Skip to content

Commit d2aded8

Browse files
Merge pull request #219 from eddyw/use-sync-external-store
2 parents 717180d + bb6e850 commit d2aded8

File tree

6 files changed

+246
-105
lines changed

6 files changed

+246
-105
lines changed

.changeset/brown-wombats-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@preact/signals-react": minor
3+
---
4+
5+
Replace useReducer with useSyncExternalStore

packages/react/package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,17 @@
3838
"prepublishOnly": "cd ../.. && pnpm build:react"
3939
},
4040
"dependencies": {
41-
"@preact/signals-core": "workspace:^1.2.1"
41+
"@preact/signals-core": "workspace:^1.2.1",
42+
"use-sync-external-store": "^1.2.0"
4243
},
4344
"peerDependencies": {
4445
"react": "17.x || 18.x"
4546
},
4647
"devDependencies": {
47-
"react": "^18.2.0",
48-
"react-dom": "^18.2.0",
4948
"@types/react": "^18.0.18",
50-
"@types/react-dom": "^18.0.6"
49+
"@types/react-dom": "^18.0.6",
50+
"@types/use-sync-external-store": "^0.0.3",
51+
"react": "^18.2.0",
52+
"react-dom": "^18.2.0"
5153
}
5254
}

packages/react/src/index.ts

