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
text/typescript
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;
}