UNPKG

lupine.web

Version:

lupine.web is a extremely fast, small size and lightweight frontend framework, using React TSX syntax.

203 lines (168 loc) 7.83 kB
import { isFrontEnd } from '../lib/is-frontend'; import { RefProps } from '../jsx'; import { mountOuterComponent } from './mount-component'; // ───────────────────────────────────────────── // ComponentStateStore // ───────────────────────────────────────────── export class ComponentStateStore<P = any> { states: any[] = []; hookIndex = 0; componentFn: (props: P) => any; componentProps: P; /** The internal ref that tracks the component's root DOM element */ domRef: RefProps = {}; /** Prevent concurrent rerenders from stacking */ isDirty = false; /** True after the root element has been mounted for the first time */ _mounted = false; /** User's original onLoad/onUnload — saved once so rerenders don't re-read the mutated ref */ _userOnLoad?: ((el: Element) => Promise<void>) | null; _userOnUnload?: ((el: Element) => Promise<void>) | null; constructor(componentFn: (props: P) => any, componentProps: P) { this.componentFn = componentFn; this.componentProps = componentProps; } async rerender() { if (!this.domRef.current) return; const el = this.domRef.current as Element; const newVNode = await evaluateComponentWithStore(this); await mountOuterComponent(el, newVNode); } } // ───────────────────────────────────────────── // Global current-store pointer // Safe because: // • cleared BEFORE any `await` in renderComponentAsync // • Node.js is single-threaded: synchronous code never interleaves // ───────────────────────────────────────────── let _currentStore: ComponentStateStore | null = null; export const setCurrentStore = (store: ComponentStateStore) => { _currentStore = store; }; export const getCurrentStore = (): ComponentStateStore | null => _currentStore; export const clearCurrentStore = () => { _currentStore = null; }; // ───────────────────────────────────────────── // useState — public API // ───────────────────────────────────────────── /** * React-compatible hook for local component state in lupine.web. * * Rules (same as React): * - Call only at the top level of a component function * - Call before any `await` inside the component * - Must be called during renderComponentAsync (not in event handlers) * * Usage: * const [count, setCount] = useState(0); * const [label, setLabel] = useState('hello'); */ export function useState<T>(initial: T): [T, (val: T | ((prev: T) => T)) => void] { const store = _currentStore; if (!store) { throw new Error('❌ useState must be called inside a component function.'); } const idx = store.hookIndex++; // Only set the initial value once — preserve across rerenders if (idx >= store.states.length) { store.states.push(initial); } const setState = (newVal: T | ((prev: T) => T)) => { // SSR: no DOM to update if (!isFrontEnd()) return; // Batch: if a rerender is already scheduled, just update the value const resolved = typeof newVal === 'function' ? (newVal as (prev: T) => T)(store.states[idx]) : newVal; store.states[idx] = resolved; if (store.isDirty) return; store.isDirty = true; Promise.resolve().then(async () => { try { await store.rerender(); } finally { store.isDirty = false; } }); }; return [store.states[idx], setState]; } // ───────────────────────────────────────────── // Internal: build the _stateRef for a store and merge with an existing user ref // Called from renderComponentAsync after type.call() // ───────────────────────────────────────────── export const buildStateRef = (store: ComponentStateStore, existingRef?: RefProps): RefProps => { // Use the user's ref object as the target (or create a fresh one). // bindRef writes .current, .$, .$all, etc. directly onto the returned object — // so we must return the SAME object the user holds, otherwise ref.current / // ref.$ would never get updated. const target: RefProps = existingRef ?? {}; // Save the user's ORIGINAL callbacks the first time only (before first mount). // On rerenders, target.onLoad is already our wrapper — don't re-read it. if (!store._mounted) { store._userOnLoad = existingRef?.onLoad ?? null; store._userOnUnload = existingRef?.onUnload ?? null; } if (existingRef?.referToCssId) { target.referToCssId = existingRef.referToCssId; } // Install a fresh wrapper every render so bindRef always calls OUR function. target.onLoad = async (el: Element) => { // Always keep the live element reference up to date. store.domRef.current = el; // Call the user's original onLoad ONLY on the initial mount. // On rerenders we skip it — this prevents infinite loops when onLoad // contains setState (which would trigger rerender → onLoad → setState → …). // This mirrors React's useEffect(() => fn, []) semantics. if (!store._mounted) { store._mounted = true; // set BEFORE await to block concurrent initial calls if (store._userOnLoad) { await store._userOnLoad(el); } } }; if (store._userOnUnload) { target.onUnload = async (el: Element) => { await store._userOnUnload!(el); }; } // Bind the programmatic functional component refresh trigger target.refresh = async () => { store.domRef.current = target.current; // ensure the store has the latest DOM node reference from the ref await store.rerender(); }; return target; }; // ───────────────────────────────────────────── // Unified Component Execution Engine // Safely executes a component with its StateStore, resolving promises, // and injecting lifecycle refs onto the returned VNode. // ───────────────────────────────────────────── export async function evaluateComponentWithStore(store: ComponentStateStore<any>): Promise<any> { // Reset hook index so hooks run in the same order (states[] values are preserved). store.hookIndex = 0; // ① Set _currentStore so that useState() calls inside componentFn read from THIS store. setCurrentStore(store); const resultMaybePromise = store.componentFn.call(null, store.componentProps); // ← Clear global pointer BEFORE any await (Node.js single-thread guarantee keeps this safe) clearCurrentStore(); let dom: any = null; if ( resultMaybePromise && typeof resultMaybePromise === 'object' && 'then' in resultMaybePromise && typeof (resultMaybePromise as any).then === 'function' ) { dom = await resultMaybePromise; } else { dom = resultMaybePromise; } // ② Re-inject stateRef onto the new root element so that after it mounts, // store.domRef.current is updated to the NEW element via the new ref's onLoad. if (dom && typeof dom === 'object' && !Array.isArray(dom)) { if (!dom.props) dom.props = {}; if (store.hookIndex > 0 || dom.props.ref) { dom.props.ref = buildStateRef(store, dom.props.ref); } } return dom; }