Lines changed: 171 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import {
22
useRef,
33
useMemo,
44
useEffect,
5-
// @ts-ignore-next-line
6-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
7-
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED as internals,
5+
Component,
6+
type FunctionComponent,
87
} from "react";
98
import React from "react";
9+
import jsxRuntime from "react/jsx-runtime";
10+
import jsxRuntimeDev from "react/jsx-dev-runtime";
11+
import { useSyncExternalStore } from "use-sync-external-store/shim";
1012
import {
1113
signal,
1214
computed,
@@ -15,64 +17,183 @@ import {
1517
Signal,
1618
type ReadonlySignal,
1719
} from "@preact/signals-core";
18-
import { Effect, ReactDispatcher } from "./internal";
20+
import type { Effect, JsxRuntimeModule } from "./internal";
1921

2022
export { signal, computed, batch, effect, Signal, type ReadonlySignal };
2123

22-
/**
23-
* Install a middleware into React.createElement to replace any Signals in props with their value.
24-
* @todo this likely needs to be duplicated for jsx()...
25-
*/
26-
const createElement = React.createElement;
27-
// @ts-ignore-next-line
28-
React.createElement = function (type, props) {
29-
if (typeof type === "string" && props) {
30-
for (let i in props) {
31-
let v = props[i];
32-
if (i !== "children" && v instanceof Signal) {
33-
// createPropUpdater(props, i, v);
34-
props[i] = v.value;
35-
}
24+
const Empty = [] as const;
25+
const ReactElemType = Symbol.for("react.element"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
26+
const ReactMemoType = Symbol.for("react.memo"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L30
27+
const ProxyInstance = new Map<FunctionComponent<any>, FunctionComponent<any>>();
28+
const SupportsProxy = typeof Proxy === "function";
29+
30+
const ProxyHandlers = {
31+
/**
32+
* This is a function call trap for functional components.
33+
* When this is called, we know it means React did run 'Component()',
34+
* that means we can use any hooks here to setup our effect and store.
35+
*
36+
* With the native Proxy, all other calls such as access/setting to/of properties will
37+
* be forwarded to the target Component, so we don't need to copy the Component's
38+
* own or inherited properties.
39+
*
40+
* @see https://github.com/facebook/react/blob/2d80a0cd690bb5650b6c8a6c079a87b5dc42bd15/packages/react-reconciler/src/ReactFiberHooks.old.js#L460
41+
*/
42+
apply(Component: FunctionComponent, thisArg: any, argumentsList: any) {
43+
const store = useMemo(createEffectStore, Empty);
44+
45+
useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
46+
47+
const stop = store.updater._start();
48+
49+
try {
50+
const children = Component.apply(thisArg, argumentsList);
51+
return children;
52+
} catch (e) {
53+
// Re-throwing promises that'll be handled by suspense
54+
// or an actual error.
55+
throw e;
56+
} finally {
57+
// Stop effects in either case before return or throw,
58+
// Otherwise the effect will leak.
59+
stop();
3660
}
37-
}
38-
// @ts-ignore-next-line
39-
return createElement.apply(this, arguments);
61+
},
4062
};
4163

42-
/*
43-
// This breaks React's controlled components implementation
44-
function createPropUpdater(props: any, prop: string, signal: Signal) {
45-
let ref = props.ref;
46-
if (!ref) ref = props.ref = React.createRef();
47-
effect(() => {
48-
if (props) props[prop] = signal.value;
49-
let el = ref.current;
50-
if (!el) return; // unsubscribe
51-
(el as any)[prop] = signal.value;
52-
});
53-
props = null;
64+
function ProxyFunctionalComponent(Component: FunctionComponent<any>) {
65+
return ProxyInstance.get(Component) || WrapWithProxy(Component);
5466
}
55-
*/
67+
function WrapWithProxy(Component: FunctionComponent<any>) {
68+
if (SupportsProxy) {
69+
const ProxyComponent = new Proxy(Component, ProxyHandlers);
5670

57-
let finishUpdate: (() => void) | undefined;
58-
const updaterForComponent = new WeakMap<() => void, Effect>();
71+
ProxyInstance.set(Component, ProxyComponent);
72+
ProxyInstance.set(ProxyComponent, ProxyComponent);
5973

60-
function setCurrentUpdater(updater?: Effect) {
61-
// end tracking for the current update:
62-
if (finishUpdate) finishUpdate();
63-
// start tracking the new update:
64-
finishUpdate = updater && updater._start();
74+
return ProxyComponent;
75+
}
76+
77+
/**
78+
* Emulate a Proxy if environment doesn't support it.
79+
*
80+
* @TODO - unlike Proxy, it's not possible to access the type/Component's
81+
* static properties this way. Not sure if we want to copy all statics here.
82+
* Omitting this for now.
83+
*
84+
* @example - works with Proxy, doesn't with wrapped function.
85+
* ```
86+
* const el = <SomeFunctionalComponent />
87+
* el.type.someOwnOrInheritedProperty;
88+
* el.type.defaultProps;
89+
* ```
90+
*/
91+
const WrappedComponent = function () {
92+
return ProxyHandlers.apply(Component, undefined, arguments);
93+
};
94+
ProxyInstance.set(Component, WrappedComponent);
95+
ProxyInstance.set(WrappedComponent, WrappedComponent);
96+
97+
return WrappedComponent;
6598
}
6699

67-
function createUpdater(update: () => void) {
100+
/**
101+
* A redux-like store whose store value is a positive 32bit integer (a 'version').
102+
*
103+
* React subscribes to this store and gets a snapshot of the current 'version',
104+
* whenever the 'version' changes, we tell React it's time to update the component (call 'onStoreChange').
105+
*
106+
* How we achieve this is by creating a binding with an 'effect', when the `effect._callback' is called,
107+
* we update our store version and tell React to re-render the component ([1] We don't really care when/how React does it).
108+
*
109+
* [1]
110+
* @see https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore
111+
* @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
112+
*/
113+
function createEffectStore() {
68114
let updater!: Effect;
69-
effect(function (this: Effect) {
115+
let version = 0;
116+
let onChangeNotifyReact: (() => void) | undefined;
117+
118+
let unsubscribe = effect(function (this: Effect) {
70119
updater = this;
71120
});
72-
updater._callback = update;
73-
return updater;
121+
updater._callback = function () {
122+
version = (version + 1) | 0;
123+
if (onChangeNotifyReact) onChangeNotifyReact();
124+
};
125+
126+
return {
127+
updater,
128+
subscribe(onStoreChange: () => void) {
129+
onChangeNotifyReact = onStoreChange;
130+
131+
return function () {
132+
/**
133+
* Rotate to next version when unsubscribing to ensure that components are re-run
134+
* when subscribing again.
135+
*
136+
* In StrictMode, 'memo'-ed components seem to keep a stale snapshot version, so
137+
* don't re-run after subscribing again if the version is the same as last time.
138+
*
139+
* Because we unsubscribe from the effect, the version may not change. We simply
140+
* set a new initial version in case of stale snapshots here.
141+
*/
142+
version = (version + 1) | 0;
143+
onChangeNotifyReact = undefined;
144+
unsubscribe();
145+
};
146+
},
147+
getSnapshot() {
148+
return version;
149+
},
150+
};
151+
}
152+
153+
function WrapJsx<T>(jsx: T): T {
154+
if (typeof jsx !== "function") return jsx;
155+
156+
return function (type: any, props: any, ...rest: any[]) {
157+
if (typeof type === "function" && !(type instanceof Component)) {
158+
return jsx.call(jsx, ProxyFunctionalComponent(type), props, ...rest);
159+
}
160+
161+
if (type && typeof type === "object" && type.$$typeof === ReactMemoType) {
162+
type.type = ProxyFunctionalComponent(type.type);
163+
return jsx.call(jsx, type, props, ...rest);
164+
}
165+
166+
if (typeof type === "string" && props) {
167+
for (let i in props) {
168+
let v = props[i];
169+
if (i !== "children" && v instanceof Signal) {
170+
props[i] = v.value;
171+
}
172+
}
173+
}
174+
175+
return jsx.call(jsx, type, props, ...rest);
176+
} as any as T;
74177
}
75178

179+
const JsxPro: JsxRuntimeModule = jsxRuntime;
180+
const JsxDev: JsxRuntimeModule = jsxRuntimeDev;
181+
182+
/**
183+
* createElement _may_ be called by jsx runtime as a fallback in certain cases,
184+
* so we need to wrap it regardless.
185+
*
186+
* The jsx exports depend on the `NODE_ENV` var to ensure the users' bundler doesn't
187+
* include both, so one of them will be set with `undefined` values.
188+
*/
189+
React.createElement = WrapJsx(React.createElement);
190+
JsxDev.jsx && /* */ (JsxDev.jsx = WrapJsx(JsxDev.jsx));
191+
JsxPro.jsx && /* */ (JsxPro.jsx = WrapJsx(JsxPro.jsx));
192+
JsxDev.jsxs && /* */ (JsxDev.jsxs = WrapJsx(JsxDev.jsxs));
193+
JsxPro.jsxs && /* */ (JsxPro.jsxs = WrapJsx(JsxPro.jsxs));
194+
JsxDev.jsxDEV && /**/ (JsxDev.jsxDEV = WrapJsx(JsxDev.jsxDEV));
195+
JsxPro.jsxDEV && /**/ (JsxPro.jsxDEV = WrapJsx(JsxPro.jsxDEV));
196+
76197
/**
77198
* A wrapper component that renders a Signal's value directly as a Text node.
78199
*/
@@ -81,11 +202,9 @@ function Text({ data }: { data: Signal }) {
81202
}
82203

83204
// Decorate Signals so React renders them as <Text> components.
84-
//@ts-ignore-next-line
85-
const $$typeof = createElement("a").$$typeof;
86205
Object.defineProperties(Signal.prototype, {
87-
$$typeof: { configurable: true, value: $$typeof },
88-
type: { configurable: true, value: Text },
206+
$$typeof: { configurable: true, value: ReactElemType },
207+
type: { configurable: true, value: ProxyFunctionalComponent(Text) },
89208
props: {
90209
configurable: true,
91210
get() {
@@ -95,59 +214,14 @@ Object.defineProperties(Signal.prototype, {
95214
ref: { configurable: true, value: null },
96215
});
97216

98-
// Track the current dispatcher (roughly equiv to current component impl)
99-
let lock = false;
100-
const UPDATE = () => ({});
101-
let currentDispatcher: ReactDispatcher;
102-
Object.defineProperty(internals.ReactCurrentDispatcher, "current", {
103-
get() {
104-
return currentDispatcher;
105-
},
106-
set(api) {
107-
currentDispatcher = api;
108-
if (lock) return;
109-
if (api && !isInvalidHookAccessor(api)) {
110-
// prevent re-injecting useReducer when the Dispatcher
111-
// context changes to run the reducer callback:
112-
lock = true;
113-
const rerender = api.useReducer(UPDATE, {})[1];
114-
lock = false;
115-
116-
let updater = updaterForComponent.get(rerender);
117-
if (!updater) {
118-
updater = createUpdater(rerender);
119-
updaterForComponent.set(rerender, updater);
120-
} else {
121-
updater._callback = rerender;
122-
}
123-
setCurrentUpdater(updater);
124-
} else {
125-
setCurrentUpdater();
126-
}
127-
},
128-
});
129-
130-
// We inject a useReducer into every function component via CurrentDispatcher.
131-
// This prevents injecting into anything other than a function component render.
132-
const invalidHookAccessors = new Map();
133-
function isInvalidHookAccessor(api: ReactDispatcher) {
134-
const cached = invalidHookAccessors.get(api);
135-
if (cached !== undefined) return cached;
136-
// we only want the real implementation, not the warning ones
137-
const invalid =
138-
api.useCallback.length < 2 || /Invalid/.test(api.useCallback as any);
139-
invalidHookAccessors.set(api, invalid);
140-
return invalid;
141-
}
142-
143217
export function useSignal<T>(value: T) {
144-
return useMemo(() => signal<T>(value), []);
218+
return useMemo(() => signal<T>(value), Empty);
145219
}
146220

147221
export function useComputed<T>(compute: () => T) {
148222
const $compute = useRef(compute);
149223
$compute.current = compute;
150-
return useMemo(() => computed<T>(() => $compute.current()), []);
224+
return useMemo(() => computed<T>(() => $compute.current()), Empty);
151225
}
152226

153227
export function useSignalEffect(cb: () => void | (() => void)) {
@@ -158,5 +232,5 @@ export function useSignalEffect(cb: () => void | (() => void)) {
158232
return effect(() => {
159233
return callback.current();
160234
});
161-
}, []);
235+
}, Empty);
162236
}

packages/react/src/internal.d.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ export interface Effect {
77
_dispose(): void;
88
}
99

10-
export interface ReactDispatcher {
11-
useCallback(): unknown;
12-
}
13-
1410
export type Updater = Signal<unknown>;
11+
12+
export interface JsxRuntimeModule {
13+
jsx?(type: any, ...rest: any[]): unknown;
14+
jsxs?(type: any, ...rest: any[]): unknown;
15+
jsxDEV?(type: any, ...rest: any[]): unknown;
16+
}

0 commit comments

Comments
 (0)