Skip to content

Commit b37a223

Browse files
authored
Merge pull request #335 from preactjs/react-adapter-update
Revert react integration to tracking current dispatcher
2 parents 0a58566 + 465cc0c commit b37a223

8 files changed

Lines changed: 411 additions & 150 deletions

File tree

.changeset/angry-papayas-accept.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+
Revert react integration to tracking current dispatcher

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@
3030
"license": "MIT",
3131
"devDependencies": {
3232
"@babel/core": "^7.19.1",
33+
"@babel/plugin-transform-typescript": "^7.19.1",
3334
"@babel/preset-env": "^7.19.1",
3435
"@babel/preset-react": "^7.18.6",
3536
"@babel/preset-typescript": "^7.18.6",
36-
"@babel/plugin-transform-typescript": "^7.19.1",
3737
"@changesets/changelog-github": "^0.4.6",
3838
"@changesets/cli": "^2.24.2",
3939
"@types/chai": "^4.3.3",

packages/react/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@types/react-dom": "^18.0.6",
5050
"@types/use-sync-external-store": "^0.0.3",
5151
"react": "^18.2.0",
52-
"react-dom": "^18.2.0"
52+
"react-dom": "^18.2.0",
53+
"react-router-dom": "^6.9.0"
5354
}
5455
}

packages/react/src/index.ts

Lines changed: 158 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
912
import React from "react";
1013
import jsxRuntime from "react/jsx-runtime";
1114
import jsxRuntimeDev from "react/jsx-dev-runtime";
12-
import { useSyncExternalStore } from "use-sync-external-store/shim/index.js";
1315
import {
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";
2124
import type { Effect, JsxRuntimeModule } from "./internal";
2225

2326
export { signal, computed, batch, effect, Signal, type ReadonlySignal };
2427

2528
const Empty = [] as const;
2629
const 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 (/Invalid/.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.
229289
Object.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

Comments
 (0)