/** * Component-scoped reactive primitives. * * Provides `useSignal`, `useComputed`, and `useEffect` that automatically * dispose when their owning component disconnects from the DOM. * * @module bquery/component */ import type { Computed } from '../reactive/computed'; import { computed } from '../reactive/computed'; import { detectDevEnvironment } from '../core/env'; import type { Signal } from '../reactive/core'; import { signal } from '../reactive/core'; import { effect } from '../reactive/effect'; import type { CleanupFn } from '../reactive/index'; /** * Holds disposable resources created inside a component scope. * All registered disposers run when the component disconnects. * @internal */ export interface ComponentScope { /** Register a cleanup function to run on dispose */ addDisposer(fn: CleanupFn): void; /** Dispose all registered resources */ dispose(): void; } /** Currently active component scope. @internal */ let currentScope: ComponentScope | undefined; /** * Sets the active component scope. * @internal */ export function setCurrentScope(scope: ComponentScope | undefined): ComponentScope | undefined { const previousScope = currentScope; currentScope = scope; return previousScope; } /** * Returns the active component scope, or undefined if none. * @internal */ export function getCurrentScope(): ComponentScope | undefined { return currentScope; } /** * Creates a new component scope that tracks disposable resources. * @internal */ export function createComponentScope(): ComponentScope { const disposers: CleanupFn[] = []; return { addDisposer(fn: CleanupFn): void { disposers.push(fn); }, dispose(): void { for (const fn of disposers) { try { fn(); } catch (error) { if ( detectDevEnvironment() && typeof console !== 'undefined' && typeof console.error === 'function' ) { console.error('bQuery component: Error disposing scoped resource', error); } } } disposers.length = 0; }, }; } /** * Creates a reactive signal scoped to the current component. * * The signal is automatically disposed when the component disconnects * from the DOM, removing all subscribers and preventing memory leaks. * * Must be called during a component lifecycle hook such as `connected`, * `beforeMount`, `onAdopted`, or `onAttributeChanged`. * * Do not create scoped primitives from `render()`. Repeated renders can * accumulate render-scoped resources until disconnect; prefer lifecycle hooks. * * @template T - The type of the signal value * @param initialValue - The initial value of the signal * @returns A new Signal instance that auto-disposes with the component * @throws {Error} If called outside a component scope * * @example * ```ts * import { component, html, useSignal } from '@bquery/bquery/component'; * * component('my-counter', { * connected() { * const count = useSignal(0); * // count.dispose() is called automatically on disconnect * }, * render({ state }) { * return html`${state.count}`; * }, * }); * ``` */ export function useSignal(initialValue: T): Signal { const scope = currentScope; if (!scope) { throw new Error( 'bQuery component: useSignal() must be called inside a component lifecycle hook. Avoid calling it directly from render()' ); } const s = signal(initialValue); scope.addDisposer(() => s.dispose()); return s; } /** * Creates a computed value scoped to the current component. * * The computed value's internal effect is automatically cleaned up * when the component disconnects from the DOM. * * Must be called during a component lifecycle hook such as `connected`, * `beforeMount`, `onAdopted`, or `onAttributeChanged`. * * Do not create scoped primitives from `render()`. Repeated renders can * accumulate render-scoped resources until disconnect; prefer lifecycle hooks. * * @template T - The type of the computed value * @param fn - Derivation function that reads reactive sources * @returns A new Computed instance that auto-cleans-up with the component * @throws {Error} If called outside a component scope * * @example * ```ts * import { component, html, useSignal, useComputed } from '@bquery/bquery/component'; * * component('my-doubler', { * connected() { * const count = useSignal(1); * const doubled = useComputed(() => count.value * 2); * }, * render({ state }) { * return html`${state.doubled}`; * }, * }); * ``` */ export function useComputed(fn: () => T): Computed { const scope = currentScope; if (!scope) { throw new Error( 'bQuery component: useComputed() must be called inside a component lifecycle hook. Avoid calling it directly from render()' ); } const c = computed(fn); scope.addDisposer(() => c.dispose()); return c; } /** * Creates a side effect scoped to the current component. * * The effect runs immediately and re-runs when its reactive dependencies * change. It is automatically disposed when the component disconnects * from the DOM. * * Must be called during a component lifecycle hook such as `connected`, * `beforeMount`, `onAdopted`, or `onAttributeChanged`. * * Do not create scoped primitives from `render()`. Repeated renders can * accumulate render-scoped resources until disconnect; prefer lifecycle hooks. * * @param fn - The effect function; may return a cleanup function * @returns A cleanup function to manually stop the effect early * @throws {Error} If called outside a component scope * * @example * ```ts * import { component, useSignal, useEffect } from '@bquery/bquery/component'; * * component('my-logger', { * connected() { * const count = useSignal(0); * useEffect(() => { * console.log('Count changed:', count.value); * return () => console.log('Cleanup'); * }); * }, * render() { return '

Logger

'; }, * }); * ``` */ export function useEffect(fn: () => void | CleanupFn): CleanupFn { const scope = currentScope; if (!scope) { throw new Error( 'bQuery component: useEffect() must be called inside a component lifecycle hook. Avoid calling it directly from render()' ); } const cleanup = effect(fn); scope.addDisposer(cleanup); return cleanup; }