UNPKG

@raven-js/reflex

Version:

Universal reactive signals for modern JavaScript - zero dependencies, SSR/hydration, DOM updates

621 lines (576 loc) 16.9 kB
/** * @author Anonyfox <max@anonyfox.com> * @license MIT * @see {@link https://github.com/Anonyfox/ravenjs} * @see {@link https://ravenjs.dev} * @see {@link https://anonyfox.com} */ /** * @file Core reactive primitives (signal/computed/effect) with: * - microtask scheduler (computed-first, then effects) * - template context that defers effect creation during renders * - render slots for transparent state preservation * - ultra-lean, zero deps, cross-platform */ // // Global, bundler-agnostic singleton runtime // const __g = /** @type {any} */ (globalThis); const __KEY__ = Symbol.for("@raven-js/reflex/runtime"); const __VERSION__ = "0.1.0"; /** * @typedef {Object} ReflexRuntime * @property {string} version * @property {Function|null} listener * @property {boolean} isRenderingTemplate * @property {Array<() => void>} deferredEffects * @property {{slots:any,cursor:number}|null} currentRenderScope * @property {Set<() => void>} pendingComputeds * @property {Set<() => void>} pendingEffects * @property {boolean} scheduled * @property {boolean} flushing * @property {Array<(v?:any)=>void>} afterFlushWaiters * @property {(err:any, where:string) => void} onError * @property {() => void} bumpWriteVersion * @property {Array<{promises:Set<Promise<any>>, track(p:Promise<any>):void}>} contextStack * @property {(opts?:{hard?:boolean}) => void} reset */ /** * Create the shared runtime state and API. This ensures all bundles/pages * use the same reactive graph, scheduler and hydration coordination. */ /** @returns {ReflexRuntime} */ function __createRuntime() { // Ensure global write version exists if (typeof __g.__REFLEX_WRITE_VERSION__ !== "number") { __g.__REFLEX_WRITE_VERSION__ = 0; } // Ensure global SSR/hydration context stack exists (browser-only usage) if (!__g.__REFLEX_CONTEXT_STACK__) __g.__REFLEX_CONTEXT_STACK__ = []; /** @type {ReflexRuntime} */ const runtime = { version: __VERSION__, listener: null, isRenderingTemplate: false, deferredEffects: [], currentRenderScope: null, pendingComputeds: new Set(), pendingEffects: new Set(), scheduled: false, flushing: false, afterFlushWaiters: [], onError(err, where) { try { __g.__REFLEX_ON_ERROR__?.(err, where); } catch {} // eslint-disable-next-line no-console console.error(err); }, bumpWriteVersion() { __g.__REFLEX_WRITE_VERSION__++; }, contextStack: /** @type {any[]} */ (__g.__REFLEX_CONTEXT_STACK__), reset(opts) { // Clear only scheduler queues and listeners; avoid clearing global stacks this.pendingComputeds.clear(); this.pendingEffects.clear(); this.afterFlushWaiters.length = 0; this.scheduled = false; this.flushing = false; this.listener = null; this.isRenderingTemplate = false; this.deferredEffects.length = 0; this.currentRenderScope = null; if (opts?.hard) { // Hard reset write version for tests if requested __g.__REFLEX_WRITE_VERSION__ = 0; } }, }; return runtime; } /** @type {ReflexRuntime} */ let __R = /** @type {ReflexRuntime} */ (__g[__KEY__]); if (!__R) { __R = __createRuntime(); __g[__KEY__] = __R; } else if (__R.version !== __VERSION__ && typeof console !== "undefined") { // Prefer the first loaded version to keep graph stable console.warn("[reflex] multiple versions loaded; using", __R.version, "ignoring", __VERSION__); } // Public, test-friendly handle to the SSR/hydration context stack export const contextStack = __R.contextStack; /** * Run function in template rendering context with deferred effect execution. * * Template context preserves component instances via render slots and defers effect * execution until template completion. Supports async templates by keeping context * active until Promise settles. * * @example * // Basic template context * const html = withTemplateContext(() => { * const count = signal(0); // preserved across renders * effect(() => console.log('deferred until template done')); * return `<div>Count: ${count()}</div>`; * }); * * @example * // Async template * const html = await withTemplateContext(async () => { * const data = await fetch('/api/data'); * return `<div>${await data.text()}</div>`; * }); * * @template T * @param {() => T | Promise<T>} fn * @param {{slots:any,cursor:number}} [scope] * @returns {T | Promise<T>} */ export function withTemplateContext(fn, scope) { const wasRendering = __R.isRenderingTemplate; __R.isRenderingTemplate = true; // Root call owns the defer queue const savedQueue = wasRendering ? __R.deferredEffects : []; if (!wasRendering) __R.deferredEffects = []; // Push render scope const prevScope = __R.currentRenderScope; if (scope) { __R.currentRenderScope = scope; __R.currentRenderScope.cursor = 0; } let finalized = false; const finalize = () => { if (finalized) return; finalized = true; // Only root executes deferred effects if (!wasRendering && __R.deferredEffects.length) { const queue = __R.deferredEffects; __R.deferredEffects = []; for (const fx of queue) { try { fx(); } catch (e) { __R.onError(e, "deferred effect"); } } } __R.isRenderingTemplate = wasRendering; if (!wasRendering) __R.deferredEffects = savedQueue; __R.currentRenderScope = prevScope; }; try { const out = fn(); // Async-aware: keep context open until it settles if (out && typeof (/** @type {any} */ (out).then) === "function") { return /** @type {Promise<T>} */ (out).then( (v) => { finalize(); return v; }, (e) => { finalize(); throw e; } ); } finalize(); return out; } catch (e) { finalize(); throw e; } } /** * Use render slot for component instance preservation during template renders. * @template T * @param {() => T} factory * @returns {T} */ function useRenderSlot(factory) { if (__R.isRenderingTemplate && __R.currentRenderScope) { const i = __R.currentRenderScope.cursor++; if (i in __R.currentRenderScope.slots) return __R.currentRenderScope.slots[i]; const v = factory(); __R.currentRenderScope.slots[i] = v; return v; } return factory(); } /* ---------------- scheduler (computed-first) ---------------- */ /** Increment global write version to track reactive state changes. */ function __bumpWriteVersion() { __R.bumpWriteVersion(); } /** * Wait for the next flush cycle to complete. * * Returns a Promise that resolves after all pending computed updates and effects * have executed. Useful for ensuring DOM updates are complete before proceeding. * * @example * // Wait for updates * const count = signal(0); * await count.set(5); * await afterFlush(); // All effects have run * * @returns {Promise<void>} resolves after a full flush cycle */ export function afterFlush() { return new Promise((res) => { __R.afterFlushWaiters.push(res); scheduleFlush(); }); } /** Schedule microtask flush if not already scheduled. */ function scheduleFlush() { if (!__R.scheduled) { __R.scheduled = true; queueMicrotask(flush); } } /** Execute computed updates first, then effects, with error isolation. */ function flush() { if (__R.flushing) return; __R.flushing = true; try { // 1) propagate computeds until stable while (__R.pendingComputeds.size) { const batch = Array.from(__R.pendingComputeds); __R.pendingComputeds.clear(); for (const update of batch) { try { update(); } catch (e) { __R.onError(e, "computed"); } } } // 2) run effects if (__R.pendingEffects.size) { const ef = Array.from(__R.pendingEffects); __R.pendingEffects.clear(); for (const run of ef) { try { run(); } catch (e) { __R.onError(e, "effect"); } } } } finally { const waiters = __R.afterFlushWaiters; __R.afterFlushWaiters = []; for (const w of waiters) w(); __R.scheduled = false; __R.flushing = false; } } /** @returns {number} internal - current write version (global) */ export function __getWriteVersion() { return __g.__REFLEX_WRITE_VERSION__; } /* ---------------- signal / computed / effect ---------------- */ /** * Create reactive signal with automatic dependency tracking and change notifications. * * Signal values are read by calling the signal as a function. Dependencies are tracked * automatically when read inside computed values or effects. Updates trigger scheduled * flushes to maintain consistent state across the reactive graph. * * @example * // Basic usage * const count = signal(0); * console.log(count()); // 0 * await count.set(5); * console.log(count()); // 5 * * @example * // Update with function * const items = signal(['apple', 'banana']); * await items.update(list => [...list, 'cherry']); * * @example * // Manual subscription * const name = signal('Alice'); * const unsubscribe = name.subscribe(value => { * console.log('Name changed:', value); * }); * // Later: unsubscribe() * * @template T * @param {T} initial * @returns {{ * (): T, * peek(): T, * set(v:T): Promise<void>, * update(fn:(v:T)=>T): Promise<void>, * subscribe(fn:(v:T)=>void): ()=>void, * _unsubscribe(fn:any): void * }} */ export function signal(initial) { return useRenderSlot(() => { /** @type {T} */ let value = initial; const subs = new Set(); /** @returns {T} */ const read = () => { if (__R.listener) { subs.add(__R.listener); const l = /** @type {any} */ (__R.listener); if (l._isEffect && l._addDependency) l._addDependency(read); else if (l._isComputed && l._trackDependency) l._trackDependency(read); } return value; }; read.peek = () => value; read.subscribe = (/** @type {any} */ fn) => { subs.add(fn); return () => subs.delete(fn); }; read._unsubscribe = (/** @type {any} */ fn) => { subs.delete(fn); }; read.set = async (/** @type {T} */ next) => { if (Object.is(value, next)) return; value = next; __bumpWriteVersion(); for (const s of subs) { if (s._isComputed) { s._invalidate(); __R.pendingComputeds.add(s._update); } else if (s._isEffect) { __R.pendingEffects.add(s); } else if (typeof s === "function") { try { s(value); } catch (e) { __R.onError(e, "subscriber"); } } } scheduleFlush(); return afterFlush(); }; read.update = (/** @type {(v:T)=>T} */ fn) => read.set(fn(value)); return read; }); } /** * Create computed value that automatically updates when dependencies change. * * Computed values cache their result and only recompute when their signal dependencies * change. They update during the computed phase before effects run, ensuring consistent * state. Circular dependencies are detected and throw errors. * * @example * // Basic derived state * const count = signal(0); * const doubled = computed(() => count() * 2); * console.log(doubled()); // 0 * await count.set(5); * console.log(doubled()); // 10 * * @example * // Complex computation * const todos = signal([]); * const completedTodos = computed(() => * todos().filter(todo => todo.completed) * ); * const progress = computed(() => * completedTodos().length / todos().length * ); * * @example * // Conditional dependencies * const mode = signal('light'); * const theme = computed(() => * mode() === 'dark' ? getDarkTheme() : getLightTheme() * ); * * @template T * @param {() => T} fn * @returns {{():T, peek():T, _unsubscribe(fn:any):void, _isComputed:true}} */ export function computed(fn) { return useRenderSlot(() => { /** @type {T} */ let value; let valid = false; let computing = false; const deps = new Set(); // Set<signal-read> const dependents = new Set(); // Set<effect|computed> function cleanup() { for (const d of deps) d._unsubscribe(comp); deps.clear(); } function recompute() { if (computing) throw new Error("Circular dependency in computed"); computing = true; const prev = __R.listener; cleanup(); __R.listener = comp; try { const next = fn(); const changed = !Object.is(value, next); value = next; valid = true; if (changed) { for (const dep of dependents) { if (dep._isComputed) { dep._invalidate(); __R.pendingComputeds.add(dep._update); } else if (dep._isEffect) { __R.pendingEffects.add(dep); } } } } finally { __R.listener = prev; computing = false; } } /** @type {any} */ const comp = () => { if (__R.listener) dependents.add(__R.listener); if (!valid) recompute(); return value; }; comp._isComputed = true; comp._invalidate = () => { valid = false; }; comp._update = recompute; comp._trackDependency = (/** @type {any} */ dep) => { deps.add(dep); }; comp.peek = () => value; comp._unsubscribe = (/** @type {any} */ fn) => { dependents.delete(fn); if (dependents.size === 0) cleanup(); }; recompute(); return comp; }); } /** * Create side effect that runs when its signal dependencies change. * * Effects run after computed values have updated, ensuring consistent state. * They automatically track signal dependencies and re-run when those signals change. * Returns a disposer function to stop the effect and clean up dependencies. * * @example * // Basic side effect * const count = signal(0); * const dispose = effect(() => { * console.log('Count is:', count()); * }); * // Logs: "Count is: 0" * * await count.set(5); * // Logs: "Count is: 5" * * @example * // DOM synchronization * const title = signal('Welcome'); * effect(() => { * document.title = title(); * }); * * @example * // Async effects (promises tracked in SSR contexts) * const userId = signal(null); * effect(async () => { * if (userId()) { * const response = await fetch(`/api/users/${userId()}`); * const user = await response.json(); * console.log('User loaded:', user); * } * }); * * @param {() => any} fn * @returns {() => void} disposer */ export function effect(fn) { return useRenderSlot(() => { let disposed = false; const deps = new Set(); function run() { if (disposed) return; const prevListener = __R.listener; // reset deps (unsubscribe from old first) for (const d of Array.from(deps)) d._unsubscribe(runAny); deps.clear(); __R.listener = runAny; try { const out = fn(); const ctx = contextStack[contextStack.length - 1]; if (out && typeof out.then === "function" && ctx) ctx.track(out); return out; } finally { __R.listener = prevListener; } } /** @type {any} */ const runAny = run; runAny._isEffect = true; runAny._addDependency = (/** @type {any} */ dep) => { deps.add(dep); }; // Initial run: defer if inside template if (__R.isRenderingTemplate) { __R.deferredEffects.push(() => { try { run(); } catch (e) { __R.onError(e, "effect"); } }); } else { try { run(); } catch (e) { __R.onError(e, "effect"); } } return () => { disposed = true; for (const d of Array.from(deps)) d._unsubscribe(runAny); deps.clear(); }; }); } /** * Run function without automatic dependency tracking. * * Prevents signals read inside the function from being tracked as dependencies * by the current computed or effect. Useful for accessing signals without * creating reactive relationships. * * @example * // Read signal without tracking * const count = signal(0); * const doubled = computed(() => { * const debug = untrack(() => count()); // not tracked * console.log('Debug:', debug); * return count() * 2; // this IS tracked * }); * * @template T * @param {() => T} fn * @returns {T} */ export function untrack(fn) { const prev = __R.listener; __R.listener = null; try { return fn(); } finally { __R.listener = prev; } } /** Test helper: reset scheduler state; use wisely in tests only. */ export function __resetReflexForTests() { __R.reset({ hard: false }); }