@@ -2,14 +2,16 @@ import {
22 useRef ,
33 useMemo ,
44 useEffect ,
5- Component ,
6- type FunctionComponent ,
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 ReactInternals ,
78 type ReactElement ,
9+ type useCallback ,
10+ type useReducer ,
811} from "react" ;
912import React from "react" ;
1013import jsxRuntime from "react/jsx-runtime" ;
1114import jsxRuntimeDev from "react/jsx-dev-runtime" ;
12- import { useSyncExternalStore } from "use-sync-external-store/shim/index.js" ;
1315import {
1416 signal ,
1517 computed ,
@@ -18,96 +20,34 @@ import {
1820 Signal ,
1921 type ReadonlySignal ,
2022} from "@preact/signals-core" ;
23+ import { useSyncExternalStore } from "use-sync-external-store/shim/index" ;
2124import type { Effect , JsxRuntimeModule } from "./internal" ;
2225
2326export { signal , computed , batch , effect , Signal , type ReadonlySignal } ;
2427
2528const Empty = [ ] as const ;
2629const ReactElemType = Symbol . for ( "react.element" ) ; // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
27- const ReactMemoType = Symbol . for ( "react.memo" ) ; // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L30
28- const ReactForwardRefType = Symbol . for ( "react.forward_ref" ) ; // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L25
29- const ProxyInstance = new WeakMap <
30- FunctionComponent < any > ,
31- FunctionComponent < any >
32- > ( ) ;
33-
34- const SupportsProxy = typeof Proxy === "function" ;
35-
36- const ProxyHandlers = {
37- /**
38- * This is a function call trap for functional components.
39- * When this is called, we know it means React did run 'Component()',
40- * that means we can use any hooks here to setup our effect and store.
41- *
42- * With the native Proxy, all other calls such as access/setting to/of properties will
43- * be forwarded to the target Component, so we don't need to copy the Component's
44- * own or inherited properties.
45- *
46- * @see https://github.com/facebook/react/blob/2d80a0cd690bb5650b6c8a6c079a87b5dc42bd15/packages/react-reconciler/src/ReactFiberHooks.old.js#L460
47- */
48- apply (
49- Component : FunctionComponent < any > ,
50- thisArg : any ,
51- argumentsList : Parameters < FunctionComponent < any > >
52- ) {
53- const store = useMemo ( createEffectStore , Empty ) ;
54-
55- useSyncExternalStore ( store . subscribe , store . getSnapshot , store . getSnapshot ) ;
56-
57- const stop = store . updater . _start ( ) ;
58-
59- try {
60- const children = Component . apply ( thisArg , argumentsList ) ;
61- return children ;
62- // eslint-disable-next-line no-useless-catch
63- } catch ( e ) {
64- // Re-throwing promises that'll be handled by suspense
65- // or an actual error.
66- throw e ;
67- } finally {
68- // Stop effects in either case before return or throw,
69- // Otherwise the effect will leak.
70- stop ( ) ;
71- }
72- } ,
73- } ;
7430
75- function ProxyFunctionalComponent ( Component : FunctionComponent < any > ) {
76- return ProxyInstance . get ( Component ) || WrapWithProxy ( Component ) ;
31+ interface ReactDispatcher {
32+ useRef : typeof useRef ;
33+ useCallback : typeof useCallback ;
34+ useReducer : typeof useReducer ;
35+ useSyncExternalStore : typeof useSyncExternalStore ;
7736}
7837
79- function WrapWithProxy ( Component : FunctionComponent < any > ) {
80- if ( SupportsProxy ) {
81- const ProxyComponent = new Proxy ( Component , ProxyHandlers ) ;
82-
83- ProxyInstance . set ( Component , ProxyComponent ) ;
84- ProxyInstance . set ( ProxyComponent , ProxyComponent ) ;
85-
86- return ProxyComponent ;
87- }
88-
89- /**
90- * Emulate a Proxy if environment doesn't support it.
91- *
92- * @TODO - unlike Proxy, it's not possible to access the type/Component's
93- * static properties this way. Not sure if we want to copy all statics here.
94- * Omitting this for now.
95- *
96- * @example - works with Proxy, doesn't with wrapped function.
97- * ```
98- * const el = <SomeFunctionalComponent />
99- * el.type.someOwnOrInheritedProperty;
100- * el.type.defaultProps;
101- * ```
102- */
103- const WrappedComponent : FunctionComponent < any > = ( ...args ) => {
104- return ProxyHandlers . apply ( Component , undefined , args ) ;
105- } ;
38+ let finishUpdate : ( ( ) => void ) | undefined ;
10639
107- ProxyInstance . set ( Component , WrappedComponent ) ;
108- ProxyInstance . set ( WrappedComponent , WrappedComponent ) ;
40+ function setCurrentUpdater ( updater ?: Effect ) {
41+ // end tracking for the current update:
42+ if ( finishUpdate ) finishUpdate ( ) ;
43+ // start tracking the new update:
44+ finishUpdate = updater && updater . _start ( ) ;
45+ }
10946
110- return WrappedComponent ;
47+ interface EffectStore {
48+ updater : Effect ;
49+ subscribe ( onStoreChange : ( ) => void ) : ( ) => void ;
50+ getSnapshot ( ) : number ;
11151}
11252
11353/**
@@ -123,7 +63,7 @@ function WrapWithProxy(Component: FunctionComponent<any>) {
12363 * @see https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore
12464 * @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
12565 */
126- function createEffectStore ( ) {
66+ function createEffectStore ( ) : EffectStore {
12767 let updater ! : Effect ;
12868 let version = 0 ;
12969 let onChangeNotifyReact : ( ( ) => void ) | undefined ;
@@ -138,7 +78,7 @@ function createEffectStore() {
13878
13979 return {
14080 updater,
141- subscribe ( onStoreChange : ( ) => void ) {
81+ subscribe ( onStoreChange ) {
14282 onChangeNotifyReact = onStoreChange ;
14383
14484 return function ( ) {
@@ -163,24 +103,144 @@ function createEffectStore() {
163103 } ;
164104}
165105
166- function WrapJsx < T > ( jsx : T ) : T {
167- if ( typeof jsx !== "function" ) return jsx ;
106+ /**
107+ * Custom hook to create the effect to track signals used during render and
108+ * subscribe to changes to rerender the component when the signals change
109+ */
110+ function usePreactSignalStore ( nextDispatcher : ReactDispatcher ) : EffectStore {
111+ const storeRef = nextDispatcher . useRef < EffectStore > ( ) ;
112+ if ( storeRef . current == null ) {
113+ storeRef . current = createEffectStore ( ) ;
114+ }
168115
169- return function ( type : any , props : any , ...rest : any [ ] ) {
170- if ( typeof type === "function" && ! ( type instanceof Component ) ) {
171- return jsx . call ( jsx , ProxyFunctionalComponent ( type ) , props , ...rest ) ;
116+ const store = storeRef . current ;
117+ useSyncExternalStore ( store . subscribe , store . getSnapshot , store . getSnapshot ) ;
118+
119+ return store ;
120+ }
121+
122+ // To track when we are entering and exiting a component render (i.e. before and
123+ // after React renders a component), we track how the dispatcher changes.
124+ // Outside of a component rendering, the dispatcher is set to an instance that
125+ // errors or warns when any hooks are called. This behavior is prevents hooks
126+ // from being used outside of components. Right before React renders a
127+ // component, the dispatcher is set to a valid one. Right after React finishes
128+ // rendering a component, the dispatcher is set to an erroring one again. This
129+ // erroring dispatcher is called the `ContextOnlyDispatcher` in React's source.
130+ //
131+ // So, we watch the getter and setter on `ReactCurrentDispatcher.current` to
132+ // monitor the changes to the current ReactDispatcher. When the dispatcher
133+ // changes from the ContextOnlyDispatcher to a valid dispatcher, we assume we
134+ // are entering a component render. At this point, we setup our
135+ // auto-subscriptions for any signals used in the component. We do this by
136+ // creating an effect and manually starting the effect. We use
137+ // `useSyncExternalStore` to trigger rerenders on the component when any signals
138+ // it uses changes.
139+ //
140+ // When the dispatcher changes from a valid dispatcher back to the
141+ // ContextOnlyDispatcher, we assume we are exiting a component render. At this
142+ // point we stop the effect.
143+ //
144+ // Some edge cases to be aware of:
145+ // - In development, useReducer, useState, and useMemo changes the dispatcher to
146+ // a different erroring dispatcher before invoking the reducer and resets it
147+ // right after.
148+ //
149+ // The useSyncExternalStore shim will use some of these hooks when we invoke
150+ // it while entering a component render. We need to prevent this dispatcher
151+ // change caused by these hooks from re-triggering our entering logic (it
152+ // would cause an infinite loop if we did not). We do this by using a lock to
153+ // prevent the setter from running while we are in the setter.
154+ //
155+ // When a Component's function body invokes useReducer, useState, or useMemo,
156+ // this change in dispatcher should not signal that we are exiting a component
157+ // render. We ignore this change by detecting these dispatchers as different
158+ // from ContextOnlyDispatcher and other valid dispatchers.
159+ //
160+ // - The `use` hook will change the dispatcher to from a valid update dispatcher
161+ // to a valid mount dispatcher in some cases. Similarly to useReducer
162+ // mentioned above, we should not signal that we are exiting a component
163+ // during this change. Because these other valid dispatchers do not pass the
164+ // ContextOnlyDispatcher check, they do not affect our logic.
165+ let lock = false ;
166+ let currentDispatcher : ReactDispatcher | null = null ;
167+ Object . defineProperty ( ReactInternals . ReactCurrentDispatcher , "current" , {
168+ get ( ) {
169+ return currentDispatcher ;
170+ } ,
171+ set ( nextDispatcher : ReactDispatcher ) {
172+ if ( lock ) {
173+ currentDispatcher = nextDispatcher ;
174+ return ;
172175 }
173176
174- if ( type && typeof type === "object" ) {
175- if ( type . $$typeof === ReactMemoType ) {
176- type . type = ProxyFunctionalComponent ( type . type ) ;
177- return jsx . call ( jsx , type , props , ...rest ) ;
178- } else if ( type . $$typeof === ReactForwardRefType ) {
179- type . render = ProxyFunctionalComponent ( type . render ) ;
180- return jsx . call ( jsx , type , props , ...rest ) ;
181- }
177+ const currentDispatcherType = getDispatcherType ( currentDispatcher ) ;
178+ const nextDispatcherType = getDispatcherType ( nextDispatcher ) ;
179+
180+ // We are entering a component render if the current dispatcher is the
181+ // ContextOnlyDispatcher and the next dispatcher is a valid dispatcher.
182+ const isEnteringComponentRender =
183+ currentDispatcherType === ContextOnlyDispatcherType &&
184+ nextDispatcherType === ValidDispatcherType ;
185+
186+ // We are exiting a component render if the current dispatcher is a valid
187+ // dispatcher and the next dispatcher is the ContextOnlyDispatcher.
188+ const isExitingComponentRender =
189+ currentDispatcherType === ValidDispatcherType &&
190+ nextDispatcherType === ContextOnlyDispatcherType ;
191+
192+ // Update the current dispatcher now so the hooks inside of the
193+ // useSyncExternalStore shim get the right dispatcher.
194+ currentDispatcher = nextDispatcher ;
195+ if ( isEnteringComponentRender ) {
196+ lock = true ;
197+ const store = usePreactSignalStore ( nextDispatcher ) ;
198+ lock = false ;
199+
200+ setCurrentUpdater ( store . updater ) ;
201+ } else if ( isExitingComponentRender ) {
202+ setCurrentUpdater ( ) ;
182203 }
204+ } ,
205+ } ) ;
206+
207+ const ValidDispatcherType = 0 ;
208+ const ContextOnlyDispatcherType = 1 ;
209+ const ErroringDispatcherType = 2 ;
210+
211+ // We inject a useSyncExternalStore into every function component via
212+ // CurrentDispatcher. This prevents injecting into anything other than a
213+ // function component render.
214+ const dispatcherTypeCache = new Map < ReactDispatcher , number > ( ) ;
215+ function getDispatcherType ( dispatcher : ReactDispatcher | null ) : number {
216+ // Treat null the same as the ContextOnlyDispatcher.
217+ if ( ! dispatcher ) return ContextOnlyDispatcherType ;
218+
219+ const cached = dispatcherTypeCache . get ( dispatcher ) ;
220+ if ( cached !== undefined ) return cached ;
221+
222+ // The ContextOnlyDispatcher sets all the hook implementations to a function
223+ // that takes no arguments and throws and error. Check the number of arguments
224+ // for this dispatcher's useCallback implementation to determine if it is a
225+ // ContextOnlyDispatcher. All other dispatchers, erroring or not, define
226+ // functions with arguments and so fail this check.
227+ let type : number ;
228+ if ( dispatcher . useCallback . length < 2 ) {
229+ type = ContextOnlyDispatcherType ;
230+ } else if ( / I n v a l i d / . test ( dispatcher . useCallback as any ) ) {
231+ type = ErroringDispatcherType ;
232+ } else {
233+ type = ValidDispatcherType ;
234+ }
235+
236+ dispatcherTypeCache . set ( dispatcher , type ) ;
237+ return type ;
238+ }
183239
240+ function WrapJsx < T > ( jsx : T ) : T {
241+ if ( typeof jsx !== "function" ) return jsx ;
242+
243+ return function ( type : any , props : any , ...rest : any [ ] ) {
184244 if ( typeof type === "string" && props ) {
185245 for ( let i in props ) {
186246 let v = props [ i ] ;
@@ -228,7 +288,7 @@ function Text({ data }: { data: Signal }) {
228288// Decorate Signals so React renders them as <Text> components.
229289Object . defineProperties ( Signal . prototype , {
230290 $$typeof : { configurable : true , value : ReactElemType } ,
231- type : { configurable : true , value : ProxyFunctionalComponent ( Text ) } ,
291+ type : { configurable : true , value : Text } ,
232292 props : {
233293 configurable : true ,
234294 get ( ) {
0 commit comments