import { createComputed, createEffect, createMemo, createScope, isFunction, isMemo, isRecord, isSlot, type MaybeCleanup, type MaybePromise, type Memo, match, type Signal, type SingleMatchHandlers, type SlotDescriptor, untrack, } from '@zeix/cause-effect' import { InvalidCustomElementError, InvalidReactivesError } from '../errors' import { getSignals } from '../internal' import type { ComponentProps, EffectDescriptor, FactoryResult, Falsy, } from '../types' import { DEV_MODE, elementName, isCustomElement, LOG_WARN } from '../util' /* === Types === */ /** * A reactive value that drives a DOM update or a slot injection. * * Three forms are accepted: * - `keyof P` — a string property name on the host; reads `host[name]` and * registers it as a signal dependency automatically. * - `Signal` — any signal; `.get()` is called inside the reactive effect. * - `() => T | Promise | null | undefined` — a thunk wrapped in `createComputed`; * all signals read inside are tracked in the pure phase. Returning `null` or * `undefined` drives the `nil` path; an async thunk becomes a `Task` signal. */ type Reactive = | keyof P | Signal | (() => T | Promise | null | undefined) /** * A map of child component property names to the reactive values to inject into them. * Passed as the second argument to `pass()`. Keys must be property names of the target component `Q`. */ type PassedProps

= { [K in keyof Q & string]?: Reactive | SlotDescriptor } /** * The `watch` helper type in `FactoryContext`. * * Drives a reactive effect from a signal source (property name, Signal, thunk, * or array). Only the declared sources trigger re-runs — incidental reads inside * the handler are not tracked. Returns an `EffectDescriptor`. * * Thunk form `() => T` is wrapped in `createComputed`, so all signals read inside * it are tracked in the pure phase — useful for deriving or transforming values * before the side-effectful handler runs. */ type WatchHelper

= { ( source: K, handler: (value: P[K]) => MaybePromise, ): EffectDescriptor ( source: K, handlers: SingleMatchHandlers, ): EffectDescriptor ( source: Signal, handler: (value: T) => MaybePromise, ): EffectDescriptor ( source: Signal, handlers: SingleMatchHandlers, ): EffectDescriptor ( source: () => T | Promise | null | undefined, handler: (value: T) => MaybePromise, ): EffectDescriptor ( source: () => T | Promise | null | undefined, handlers: SingleMatchHandlers, ): EffectDescriptor ( source: Array, P>>, handler: (values: any[]) => MaybePromise, ): EffectDescriptor } /** * The `pass` helper type in `FactoryContext`. * * Passes reactive values to a descendant Le Truc component's Slot-backed signals. * Supports single-element and Memo targets (per-element lifecycle for Memo). */ type PassHelper

= { ( target: (HTMLElement & Q) | Falsy, props: PassedProps, ): EffectDescriptor ( target: Memo<(HTMLElement & Q)[]> | Falsy, props: PassedProps, ): EffectDescriptor } /* === Internal Helpers === */ /** * Recursively activate a `FactoryResult` array of effect descriptors. * * Nested arrays are flattened; falsy values are skipped. Each truthy descriptor * is called immediately so its reactive effects register in the current scope. * * @since 2.0 * @param {FactoryResult} result - Flat or nested array of effect descriptors to activate */ const activateResult = (result: FactoryResult): void => { for (const descriptor of result) { if (Array.isArray(descriptor)) activateResult(descriptor) else if (descriptor) descriptor() } } /** * Resolve a `Reactive` value to a Signal usable by `match`. * * - String: look up the signal in the component's signal map; fall back to a computed * that reads `host[name]` (covers properties added via `Object.defineProperty`). * - Thunk `() => T | Promise | null | undefined`: wrapped in `createComputed` * so all signals read inside are tracked in the pure phase. Async thunks become * Task signals. * - Signal: use directly. * * @since 2.0 * @param {HTMLElement & P} host - The component host element * @param {Reactive | { get: () => T; set?: (value: T) => void }} source - Property name string, signal, thunk, or descriptor to resolve * @returns {Signal} Resolved signal ready for use with `match()` */ const toSignal = ( host: HTMLElement & P, source: Reactive | SlotDescriptor, ): Signal | SlotDescriptor => { if (isFunction(source)) return createComputed(source) if (typeof source === 'string') { const sig = getSignals(host)[source] if (sig) return sig as Signal return createMemo(() => (host as any)[source]) } if ( source && typeof source === 'object' && 'get' in source && !(Symbol.toStringTag in source) ) { return source as SlotDescriptor } return source as Signal } /* === Exported Functions === */ /** * Create a `watch` helper bound to a specific component host. * * `watch` wraps `match` to create a reactive effect driven by explicitly declared * signal sources. Only the declared source signals trigger re-runs — other reads * inside the handler are not tracked. Returns an `EffectDescriptor`. * * @since 2.0 * @param {HTMLElement & P} host - The component host element * @returns {WatchHelper

} Bound `watch` function for the given host */ const makeWatch =

