@@ -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" ;
2324import type { Effect , JsxRuntimeModule } from "./internal" ;
2425
2526export { signal , computed , batch , effect , Signal , type ReadonlySignal } ;
2627
2728const Empty = [ ] as const ;
2829const 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-
5231interface ReactDispatcher {
32+ useRef : typeof useRef ;
5333 useCallback : typeof useCallback ;
5434 useReducer : typeof useReducer ;
35+ useSyncExternalStore : typeof useSyncExternalStore ;
5536}
5637
5738let finishUpdate : ( ( ) => void ) | undefined ;
58- const updaterForComponent = new WeakMap < object , Effect > ( ) ;
5939
6040function 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.
116148let lock = false ;
117- const FORCE_UPDATE = ( ) => ( { } ) ;
118149let currentDispatcher : ReactDispatcher | null = null ;
119150Object . 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.
160196const dispatcherTypeCache = new Map ( ) ;
161197function isContextOnlyDispatcher ( dispatcher : ReactDispatcher | null ) {
162198 // Treat null the same as the ContextOnlyDispatcher.
0 commit comments