@preact/signals-react 1.3.0 → 1.3.2
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 +16 -0
- package/README.md +8 -8
- 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 +2 -2
- package/src/index.ts +241 -51
- package/test/browser/mounts.test.tsx +32 -0
- package/test/{react-router.test.tsx → browser/react-router.test.tsx} +17 -3
- package/test/{index.test.tsx → browser/updates.test.tsx} +130 -14
- package/test/node/renderToStaticMarkup.test.tsx +24 -0
- package/test/node/setup.js +52 -0
- package/test/shared/mounting.tsx +184 -0
- package/test/shared/utils.ts +126 -0
- package/test/utils.ts +0 -67
- /package/test/{exports.test.tsx → browser/exports.test.tsx} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preact/signals-react",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Manage state with style in React",
|
|
6
6
|
"keywords": [],
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"use-sync-external-store": "^1.2.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"react": "17.x || 18.x"
|
|
42
|
+
"react": "^16.14.0 || 17.x || 18.x"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/react": "^18.0.18",
|
package/src/index.ts
CHANGED
|
@@ -6,8 +6,6 @@ import {
|
|
|
6
6
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
7
|
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED as ReactInternals,
|
|
8
8
|
type ReactElement,
|
|
9
|
-
type useCallback,
|
|
10
|
-
type useReducer,
|
|
11
9
|
} from "react";
|
|
12
10
|
import React from "react";
|
|
13
11
|
import jsxRuntime from "react/jsx-runtime";
|
|
@@ -20,7 +18,7 @@ import {
|
|
|
20
18
|
Signal,
|
|
21
19
|
type ReadonlySignal,
|
|
22
20
|
} from "@preact/signals-core";
|
|
23
|
-
import { useSyncExternalStore } from "use-sync-external-store/shim/index";
|
|
21
|
+
import { useSyncExternalStore } from "use-sync-external-store/shim/index.js";
|
|
24
22
|
import type { Effect, JsxRuntimeModule } from "./internal";
|
|
25
23
|
|
|
26
24
|
export { signal, computed, batch, effect, Signal, type ReadonlySignal };
|
|
@@ -29,10 +27,12 @@ const Empty = [] as const;
|
|
|
29
27
|
const ReactElemType = Symbol.for("react.element"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
|
|
30
28
|
|
|
31
29
|
interface ReactDispatcher {
|
|
32
|
-
useRef: typeof useRef;
|
|
33
|
-
useCallback: typeof useCallback;
|
|
34
|
-
useReducer: typeof useReducer;
|
|
35
|
-
useSyncExternalStore: typeof useSyncExternalStore;
|
|
30
|
+
useRef: typeof React.useRef;
|
|
31
|
+
useCallback: typeof React.useCallback;
|
|
32
|
+
useReducer: typeof React.useReducer;
|
|
33
|
+
useSyncExternalStore: typeof React.useSyncExternalStore;
|
|
34
|
+
useEffect: typeof React.useEffect;
|
|
35
|
+
useImperativeHandle: typeof React.useImperativeHandle;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
let finishUpdate: (() => void) | undefined;
|
|
@@ -60,7 +60,7 @@ interface EffectStore {
|
|
|
60
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
61
|
*
|
|
62
62
|
* [1]
|
|
63
|
-
* @see https://
|
|
63
|
+
* @see https://react.dev/reference/react/useSyncExternalStore
|
|
64
64
|
* @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
|
|
65
65
|
*/
|
|
66
66
|
function createEffectStore(): EffectStore {
|
|
@@ -119,21 +119,27 @@ function usePreactSignalStore(nextDispatcher: ReactDispatcher): EffectStore {
|
|
|
119
119
|
return store;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// In order for signals to work in React, we need to observe what signals a
|
|
123
|
+
// component uses while rendering. To do this, we need to know when a component
|
|
124
|
+
// is rendering. To do this, we watch the transition of the
|
|
125
|
+
// ReactCurrentDispatcher to know when a component is rerendering.
|
|
126
|
+
//
|
|
122
127
|
// To track when we are entering and exiting a component render (i.e. before and
|
|
123
128
|
// after React renders a component), we track how the dispatcher changes.
|
|
124
129
|
// Outside of a component rendering, the dispatcher is set to an instance that
|
|
125
130
|
// errors or warns when any hooks are called. This behavior is prevents hooks
|
|
126
131
|
// from being used outside of components. Right before React renders a
|
|
127
|
-
// component, the dispatcher is set to
|
|
128
|
-
//
|
|
132
|
+
// component, the dispatcher is set to an instance that doesn't warn or error
|
|
133
|
+
// and contains the implementations of all hooks. Right after React finishes
|
|
134
|
+
// rendering a component, the dispatcher is set to the erroring one again. This
|
|
129
135
|
// erroring dispatcher is called the `ContextOnlyDispatcher` in React's source.
|
|
130
136
|
//
|
|
131
137
|
// So, we watch the getter and setter on `ReactCurrentDispatcher.current` to
|
|
132
138
|
// monitor the changes to the current ReactDispatcher. When the dispatcher
|
|
133
|
-
// changes from the ContextOnlyDispatcher to a valid dispatcher, we assume we
|
|
139
|
+
// changes from the ContextOnlyDispatcher to a "valid" dispatcher, we assume we
|
|
134
140
|
// are entering a component render. At this point, we setup our
|
|
135
141
|
// auto-subscriptions for any signals used in the component. We do this by
|
|
136
|
-
// creating an effect and manually starting the effect. We use
|
|
142
|
+
// creating an Signal effect and manually starting the Signal effect. We use
|
|
137
143
|
// `useSyncExternalStore` to trigger rerenders on the component when any signals
|
|
138
144
|
// it uses changes.
|
|
139
145
|
//
|
|
@@ -141,10 +147,16 @@ function usePreactSignalStore(nextDispatcher: ReactDispatcher): EffectStore {
|
|
|
141
147
|
// ContextOnlyDispatcher, we assume we are exiting a component render. At this
|
|
142
148
|
// point we stop the effect.
|
|
143
149
|
//
|
|
144
|
-
// Some
|
|
145
|
-
// -
|
|
146
|
-
//
|
|
147
|
-
//
|
|
150
|
+
// Some additional complexities to be aware of:
|
|
151
|
+
// - If a component calls `setState` while rendering, React will re-render the
|
|
152
|
+
// component immediately. Before triggering the re-render, React will change
|
|
153
|
+
// the dispatcher to the HooksDispatcherOnRerender. When we transition to this
|
|
154
|
+
// rerendering adapter, we need to re-trigger our hooks to keep the order of
|
|
155
|
+
// hooks the same for every render of a component.
|
|
156
|
+
//
|
|
157
|
+
// - In development, useReducer, useState, and useMemo change the dispatcher to
|
|
158
|
+
// a different warning dispatcher (not ContextOnlyDispatcher) before invoking
|
|
159
|
+
// the reducer and resets it right after.
|
|
148
160
|
//
|
|
149
161
|
// The useSyncExternalStore shim will use some of these hooks when we invoke
|
|
150
162
|
// it while entering a component render. We need to prevent this dispatcher
|
|
@@ -153,15 +165,89 @@ function usePreactSignalStore(nextDispatcher: ReactDispatcher): EffectStore {
|
|
|
153
165
|
// prevent the setter from running while we are in the setter.
|
|
154
166
|
//
|
|
155
167
|
// When a Component's function body invokes useReducer, useState, or useMemo,
|
|
156
|
-
// this change in dispatcher should not signal that we are
|
|
157
|
-
// render. We ignore this change by detecting these dispatchers as
|
|
158
|
-
// from ContextOnlyDispatcher and other valid dispatchers.
|
|
168
|
+
// this change in dispatcher should not signal that we are entering or exiting
|
|
169
|
+
// a component render. We ignore this change by detecting these dispatchers as
|
|
170
|
+
// different from ContextOnlyDispatcher and other valid dispatchers.
|
|
159
171
|
//
|
|
160
172
|
// - The `use` hook will change the dispatcher to from a valid update dispatcher
|
|
161
173
|
// to a valid mount dispatcher in some cases. Similarly to useReducer
|
|
162
174
|
// mentioned above, we should not signal that we are exiting a component
|
|
163
175
|
// during this change. Because these other valid dispatchers do not pass the
|
|
164
176
|
// ContextOnlyDispatcher check, they do not affect our logic.
|
|
177
|
+
//
|
|
178
|
+
// - When server rendering, React does not change the dispatcher before and
|
|
179
|
+
// after each component render. It sets it once for before the first render
|
|
180
|
+
// and once for after the last render. This means that we will not be able to
|
|
181
|
+
// detect when we are entering or exiting a component render. This is fine
|
|
182
|
+
// because we don't need to detect this for server rendering. A component
|
|
183
|
+
// can't trigger async rerenders in SSR so we don't need to track signals.
|
|
184
|
+
//
|
|
185
|
+
// If a component updates a signal value while rendering during SSR, we will
|
|
186
|
+
// not rerender the component because the signal value will synchronously
|
|
187
|
+
// change so all reads of the signal further down the tree will see the new
|
|
188
|
+
// value.
|
|
189
|
+
|
|
190
|
+
/*
|
|
191
|
+
Below is a state machine definition for transitions between the various
|
|
192
|
+
dispatchers in React's prod build. (It does not include dev time warning
|
|
193
|
+
dispatchers which are just always ignored).
|
|
194
|
+
|
|
195
|
+
ENTER and EXIT suffixes indicates whether this ReactCurrentDispatcher transition
|
|
196
|
+
signals we are entering or exiting a component render, or if it doesn't signal a
|
|
197
|
+
change in the component rendering lifecyle (NOOP).
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
// Paste this into https://stately.ai/viz to visualize the state machine.
|
|
201
|
+
import { createMachine } from "xstate";
|
|
202
|
+
|
|
203
|
+
// ENTER, EXIT, NOOP suffixes indicates whether this ReactCurrentDispatcher
|
|
204
|
+
// transition signals we are entering or exiting a component render, or
|
|
205
|
+
// if it doesn't signal a change in the component rendering lifecyle (NOOP).
|
|
206
|
+
|
|
207
|
+
const dispatcherMachinePROD = createMachine({
|
|
208
|
+
id: "ReactCurrentDispatcher_PROD",
|
|
209
|
+
initial: "null",
|
|
210
|
+
states: {
|
|
211
|
+
null: {
|
|
212
|
+
on: {
|
|
213
|
+
pushDispatcher: "ContextOnlyDispatcher",
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
ContextOnlyDispatcher: {
|
|
217
|
+
on: {
|
|
218
|
+
renderWithHooks_Mount_ENTER: "HooksDispatcherOnMount",
|
|
219
|
+
renderWithHooks_Update_ENTER: "HooksDispatcherOnUpdate",
|
|
220
|
+
pushDispatcher_NOOP: "ContextOnlyDispatcher",
|
|
221
|
+
popDispatcher_NOOP: "ContextOnlyDispatcher",
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
HooksDispatcherOnMount: {
|
|
225
|
+
on: {
|
|
226
|
+
renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
|
|
227
|
+
resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
|
|
228
|
+
finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
HooksDispatcherOnUpdate: {
|
|
232
|
+
on: {
|
|
233
|
+
renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
|
|
234
|
+
resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
|
|
235
|
+
finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
|
|
236
|
+
use_ResumeSuspensedMount_NOOP: "HooksDispatcherOnMount",
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
HooksDispatcherOnRerender: {
|
|
240
|
+
on: {
|
|
241
|
+
renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
|
|
242
|
+
resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
|
|
243
|
+
finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
*/
|
|
250
|
+
|
|
165
251
|
let lock = false;
|
|
166
252
|
let currentDispatcher: ReactDispatcher | null = null;
|
|
167
253
|
Object.defineProperty(ReactInternals.ReactCurrentDispatcher, "current", {
|
|
@@ -177,42 +263,35 @@ Object.defineProperty(ReactInternals.ReactCurrentDispatcher, "current", {
|
|
|
177
263
|
const currentDispatcherType = getDispatcherType(currentDispatcher);
|
|
178
264
|
const nextDispatcherType = getDispatcherType(nextDispatcher);
|
|
179
265
|
|
|
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
266
|
// Update the current dispatcher now so the hooks inside of the
|
|
193
267
|
// useSyncExternalStore shim get the right dispatcher.
|
|
194
268
|
currentDispatcher = nextDispatcher;
|
|
195
|
-
if (isEnteringComponentRender) {
|
|
269
|
+
if (isEnteringComponentRender(currentDispatcherType, nextDispatcherType)) {
|
|
196
270
|
lock = true;
|
|
197
271
|
const store = usePreactSignalStore(nextDispatcher);
|
|
198
272
|
lock = false;
|
|
199
273
|
|
|
200
274
|
setCurrentUpdater(store.updater);
|
|
201
|
-
} else if (
|
|
275
|
+
} else if (
|
|
276
|
+
isExitingComponentRender(currentDispatcherType, nextDispatcherType)
|
|
277
|
+
) {
|
|
202
278
|
setCurrentUpdater();
|
|
203
279
|
}
|
|
204
280
|
},
|
|
205
281
|
});
|
|
206
282
|
|
|
207
|
-
|
|
208
|
-
const ContextOnlyDispatcherType = 1;
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
283
|
+
type DispatcherType = number;
|
|
284
|
+
const ContextOnlyDispatcherType = 1 << 0;
|
|
285
|
+
const WarningDispatcherType = 1 << 1;
|
|
286
|
+
const MountDispatcherType = 1 << 2;
|
|
287
|
+
const UpdateDispatcherType = 1 << 3;
|
|
288
|
+
const RerenderDispatcherType = 1 << 4;
|
|
289
|
+
const ServerDispatcherType = 1 << 5;
|
|
290
|
+
const BrowserClientDispatcherType =
|
|
291
|
+
MountDispatcherType | UpdateDispatcherType | RerenderDispatcherType;
|
|
292
|
+
|
|
293
|
+
const dispatcherTypeCache = new Map<ReactDispatcher, DispatcherType>();
|
|
294
|
+
function getDispatcherType(dispatcher: ReactDispatcher | null): DispatcherType {
|
|
216
295
|
// Treat null the same as the ContextOnlyDispatcher.
|
|
217
296
|
if (!dispatcher) return ContextOnlyDispatcherType;
|
|
218
297
|
|
|
@@ -220,23 +299,134 @@ function getDispatcherType(dispatcher: ReactDispatcher | null): number {
|
|
|
220
299
|
if (cached !== undefined) return cached;
|
|
221
300
|
|
|
222
301
|
// The ContextOnlyDispatcher sets all the hook implementations to a function
|
|
223
|
-
// that takes no arguments and throws and error.
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (dispatcher.
|
|
302
|
+
// that takes no arguments and throws and error. This dispatcher is the only
|
|
303
|
+
// dispatcher where useReducer and useEffect will have the same
|
|
304
|
+
// implementation.
|
|
305
|
+
let type: DispatcherType;
|
|
306
|
+
const useCallbackImpl = dispatcher.useCallback.toString();
|
|
307
|
+
if (dispatcher.useReducer === dispatcher.useEffect) {
|
|
229
308
|
type = ContextOnlyDispatcherType;
|
|
230
|
-
|
|
231
|
-
|
|
309
|
+
|
|
310
|
+
// @ts-expect-error When server rendering, useEffect and useImperativeHandle
|
|
311
|
+
// are both set to noop functions and so have the same implementation.
|
|
312
|
+
} else if (dispatcher.useEffect === dispatcher.useImperativeHandle) {
|
|
313
|
+
type = ServerDispatcherType;
|
|
314
|
+
} else if (/Invalid/.test(useCallbackImpl)) {
|
|
315
|
+
// We first check for warning dispatchers because they would also pass some
|
|
316
|
+
// of the checks below.
|
|
317
|
+
type = WarningDispatcherType;
|
|
318
|
+
} else if (
|
|
319
|
+
// The development mount dispatcher invokes a function called
|
|
320
|
+
// `mountCallback` whereas the development update/re-render dispatcher
|
|
321
|
+
// invokes a function called `updateCallback`. Use that difference to
|
|
322
|
+
// determine if we are in a mount or update-like dispatcher in development.
|
|
323
|
+
// The production mount dispatcher defines an array of the form [callback,
|
|
324
|
+
// deps] whereas update/re-render dispatchers read the array using array
|
|
325
|
+
// indices (e.g. `[0]` and `[1]`). Use those differences to determine if we
|
|
326
|
+
// are in a mount or update-like dispatcher in production.
|
|
327
|
+
/updateCallback/.test(useCallbackImpl) ||
|
|
328
|
+
(/\[0\]/.test(useCallbackImpl) && /\[1\]/.test(useCallbackImpl))
|
|
329
|
+
) {
|
|
330
|
+
// The update and rerender dispatchers have different implementations for
|
|
331
|
+
// useReducer. We'll check it's implementation to determine if this is the
|
|
332
|
+
// rerender or update dispatcher.
|
|
333
|
+
let useReducerImpl = dispatcher.useReducer.toString();
|
|
334
|
+
if (
|
|
335
|
+
// The development rerender dispatcher invokes a function called
|
|
336
|
+
// `rerenderReducer` whereas the update dispatcher invokes a function
|
|
337
|
+
// called `updateReducer`. The production rerender dispatcher returns an
|
|
338
|
+
// array of the form `[state, dispatch]` whereas the update dispatcher
|
|
339
|
+
// returns an array of `[fiber.memoizedState, dispatch]` so we check the
|
|
340
|
+
// return statement in the implementation of useReducer to differentiate
|
|
341
|
+
// between the two.
|
|
342
|
+
/rerenderReducer/.test(useReducerImpl) ||
|
|
343
|
+
/return\s*\[\w+,/.test(useReducerImpl)
|
|
344
|
+
) {
|
|
345
|
+
type = RerenderDispatcherType;
|
|
346
|
+
} else {
|
|
347
|
+
type = UpdateDispatcherType;
|
|
348
|
+
}
|
|
232
349
|
} else {
|
|
233
|
-
type =
|
|
350
|
+
type = MountDispatcherType;
|
|
234
351
|
}
|
|
235
352
|
|
|
236
353
|
dispatcherTypeCache.set(dispatcher, type);
|
|
237
354
|
return type;
|
|
238
355
|
}
|
|
239
356
|
|
|
357
|
+
function isEnteringComponentRender(
|
|
358
|
+
currentDispatcherType: DispatcherType,
|
|
359
|
+
nextDispatcherType: DispatcherType
|
|
360
|
+
): boolean {
|
|
361
|
+
if (
|
|
362
|
+
currentDispatcherType & ContextOnlyDispatcherType &&
|
|
363
|
+
nextDispatcherType & BrowserClientDispatcherType
|
|
364
|
+
) {
|
|
365
|
+
// ## Mount or update (ContextOnlyDispatcher -> ValidDispatcher (Mount or Update))
|
|
366
|
+
//
|
|
367
|
+
// If the current dispatcher is the ContextOnlyDispatcher and the next
|
|
368
|
+
// dispatcher is a valid dispatcher, we are entering a component render.
|
|
369
|
+
return true;
|
|
370
|
+
} else if (
|
|
371
|
+
currentDispatcherType & WarningDispatcherType ||
|
|
372
|
+
nextDispatcherType & WarningDispatcherType
|
|
373
|
+
) {
|
|
374
|
+
// ## Warning dispatcher
|
|
375
|
+
//
|
|
376
|
+
// If the current dispatcher or next dispatcher is an warning dispatcher,
|
|
377
|
+
// we are not entering a component render. The current warning dispatchers
|
|
378
|
+
// are used to warn when hooks are nested improperly and do not indicate
|
|
379
|
+
// entering a new component render.
|
|
380
|
+
return false;
|
|
381
|
+
} else if (nextDispatcherType & RerenderDispatcherType) {
|
|
382
|
+
// Any transition into the rerender dispatcher is the beginning of a
|
|
383
|
+
// component render, so we should invoke our hooks. Details below.
|
|
384
|
+
//
|
|
385
|
+
// ## In-place rerendering (e.g. Mount -> Rerender)
|
|
386
|
+
//
|
|
387
|
+
// If we are transitioning from the mount, update, or rerender dispatcher to
|
|
388
|
+
// the rerender dispatcher (e.g. HooksDispatcherOnMount to
|
|
389
|
+
// HooksDispatcherOnRerender), then this component is rerendering due to
|
|
390
|
+
// calling setState inside of its function body. We are re-entering a
|
|
391
|
+
// component's render method and so we should re-invoke our hooks.
|
|
392
|
+
return true;
|
|
393
|
+
} else {
|
|
394
|
+
// ## Resuming suspended mount edge case (Update -> Mount)
|
|
395
|
+
//
|
|
396
|
+
// If we are transitioning from the update dispatcher to the mount
|
|
397
|
+
// dispatcher, then this component is using the `use` hook and is resuming
|
|
398
|
+
// from a mount. We should not re-invoke our hooks in this situation since
|
|
399
|
+
// we are not entering a new component render, but instead continuing a
|
|
400
|
+
// previous render.
|
|
401
|
+
//
|
|
402
|
+
// ## Other transitions
|
|
403
|
+
//
|
|
404
|
+
// For example, Mount -> Mount, Update -> Update, Mount -> Update, any
|
|
405
|
+
// transition in and out of invalid dispatchers.
|
|
406
|
+
//
|
|
407
|
+
// There is no known transition for the following transitions so we default
|
|
408
|
+
// to not triggering a re-enter of the component.
|
|
409
|
+
// - HooksDispatcherOnMount -> HooksDispatcherOnMount
|
|
410
|
+
// - HooksDispatcherOnMount -> HooksDispatcherOnUpdate
|
|
411
|
+
// - HooksDispatcherOnUpdate -> HooksDispatcherOnUpdate
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* We are exiting a component render if the current dispatcher is a valid
|
|
418
|
+
* dispatcher and the next dispatcher is the ContextOnlyDispatcher.
|
|
419
|
+
*/
|
|
420
|
+
function isExitingComponentRender(
|
|
421
|
+
currentDispatcherType: DispatcherType,
|
|
422
|
+
nextDispatcherType: DispatcherType
|
|
423
|
+
): boolean {
|
|
424
|
+
return Boolean(
|
|
425
|
+
currentDispatcherType & BrowserClientDispatcherType &&
|
|
426
|
+
nextDispatcherType & ContextOnlyDispatcherType
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
240
430
|
function WrapJsx<T>(jsx: T): T {
|
|
241
431
|
if (typeof jsx !== "function") return jsx;
|
|
242
432
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mountSignalsTests } from "../shared/mounting";
|
|
2
|
+
import {
|
|
3
|
+
Root,
|
|
4
|
+
createRoot,
|
|
5
|
+
act,
|
|
6
|
+
getConsoleErrorSpy,
|
|
7
|
+
checkConsoleErrorLogs,
|
|
8
|
+
} from "../shared/utils";
|
|
9
|
+
|
|
10
|
+
describe("@preact/signals-react mounting", () => {
|
|
11
|
+
let scratch: HTMLDivElement;
|
|
12
|
+
let root: Root;
|
|
13
|
+
|
|
14
|
+
async function render(element: JSX.Element | null): Promise<string> {
|
|
15
|
+
await act(() => root.render(element));
|
|
16
|
+
return scratch.innerHTML;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
scratch = document.createElement("div");
|
|
21
|
+
document.body.appendChild(scratch);
|
|
22
|
+
root = await createRoot(scratch);
|
|
23
|
+
getConsoleErrorSpy().resetHistory();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
scratch.remove();
|
|
28
|
+
checkConsoleErrorLogs();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
mountSignalsTests(render);
|
|
32
|
+
});
|
|
@@ -3,9 +3,23 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
3
3
|
|
|
4
4
|
import { signal } from "@preact/signals-react";
|
|
5
5
|
import { createElement } from "react";
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
import { act, checkHangingAct, createRoot, Root } from "
|
|
6
|
+
import * as ReactRouter from "react-router-dom";
|
|
7
|
+
|
|
8
|
+
import { act, checkHangingAct, createRoot, Root } from "../shared/utils";
|
|
9
|
+
|
|
10
|
+
const MemoryRouter = ReactRouter.MemoryRouter;
|
|
11
|
+
const Routes = ReactRouter.Routes
|
|
12
|
+
? ReactRouter.Routes
|
|
13
|
+
: (ReactRouter as any).Switch; // react-router-dom v5
|
|
14
|
+
|
|
15
|
+
// @ts-expect-error We are doing a check for react-router-dom v5 vs v6 here, so
|
|
16
|
+
// while TS thinks ReactRouter.Routes will always be here, it isn't in v5.
|
|
17
|
+
const Route = ReactRouter.Routes
|
|
18
|
+
? ReactRouter.Route
|
|
19
|
+
: // react-router-dom v5 requires the element prop to be passed as children.
|
|
20
|
+
({ element, ...props }: any) => (
|
|
21
|
+
<ReactRouter.Route {...props}>{element}</ReactRouter.Route>
|
|
22
|
+
);
|
|
9
23
|
|
|
10
24
|
describe("@preact/signals-react", () => {
|
|
11
25
|
let scratch: HTMLDivElement;
|
|
@@ -16,12 +16,24 @@ import {
|
|
|
16
16
|
memo,
|
|
17
17
|
StrictMode,
|
|
18
18
|
createRef,
|
|
19
|
+
useState,
|
|
20
|
+
useContext,
|
|
21
|
+
createContext,
|
|
19
22
|
} from "react";
|
|
20
23
|
|
|
21
24
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
import {
|
|
26
|
+
createRoot,
|
|
27
|
+
Root,
|
|
28
|
+
act,
|
|
29
|
+
checkHangingAct,
|
|
30
|
+
isReact16,
|
|
31
|
+
isProd,
|
|
32
|
+
getConsoleErrorSpy,
|
|
33
|
+
checkConsoleErrorLogs,
|
|
34
|
+
} from "../shared/utils";
|
|
35
|
+
|
|
36
|
+
describe("@preact/signals-react updating", () => {
|
|
25
37
|
let scratch: HTMLDivElement;
|
|
26
38
|
let root: Root;
|
|
27
39
|
|
|
@@ -33,12 +45,15 @@ describe("@preact/signals-react", () => {
|
|
|
33
45
|
scratch = document.createElement("div");
|
|
34
46
|
document.body.appendChild(scratch);
|
|
35
47
|
root = await createRoot(scratch);
|
|
48
|
+
getConsoleErrorSpy().resetHistory();
|
|
36
49
|
});
|
|
37
50
|
|
|
38
51
|
afterEach(async () => {
|
|
39
|
-
checkHangingAct();
|
|
40
52
|
await act(() => root.unmount());
|
|
41
53
|
scratch.remove();
|
|
54
|
+
|
|
55
|
+
checkConsoleErrorLogs();
|
|
56
|
+
checkHangingAct();
|
|
42
57
|
});
|
|
43
58
|
|
|
44
59
|
describe("Text bindings", () => {
|
|
@@ -323,6 +338,102 @@ describe("@preact/signals-react", () => {
|
|
|
323
338
|
`<pre><code>-1</code><code>${count.value * 2}</code></pre>`
|
|
324
339
|
);
|
|
325
340
|
});
|
|
341
|
+
|
|
342
|
+
it("should not fail when a component calls setState while rendering", async () => {
|
|
343
|
+
let increment: () => void;
|
|
344
|
+
function App() {
|
|
345
|
+
const [state, setState] = useState(0);
|
|
346
|
+
increment = () => setState(state + 1);
|
|
347
|
+
|
|
348
|
+
if (state > 0 && state < 2) {
|
|
349
|
+
setState(state + 1);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return <div>{state}</div>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await render(<App />);
|
|
356
|
+
expect(scratch.innerHTML).to.equal("<div>0</div>");
|
|
357
|
+
|
|
358
|
+
await act(() => {
|
|
359
|
+
increment();
|
|
360
|
+
});
|
|
361
|
+
expect(scratch.innerHTML).to.equal("<div>2</div>");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("should not fail when a component calls setState multiple times while rendering", async () => {
|
|
365
|
+
let increment: () => void;
|
|
366
|
+
function App() {
|
|
367
|
+
const [state, setState] = useState(0);
|
|
368
|
+
increment = () => setState(state + 1);
|
|
369
|
+
|
|
370
|
+
if (state > 0 && state < 5) {
|
|
371
|
+
setState(state + 1);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return <div>{state}</div>;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
await render(<App />);
|
|
378
|
+
expect(scratch.innerHTML).to.equal("<div>0</div>");
|
|
379
|
+
|
|
380
|
+
await act(() => {
|
|
381
|
+
increment();
|
|
382
|
+
});
|
|
383
|
+
expect(scratch.innerHTML).to.equal("<div>5</div>");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("should not fail when a component only uses state-less hooks", async () => {
|
|
387
|
+
// This test is suppose to trigger a condition in React where the
|
|
388
|
+
// HooksDispatcherOnMountWithHookTypesInDEV is used. This dispatcher is
|
|
389
|
+
// used in the development build of React if a component has hook types
|
|
390
|
+
// defined but no memoizedState, meaning no stateful hooks (e.g. useState)
|
|
391
|
+
// are used. `useContext` is an example of a state-less hook because it
|
|
392
|
+
// does not mount any hook state onto the fiber's memoizedState field.
|
|
393
|
+
//
|
|
394
|
+
// However, as of writing, because our react adapter inserts a
|
|
395
|
+
// useSyncExternalStore into all components, all components have memoized
|
|
396
|
+
// state and so this condition is never hit. However, I'm leaving the test
|
|
397
|
+
// to capture this unique behavior to hopefully catch any errors caused by
|
|
398
|
+
// not understanding or handling this in the future.
|
|
399
|
+
|
|
400
|
+
const sig = signal(0);
|
|
401
|
+
const MyContext = createContext(0);
|
|
402
|
+
|
|
403
|
+
function Child() {
|
|
404
|
+
const value = useContext(MyContext);
|
|
405
|
+
return (
|
|
406
|
+
<div>
|
|
407
|
+
{sig} {value}
|
|
408
|
+
</div>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let updateContext: () => void;
|
|
413
|
+
function App() {
|
|
414
|
+
const [value, setValue] = useState(0);
|
|
415
|
+
updateContext = () => setValue(value + 1);
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<MyContext.Provider value={value}>
|
|
419
|
+
<Child />
|
|
420
|
+
</MyContext.Provider>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
await render(<App />);
|
|
425
|
+
expect(scratch.innerHTML).to.equal("<div>0 0</div>");
|
|
426
|
+
|
|
427
|
+
await act(() => {
|
|
428
|
+
sig.value++;
|
|
429
|
+
});
|
|
430
|
+
expect(scratch.innerHTML).to.equal("<div>1 0</div>");
|
|
431
|
+
|
|
432
|
+
await act(() => {
|
|
433
|
+
updateContext();
|
|
434
|
+
});
|
|
435
|
+
expect(scratch.innerHTML).to.equal("<div>1 1</div>");
|
|
436
|
+
});
|
|
326
437
|
});
|
|
327
438
|
|
|
328
439
|
describe("useSignal()", () => {
|
|
@@ -386,14 +497,17 @@ describe("@preact/signals-react", () => {
|
|
|
386
497
|
|
|
387
498
|
expect(scratch.textContent).to.equal("bar");
|
|
388
499
|
|
|
389
|
-
// NOTE: Ideally, call should receive "1" as its third argument!
|
|
390
|
-
//
|
|
391
|
-
// This happens because we do signal-based effect runs after
|
|
392
|
-
// Perhaps we could find a way to defer the callback
|
|
500
|
+
// NOTE: Ideally, call should receive "1" as its third argument! The "0"
|
|
501
|
+
// indicates that React's DOM mutations hadn't yet been performed when the
|
|
502
|
+
// callback ran. This happens because we do signal-based effect runs after
|
|
503
|
+
// the first, not VDOM. Perhaps we could find a way to defer the callback
|
|
504
|
+
// when it coincides with a render? In React 16 when running in production
|
|
505
|
+
// however, we do see "1" as expected, likely because we are using a fake
|
|
506
|
+
// act() implementation which completes after the DOM has been updated.
|
|
393
507
|
expect(spy).to.have.been.calledOnceWith(
|
|
394
508
|
"bar",
|
|
395
509
|
scratch.firstElementChild,
|
|
396
|
-
"0" // ideally "1" - update if we find a nice way to do so!
|
|
510
|
+
isReact16 && isProd ? "1" : "0" // ideally always "1" - update if we find a nice way to do so!
|
|
397
511
|
);
|
|
398
512
|
});
|
|
399
513
|
|
|
@@ -441,7 +555,7 @@ describe("@preact/signals-react", () => {
|
|
|
441
555
|
expect(spy).to.have.been.calledOnceWith(
|
|
442
556
|
"bar",
|
|
443
557
|
child,
|
|
444
|
-
"0" // ideally "1" - update if we find a nice way to do so!
|
|
558
|
+
isReact16 && isProd ? "1" : "0" // ideally always "1" - update if we find a nice way to do so!
|
|
445
559
|
);
|
|
446
560
|
});
|
|
447
561
|
|
|
@@ -469,14 +583,16 @@ describe("@preact/signals-react", () => {
|
|
|
469
583
|
expect(spy).to.have.been.calledOnceWith("foo", child);
|
|
470
584
|
spy.resetHistory();
|
|
471
585
|
|
|
472
|
-
await
|
|
586
|
+
await act(() => {
|
|
587
|
+
root.unmount();
|
|
588
|
+
});
|
|
473
589
|
|
|
474
590
|
expect(scratch.innerHTML).to.equal("");
|
|
475
591
|
expect(spy).not.to.have.been.called;
|
|
476
592
|
expect(cleanup).to.have.been.calledOnce;
|
|
477
|
-
// @note: React cleans up the ref eagerly, so it's already null by the
|
|
478
|
-
// this is probably worth fixing at some point.
|
|
479
|
-
expect(cleanup).to.have.been.calledWith("foo", null);
|
|
593
|
+
// @note: React v18 cleans up the ref eagerly, so it's already null by the
|
|
594
|
+
// time the callback runs. this is probably worth fixing at some point.
|
|
595
|
+
expect(cleanup).to.have.been.calledWith("foo", isReact16 ? child : null);
|
|
480
596
|
});
|
|
481
597
|
});
|
|
482
598
|
});
|