UNPKG

create-react-signals

Version:

A factory function to create signals for React

300 lines (299 loc) 11.7 kB
/* eslint @typescript-eslint/no-explicit-any: off */ import { createElement, isValidElement, useEffect, useState } from 'react'; import { applyProps } from './applyProps.js'; export function createReactSignals(createSignal, recursive, valueProp, fallbackValueProp, handlePromise) { const SIGNAL = Symbol('REACT_SIGNAL'); const isSignal = (x) => !!(x === null || x === void 0 ? void 0 : x[SIGNAL]); const EMPTY = Symbol(); const wrapProxy = (sub, get, set) => { const sig = new Proxy((() => { }), { get(target, prop) { if (prop === SIGNAL) { return [sub, get, set]; } if (prop === valueProp) { return get(); } if (valueProp && prop === fallbackValueProp) { prop = valueProp; } if (recursive) { let value = EMPTY; return wrapProxy((callback) => sub(() => { try { const obj = get(); const prevValue = value; value = obj[prop]; if (typeof value !== 'function' && Object.is(prevValue, value)) { return; } } catch (_e) { // NOTE shouldn't we catch all errors? } callback(); }), () => { const obj = get(); value = obj[prop]; if (typeof value === 'function') { return value.bind(obj); } return value; }, (path, val) => { set([prop, ...path], val); }); } return target[prop]; }, set(target, prop, value) { if (prop === valueProp) { set([], value); return true; } if (!recursive) { target[prop] = value; return true; } return false; }, apply(_target, _thisArg, args) { return wrapProxy(sub, () => get()(...args), () => { throw new Error('Cannot set a value'); }); }, }); return sig; }; const signalCache = new WeakMap(); const getSignal = (...args) => { let cache = signalCache; for (let i = 0; i < args.length - 1; ++i) { const arg = args[i]; let nextCache = cache.get(arg); if (!nextCache) { nextCache = new WeakMap(); cache.set(arg, nextCache); } cache = nextCache; } const lastArg = args[args.length - 1]; let sig = cache.get(lastArg); if (!sig) { sig = wrapProxy(...createSignal(...args)); cache.set(lastArg, sig); } return sig; }; const subscribeSignal = (sig, callback) => { return sig[SIGNAL][0](callback); }; const readSignal = (sig) => { const value = sig[SIGNAL][1](); if (handlePromise && value instanceof Promise) { return handlePromise(value); } return value; }; // ---------------------------------------------------------------------- const findAllSignals = (target) => { const seen = new WeakSet(); const find = (x) => { if (typeof x === 'object' && x !== null) { if (isValidElement(x)) { return []; } if (seen.has(x)) { return []; } seen.add(x); } if (isSignal(x)) { return [x]; } if (Array.isArray(x)) { return x.flatMap(find); } if (typeof x === 'object' && x !== null) { return Object.values(x).flatMap(find); } return []; }; return find(target); }; const fillAllSignalValues = (target) => { const seen = new WeakSet(); const fill = (x) => { if (typeof x === 'object' && x !== null) { if (isValidElement(x)) { return x; } if (seen.has(x)) { return x; } seen.add(x); } if (isSignal(x)) { return readSignal(x); } if (Array.isArray(x)) { let changed = false; const x2 = x.map((item) => { const item2 = fill(item); if (item !== item2) { changed = true; // HACK side effect } return item2; }); return changed ? x2 : x; } if (typeof x === 'object' && x !== null) { let changed = false; const x2 = Object.fromEntries(Object.entries(x).map(([key, value]) => { const value2 = fill(value); if (value !== value2) { changed = true; // HACK side effect } return [key, value2]; })); return changed ? x2 : x; } return x; }; return fill(target); }; const register = (fallback, signalsInChildren, signalsInProps, children, props) => { const unsubs = []; return (instance) => { unsubs.splice(0).forEach((unsub) => unsub()); if (!instance) { return; } // NOTE it would be nicer if we can batch callbacks if (signalsInChildren.length) { const callback = () => { try { applyProps(instance, { children: fillAllSignalValues(children).join(''), }); } catch (_e) { // NOTE shouldn't we catch all errors? fallback(); } }; signalsInChildren.forEach((sig) => unsubs.push(subscribeSignal(sig, () => { try { const v = readSignal(sig); if (typeof v === 'string' || typeof v === 'number') { callback(); return; } } catch (_e) { // NOTE shouldn't we catch all errors? } fallback(); }))); } Object.entries(props || {}).forEach(([key, val]) => { const sigs = signalsInProps[key]; if (sigs) { const callback = () => { try { applyProps(instance, { [key]: fillAllSignalValues(val), }); } catch (_e) { // NOTE shouldn't we catch all errors? fallback(); } }; sigs.forEach((sig) => unsubs.push(subscribeSignal(sig, () => { try { const v = readSignal(sig); if (!(v instanceof Promise)) { callback(); return; } } catch (_e) { // NOTE shouldn't we catch all errors? } fallback(); }))); } }); }; }; const useMemoList = (list, compareFn = (a, b) => a === b) => { const [state, setState] = useState(list); const listChanged = list.length !== state.length || list.some((arg, index) => !compareFn(arg, state[index])); if (listChanged) { // schedule update, triggers re-render setState(list); } return listChanged ? list : state; }; const SignalsRerenderer = ({ uncontrolled, signals, render, }) => { const [state, setState] = useState({ uncontrolled, }); const uncontrolledFallback = !!state.uncontrolled && (() => setState({})); const memoedSignals = useMemoList(state.uncontrolled ? [] : signals); useEffect(() => { const rerender = () => setState({}); const unsubs = memoedSignals.map((sig) => subscribeSignal(sig, rerender)); // FIXME we need to check if signals are updated // before the effect fires, and trigger rerender return () => unsubs.forEach((unsub) => unsub()); }, [memoedSignals]); return render(uncontrolledFallback); }; const inject = (createElementOrig) => { const createElementInjected = (type, props, ...children) => { const signalsInChildren = children.flatMap((child) => isSignal(child) ? [child] : []); const signalsInProps = Object.fromEntries(Object.entries(props || {}).flatMap(([key, value]) => { const sigs = findAllSignals(value); if (sigs.length) { return [[key, sigs]]; } return []; })); const allSignalsInProps = Object.values(signalsInProps).flat(); // case: no signals if (!signalsInChildren.length && !allSignalsInProps.length) { return createElementOrig(type, props, ...children); } const hasNonDisplayableChildren = children.some((child) => !isSignal(child) && typeof child !== 'string' && typeof child !== 'number'); // case: rerenderer const getChildren = () => signalsInChildren.length ? children.map((child) => isSignal(child) ? readSignal(child) : child) : children; const getProps = (uncontrolledFallback) => { let propsToReturn = props; if (allSignalsInProps.length) { propsToReturn = fillAllSignalValues(props); } if (uncontrolledFallback) { propsToReturn = { ...propsToReturn, ref: register(uncontrolledFallback, signalsInChildren, signalsInProps, children, props), }; } return propsToReturn; }; return createElementOrig(SignalsRerenderer, { uncontrolled: typeof type === 'string' && !hasNonDisplayableChildren, signals: [...signalsInChildren, ...allSignalsInProps], render: (uncontrolledFallback) => createElementOrig(type, getProps(uncontrolledFallback), ...getChildren()), }); }; return createElementInjected; }; return { getSignal, inject }; }