@preact/signals-react 1.2.2 → 1.3.1
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 +23 -0
- 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 -85
- package/src/internal.d.ts +0 -4
- package/test/index.test.tsx +180 -54
- 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,87 +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 WeakMap<
|
|
28
|
-
FunctionComponent<any>,
|
|
29
|
-
FunctionComponent<any>
|
|
30
|
-
>();
|
|
31
|
-
const SupportsProxy = typeof Proxy === "function";
|
|
32
|
-
|
|
33
|
-
const ProxyHandlers = {
|
|
34
|
-
/**
|
|
35
|
-
* This is a function call trap for functional components.
|
|
36
|
-
* When this is called, we know it means React did run 'Component()',
|
|
37
|
-
* that means we can use any hooks here to setup our effect and store.
|
|
38
|
-
*
|
|
39
|
-
* With the native Proxy, all other calls such as access/setting to/of properties will
|
|
40
|
-
* be forwarded to the target Component, so we don't need to copy the Component's
|
|
41
|
-
* own or inherited properties.
|
|
42
|
-
*
|
|
43
|
-
* @see https://github.com/facebook/react/blob/2d80a0cd690bb5650b6c8a6c079a87b5dc42bd15/packages/react-reconciler/src/ReactFiberHooks.old.js#L460
|
|
44
|
-
*/
|
|
45
|
-
apply(Component: FunctionComponent, thisArg: any, argumentsList: any) {
|
|
46
|
-
const store = useMemo(createEffectStore, Empty);
|
|
47
|
-
|
|
48
|
-
useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
49
|
-
|
|
50
|
-
const stop = store.updater._start();
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const children = Component.apply(thisArg, argumentsList);
|
|
54
|
-
return children;
|
|
55
|
-
} catch (e) {
|
|
56
|
-
// Re-throwing promises that'll be handled by suspense
|
|
57
|
-
// or an actual error.
|
|
58
|
-
throw e;
|
|
59
|
-
} finally {
|
|
60
|
-
// Stop effects in either case before return or throw,
|
|
61
|
-
// Otherwise the effect will leak.
|
|
62
|
-
stop();
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
30
|
|
|
67
|
-
|
|
68
|
-
|
|
31
|
+
interface ReactDispatcher {
|
|
32
|
+
useRef: typeof useRef;
|
|
33
|
+
useCallback: typeof useCallback;
|
|
34
|
+
useReducer: typeof useReducer;
|
|
35
|
+
useSyncExternalStore: typeof useSyncExternalStore;
|
|
69
36
|
}
|
|
70
|
-
function WrapWithProxy(Component: FunctionComponent<any>) {
|
|
71
|
-
if (SupportsProxy) {
|
|
72
|
-
const ProxyComponent = new Proxy(Component, ProxyHandlers);
|
|
73
37
|
|
|
74
|
-
|
|
75
|
-
ProxyInstance.set(ProxyComponent, ProxyComponent);
|
|
38
|
+
let finishUpdate: (() => void) | undefined;
|
|
76
39
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
* @TODO - unlike Proxy, it's not possible to access the type/Component's
|
|
84
|
-
* static properties this way. Not sure if we want to copy all statics here.
|
|
85
|
-
* Omitting this for now.
|
|
86
|
-
*
|
|
87
|
-
* @example - works with Proxy, doesn't with wrapped function.
|
|
88
|
-
* ```
|
|
89
|
-
* const el = <SomeFunctionalComponent />
|
|
90
|
-
* el.type.someOwnOrInheritedProperty;
|
|
91
|
-
* el.type.defaultProps;
|
|
92
|
-
* ```
|
|
93
|
-
*/
|
|
94
|
-
const WrappedComponent = function () {
|
|
95
|
-
return ProxyHandlers.apply(Component, undefined, arguments);
|
|
96
|
-
};
|
|
97
|
-
ProxyInstance.set(Component, WrappedComponent);
|
|
98
|
-
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
|
+
}
|
|
99
46
|
|
|
100
|
-
|
|
47
|
+
interface EffectStore {
|
|
48
|
+
updater: Effect;
|
|
49
|
+
subscribe(onStoreChange: () => void): () => void;
|
|
50
|
+
getSnapshot(): number;
|
|
101
51
|
}
|
|
102
52
|
|
|
103
53
|
/**
|
|
@@ -110,10 +60,10 @@ function WrapWithProxy(Component: FunctionComponent<any>) {
|
|
|
110
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).
|
|
111
61
|
*
|
|
112
62
|
* [1]
|
|
113
|
-
* @see https://
|
|
63
|
+
* @see https://react.dev/reference/react/useSyncExternalStore
|
|
114
64
|
* @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
|
|
115
65
|
*/
|
|
116
|
-
function createEffectStore() {
|
|
66
|
+
function createEffectStore(): EffectStore {
|
|
117
67
|
let updater!: Effect;
|
|
118
68
|
let version = 0;
|
|
119
69
|
let onChangeNotifyReact: (() => void) | undefined;
|
|
@@ -128,7 +78,7 @@ function createEffectStore() {
|
|
|
128
78
|
|
|
129
79
|
return {
|
|
130
80
|
updater,
|
|
131
|
-
subscribe(onStoreChange
|
|
81
|
+
subscribe(onStoreChange) {
|
|
132
82
|
onChangeNotifyReact = onStoreChange;
|
|
133
83
|
|
|
134
84
|
return function () {
|
|
@@ -153,19 +103,144 @@ function createEffectStore() {
|
|
|
153
103
|
};
|
|
154
104
|
}
|
|
155
105
|
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
}
|
|
158
115
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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;
|
|
162
175
|
}
|
|
163
176
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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();
|
|
167
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
|
+
}
|
|
168
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[]) {
|
|
169
244
|
if (typeof type === "string" && props) {
|
|
170
245
|
for (let i in props) {
|
|
171
246
|
let v = props[i];
|
|
@@ -197,6 +272,12 @@ JsxPro.jsxs && /* */ (JsxPro.jsxs = WrapJsx(JsxPro.jsxs));
|
|
|
197
272
|
JsxDev.jsxDEV && /**/ (JsxDev.jsxDEV = WrapJsx(JsxDev.jsxDEV));
|
|
198
273
|
JsxPro.jsxDEV && /**/ (JsxPro.jsxDEV = WrapJsx(JsxPro.jsxDEV));
|
|
199
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
|
+
|
|
200
281
|
/**
|
|
201
282
|
* A wrapper component that renders a Signal's value directly as a Text node.
|
|
202
283
|
*/
|
|
@@ -207,7 +288,7 @@ function Text({ data }: { data: Signal }) {
|
|
|
207
288
|
// Decorate Signals so React renders them as <Text> components.
|
|
208
289
|
Object.defineProperties(Signal.prototype, {
|
|
209
290
|
$$typeof: { configurable: true, value: ReactElemType },
|
|
210
|
-
type: { configurable: true, value:
|
|
291
|
+
type: { configurable: true, value: Text },
|
|
211
292
|
props: {
|
|
212
293
|
configurable: true,
|
|
213
294
|
get() {
|
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;
|