@preact/signals-react 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/dist/signals.d.ts +5 -0
- package/dist/signals.js +1 -1
- package/dist/signals.js.map +1 -1
- package/dist/signals.min.js +1 -1
- package/dist/signals.min.js.map +1 -1
- package/dist/signals.mjs +1 -1
- package/dist/signals.mjs.map +1 -1
- package/dist/signals.module.js +1 -1
- package/dist/signals.module.js.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +166 -84
- package/src/internal.d.ts +0 -4
- package/test/index.test.tsx +305 -48
- package/test/react-router.test.tsx +49 -0
- package/test/utils.ts +67 -0
package/src/index.ts
CHANGED
|
@@ -2,13 +2,16 @@ import {
|
|
|
2
2
|
useRef,
|
|
3
3
|
useMemo,
|
|
4
4
|
useEffect,
|
|
5
|
-
|
|
6
|
-
|
|
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,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
type useCallback,
|
|
10
|
+
type useReducer,
|
|
7
11
|
} from "react";
|
|
8
12
|
import React from "react";
|
|
9
13
|
import jsxRuntime from "react/jsx-runtime";
|
|
10
14
|
import jsxRuntimeDev from "react/jsx-dev-runtime";
|
|
11
|
-
import { useSyncExternalStore } from "use-sync-external-store/shim/index.js";
|
|
12
15
|
import {
|
|
13
16
|
signal,
|
|
14
17
|
computed,
|
|
@@ -17,84 +20,34 @@ import {
|
|
|
17
20
|
Signal,
|
|
18
21
|
type ReadonlySignal,
|
|
19
22
|
} from "@preact/signals-core";
|
|
23
|
+
import { useSyncExternalStore } from "use-sync-external-store/shim/index";
|
|
20
24
|
import type { Effect, JsxRuntimeModule } from "./internal";
|
|
21
25
|
|
|
22
26
|
export { signal, computed, batch, effect, Signal, type ReadonlySignal };
|
|
23
27
|
|
|
24
28
|
const Empty = [] as const;
|
|
25
29
|
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();
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
30
|
|
|
64
|
-
|
|
65
|
-
|
|
31
|
+
interface ReactDispatcher {
|
|
32
|
+
useRef: typeof useRef;
|
|
33
|
+
useCallback: typeof useCallback;
|
|
34
|
+
useReducer: typeof useReducer;
|
|
35
|
+
useSyncExternalStore: typeof useSyncExternalStore;
|
|
66
36
|
}
|
|
67
|
-
function WrapWithProxy(Component: FunctionComponent<any>) {
|
|
68
|
-
if (SupportsProxy) {
|
|
69
|
-
const ProxyComponent = new Proxy(Component, ProxyHandlers);
|
|
70
37
|
|
|
71
|
-
|
|
72
|
-
ProxyInstance.set(ProxyComponent, ProxyComponent);
|
|
38
|
+
let finishUpdate: (() => void) | undefined;
|
|
73
39
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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);
|
|
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
|
+
}
|
|
96
46
|
|
|
97
|
-
|
|
47
|
+
interface EffectStore {
|
|
48
|
+
updater: Effect;
|
|
49
|
+
subscribe(onStoreChange: () => void): () => void;
|
|
50
|
+
getSnapshot(): number;
|
|
98
51
|
}
|
|
99
52
|
|
|
100
53
|
/**
|
|
@@ -110,7 +63,7 @@ function WrapWithProxy(Component: FunctionComponent<any>) {
|
|
|
110
63
|
* @see https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore
|
|
111
64
|
* @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
|
|
112
65
|
*/
|
|
113
|
-
function createEffectStore() {
|
|
66
|
+
function createEffectStore(): EffectStore {
|
|
114
67
|
let updater!: Effect;
|
|
115
68
|
let version = 0;
|
|
116
69
|
let onChangeNotifyReact: (() => void) | undefined;
|
|
@@ -125,7 +78,7 @@ function createEffectStore() {
|
|
|
125
78
|
|
|
126
79
|
return {
|
|
127
80
|
updater,
|
|
128
|
-
subscribe(onStoreChange
|
|
81
|
+
subscribe(onStoreChange) {
|
|
129
82
|
onChangeNotifyReact = onStoreChange;
|
|
130
83
|
|
|
131
84
|
return function () {
|
|
@@ -150,19 +103,144 @@ function createEffectStore() {
|
|
|
150
103
|
};
|
|
151
104
|
}
|
|
152
105
|
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|
|
155
115
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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;
|
|
159
175
|
}
|
|
160
176
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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();
|
|
164
203
|
}
|
|
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
|
+
}
|
|
165
235
|
|
|
236
|
+
dispatcherTypeCache.set(dispatcher, type);
|
|
237
|
+
return type;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function WrapJsx<T>(jsx: T): T {
|
|
241
|
+
if (typeof jsx !== "function") return jsx;
|
|
242
|
+
|
|
243
|
+
return function (type: any, props: any, ...rest: any[]) {
|
|
166
244
|
if (typeof type === "string" && props) {
|
|
167
245
|
for (let i in props) {
|
|
168
246
|
let v = props[i];
|
|
@@ -194,6 +272,12 @@ JsxPro.jsxs && /* */ (JsxPro.jsxs = WrapJsx(JsxPro.jsxs));
|
|
|
194
272
|
JsxDev.jsxDEV && /**/ (JsxDev.jsxDEV = WrapJsx(JsxDev.jsxDEV));
|
|
195
273
|
JsxPro.jsxDEV && /**/ (JsxPro.jsxDEV = WrapJsx(JsxPro.jsxDEV));
|
|
196
274
|
|
|
275
|
+
declare module "@preact/signals-core" {
|
|
276
|
+
// @ts-ignore internal Signal is viewed as function
|
|
277
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
278
|
+
interface Signal extends ReactElement {}
|
|
279
|
+
}
|
|
280
|
+
|
|
197
281
|
/**
|
|
198
282
|
* A wrapper component that renders a Signal's value directly as a Text node.
|
|
199
283
|
*/
|
|
@@ -204,7 +288,7 @@ function Text({ data }: { data: Signal }) {
|
|
|
204
288
|
// Decorate Signals so React renders them as <Text> components.
|
|
205
289
|
Object.defineProperties(Signal.prototype, {
|
|
206
290
|
$$typeof: { configurable: true, value: ReactElemType },
|
|
207
|
-
type: { configurable: true, value:
|
|
291
|
+
type: { configurable: true, value: Text },
|
|
208
292
|
props: {
|
|
209
293
|
configurable: true,
|
|
210
294
|
get() {
|
|
@@ -229,8 +313,6 @@ export function useSignalEffect(cb: () => void | (() => void)) {
|
|
|
229
313
|
callback.current = cb;
|
|
230
314
|
|
|
231
315
|
useEffect(() => {
|
|
232
|
-
return effect(() =>
|
|
233
|
-
return callback.current();
|
|
234
|
-
});
|
|
316
|
+
return effect(() => callback.current());
|
|
235
317
|
}, Empty);
|
|
236
318
|
}
|
package/src/internal.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { Signal } from "@preact/signals-core";
|
|
2
|
-
|
|
3
1
|
export interface Effect {
|
|
4
2
|
_sources: object | undefined;
|
|
5
3
|
_start(): () => void;
|
|
@@ -7,8 +5,6 @@ export interface Effect {
|
|
|
7
5
|
_dispose(): void;
|
|
8
6
|
}
|
|
9
7
|
|
|
10
|
-
export type Updater = Signal<unknown>;
|
|
11
|
-
|
|
12
8
|
export interface JsxRuntimeModule {
|
|
13
9
|
jsx?(type: any, ...rest: any[]): unknown;
|
|
14
10
|
jsxs?(type: any, ...rest: any[]): unknown;
|