( host: HTMLElement & P, ): WatchHelper

=> { function watch( source: K, handler: (value: P[K]) => MaybePromise, ): EffectDescriptor function watch( source: K, handlers: SingleMatchHandlers, ): EffectDescriptor function watch( source: Signal, handler: (value: T) => MaybePromise, ): EffectDescriptor function watch( source: Signal, handlers: SingleMatchHandlers, ): EffectDescriptor function watch( source: () => T | Promise | null | undefined, handler: (value: T) => MaybePromise, ): EffectDescriptor function watch( source: () => T | Promise | null | undefined, handlers: SingleMatchHandlers, ): EffectDescriptor function watch( source: Array, P>>, handler: (values: any[]) => MaybePromise, ): EffectDescriptor function watch( source: | Reactive, P> | Array, P>>, handlerOrHandlers: | ((value: any) => MaybePromise) | SingleMatchHandlers, ): EffectDescriptor { return () => { if (Array.isArray(source)) { const signals = source.map(s => toSignal(host, s)) const handler = handlerOrHandlers as ( values: any[], ) => MaybePromise return createEffect(() => match(signals, { ok: values => untrack(() => handler(values)) }), ) } const signal = toSignal(host, source) if (typeof handlerOrHandlers === 'function') { return createEffect(() => match(signal, { ok: value => untrack(() => handlerOrHandlers(value)), }), ) } return createEffect(() => match(signal, handlerOrHandlers)) } } return watch } /** * Create a `pass` helper bound to a specific component host. * * `pass` passes reactive values to a descendant Le Truc component by swapping * its Slot-backed signals. The original signals are restored when the component * disconnects. Supports both single-element and `Memo` targets. * * For Memo targets, uses per-element lifecycle: signals are swapped when elements * enter the collection and restored when they leave. * * @since 2.0 * @param {HTMLElement & P} host - The component host element * @returns {PassHelper

} Bound `pass` function for the given host */ const makePass =

( host: HTMLElement & P, ): PassHelper

=> { /** * Perform the slot-swap for a single target element. * Returns a cleanup that restores all original slot signals. */ const swapSlots = ( target: HTMLElement & Q, props: PassedProps, ): (() => void) | undefined => createScope(() => { if (!isCustomElement(target)) throw new InvalidCustomElementError( target, `pass from ${elementName(host)}`, ) if (!isRecord(props)) throw new InvalidReactivesError(host, target, props) const signals = getSignals(target) const targetName = elementName(target) const cleanups: (() => void)[] = [] for (const [prop, reactive] of Object.entries(props)) { if (reactive == null) continue if (!(prop in target)) { if (DEV_MODE) console[LOG_WARN]( `pass(): property '${prop}' does not exist on ${targetName}`, ) continue } const signal = toSignal(host, reactive) if (!signal) continue // Slot-backed (Le Truc component) — replace and restore on cleanup const slot = signals[prop] if (isSlot(slot)) { const original = slot.current() slot.replace(signal) cleanups.push(() => slot.replace(original)) continue } if (DEV_MODE) console[LOG_WARN]( `pass(): property '${prop}' on ${targetName} is not Slot-backed — use setProperty() for non-Le Truc elements`, ) } if (cleanups.length) return () => { for (const c of cleanups) c() } }) function pass( target: (HTMLElement & Q) | Falsy, props: PassedProps, ): EffectDescriptor function pass( target: Memo<(HTMLElement & Q)[]> | Falsy, props: PassedProps, ): EffectDescriptor function pass( target: (HTMLElement & Q) | Memo<(HTMLElement & Q)[]> | Falsy, props: PassedProps, ): EffectDescriptor { return () => { if (!target) return if (isMemo<(HTMLElement & Q)[]>(target)) { // Memo target: per-element lifecycle via createEffect createEffect(() => { for (const el of target.get()) createScope(() => swapSlots(el, props)) }) } else { // Single element: swap slots directly in current scope swapSlots(target, props) } } } return pass } /** * Create per-element reactive effects from a `Memo`. * * When elements enter the collection, their effects are created in a per-element * scope; when they leave, their effects are disposed with that scope. * * The callback receives a single element and returns a `FactoryResult` (array of * `EffectDescriptor`s) or a single `EffectDescriptor` (single-descriptor shortcut). * Falsy values can also be returned to skip conditionally. * * @since 2.0 */ function each( memo: Memo, callback: (element: E) => FactoryResult | EffectDescriptor | Falsy, ): EffectDescriptor { return () => { createEffect(() => { for (const element of memo.get()) { createScope(() => { const result = callback(element) if (Array.isArray(result)) activateResult(result) else if (typeof result === 'function') result() }) } }) } } export { activateResult, type EffectDescriptor, each, type FactoryResult, type Falsy, makePass, makeWatch, type PassedProps, type PassHelper, type Reactive, type WatchHelper, }