Skip to content

Commit 5153505

Browse files
committed
Switch out useReducer for useSyncExternalStore
1 parent dc7db0b commit 5153505

1 file changed

Lines changed: 80 additions & 44 deletions

File tree

packages/react/src/index.ts

Lines changed: 80 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -20,42 +20,22 @@ import {
2020
Signal,
2121
type ReadonlySignal,
2222
} from "@preact/signals-core";
23+
import { useSyncExternalStore } from "use-sync-external-store/shim/index";
2324
import type { Effect, JsxRuntimeModule } from "./internal";
2425

2526
export { signal, computed, batch, effect, Signal, type ReadonlySignal };
2627

2728
const Empty = [] as const;
2829
const ReactElemType = Symbol.for("react.element"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
2930

30-
// Idea:
31-
// - For Function components: Use CurrentDispatcher to add the signal effect
32-
// store to every component (kinda expensive?).
33-
// - Actually we could probably skip using useSyncExternalStore and just use
34-
// the effect instance directly... Ideally that'd means components that
35-
// don't use any signals incur no persistent memory cost, outside of an
36-
// empty call to useReducer to generate a rerender function.
37-
//
38-
// Though maybe useSyncExternalStore makes it more concurrent mode safe? It
39-
// seems that useSyncExternalStore may be efficient enough if we don't
40-
// allocate more objects (aka the store). Though the
41-
// `pushStoreConsistencyCheck` function would have a object per
42-
// component... and then it'd have to loop through all of them to check if
43-
// a store changed while rendering (if doing non-blocking work? So
44-
// something concurrent related?). useSyncExternalStore probably isn't
45-
// intended to be used on EVERY component.
46-
//
47-
// Conclusion: Let's avoid useSyncExternalStore for now, and bring it if we
48-
// find bugs.
49-
//
50-
// - For class components: Use CurrentOwner to mimic the above behavior
51-
5231
interface ReactDispatcher {
32+
useRef: typeof useRef;
5333
useCallback: typeof useCallback;
5434
useReducer: typeof useReducer;
35+
useSyncExternalStore: typeof useSyncExternalStore;
5536
}
5637

5738
let finishUpdate: (() => void) | undefined;
58-
const updaterForComponent = new WeakMap<object, Effect>();
5939

6040
function setCurrentUpdater(updater?: Effect) {
6141
// end tracking for the current update:
@@ -64,13 +44,63 @@ function setCurrentUpdater(updater?: Effect) {
6444
finishUpdate = updater && updater._start();
6545
}
6646

67-
function createUpdater(rerender: () => void): Effect {
47+
interface EffectStore {
48+
updater: Effect;
49+
subscribe(onStoreChange: () => void): () => void;
50+
getSnapshot(): number;
51+
}
52+
53+
/**
54+
* A redux-like store whose store value is a positive 32bit integer (a 'version').
55+
*
56+
* React subscribes to this store and gets a snapshot of the current 'version',
57+
* whenever the 'version' changes, we tell React it's time to update the component (call 'onStoreChange').
58+
*
59+
* How we achieve this is by creating a binding with an 'effect', when the `effect._callback' is called,
60+
* we update our store version and tell React to re-render the component ([1] We don't really care when/how React does it).
61+
*
62+
* [1]
63+
* @see https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore
64+
* @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
65+
*/
66+
function createEffectStore(): EffectStore {
6867
let updater!: Effect;
69-
effect(function (this: Effect) {
68+
let version = 0;
69+
let onChangeNotifyReact: (() => void) | undefined;
70+
71+
let unsubscribe = effect(function (this: Effect) {
7072
updater = this;
7173
});
72-
updater._callback = rerender;
73-
return updater;
74+
updater._callback = function () {
75+
version = (version + 1) | 0;
76+
if (onChangeNotifyReact) onChangeNotifyReact();
77+
};
78+
79+
return {
80+
updater,
81+
subscribe(onStoreChange) {
82+
onChangeNotifyReact = onStoreChange;
83+
84+
return function () {
85+
/**
86+
* Rotate to next version when unsubscribing to ensure that components are re-run
87+
* when subscribing again.
88+
*
89+
* In StrictMode, 'memo'-ed components seem to keep a stale snapshot version, so
90+
* don't re-run after subscribing again if the version is the same as last time.
91+
*
92+
* Because we unsubscribe from the effect, the version may not change. We simply
93+
* set a new initial version in case of stale snapshots here.
94+
*/
95+
version = (version + 1) | 0;
96+
onChangeNotifyReact = undefined;
97+
unsubscribe();
98+
};
99+
},
100+
getSnapshot() {
101+
return version;
102+
},
103+
};
74104
}
75105

76106
// To track when we are entering and exiting a component render (i.e. before and
@@ -99,9 +129,11 @@ function createUpdater(rerender: () => void): Effect {
99129
// a different erroring dispatcher before invoking the reducer and resets it
100130
// right after.
101131
//
102-
// When we invoke our own useReducer while entering a component render, we
103-
// need to prevent this change from re-triggering our logic. We do this by
104-
// using a lock to prevent the setter from running while we are in the setter.
132+
// The useSyncExternalStore shim will use some of these hooks when we invoke
133+
// it while entering a component render. We need to prevent this dispatcher
134+
// change caused by these hooks from re-triggering our entering logic (it
135+
// would cause an infinite loop if we did not). We do this by using a lock to
136+
// prevent the setter from running while we are in the setter.
105137
//
106138
// When a Component's function body invokes useReducer, useState, or useMemo,
107139
// this change in dispatcher should not signal that we are exiting a component
@@ -114,7 +146,6 @@ function createUpdater(rerender: () => void): Effect {
114146
// during this change. Because these other dispatchers do not pass the
115147
// ContextOnlyDispatcher check, they do not affect our logic.
116148
let lock = false;
117-
const FORCE_UPDATE = () => ({});
118149
let currentDispatcher: ReactDispatcher | null = null;
119150
Object.defineProperty(ReactInternals.ReactCurrentDispatcher, "current", {
120151
get() {
@@ -134,29 +165,34 @@ Object.defineProperty(ReactInternals.ReactCurrentDispatcher, "current", {
134165
!isContextOnlyDispatcher(currentDispatcher) &&
135166
isContextOnlyDispatcher(nextDispatcher);
136167

168+
// Update the current dispatcher now so the hooks inside of the
169+
// useSyncExternalStore shim get the right dispatcher.
170+
currentDispatcher = nextDispatcher;
137171
if (isEnteringComponentRender) {
138172
lock = true;
139-
// TODO: Consider switching to useSyncExternalStore
140-
const rerender = nextDispatcher.useReducer(FORCE_UPDATE, {})[1];
141-
lock = false;
142-
143-
let updater = updaterForComponent.get(rerender);
144-
if (!updater) {
145-
updater = createUpdater(rerender);
146-
updaterForComponent.set(rerender, updater);
173+
const storeRef = nextDispatcher.useRef<EffectStore>();
174+
if (storeRef.current == null) {
175+
storeRef.current = createEffectStore();
147176
}
148177

149-
setCurrentUpdater(updater);
178+
const store = storeRef.current;
179+
useSyncExternalStore(
180+
store.subscribe,
181+
store.getSnapshot,
182+
store.getSnapshot
183+
);
184+
lock = false;
185+
186+
setCurrentUpdater(store.updater);
150187
} else if (isExitingComponentRender) {
151188
setCurrentUpdater();
152189
}
153-
154-
currentDispatcher = nextDispatcher;
155190
},
156191
});
157192

158-
// We inject a useReducer into every function component via CurrentDispatcher.
159-
// This prevents injecting into anything other than a function component render.
193+
// We inject a useSyncExternalStore into every function component via
194+
// CurrentDispatcher. This prevents injecting into anything other than a
195+
// function component render.
160196
const dispatcherTypeCache = new Map();
161197
function isContextOnlyDispatcher(dispatcher: ReactDispatcher | null) {
162198
// Treat null the same as the ContextOnlyDispatcher.

0 commit comments

Comments
 (0)