create-react-signals
Version:
A factory function to create signals for React
300 lines (299 loc) • 11.7 kB
JavaScript
/* 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 };
}