Implement useSyncExternalStore in Fiber (#22239)
This adds an initial implementation of useSyncExternalStore to the fiber reconciler. It's mostly a copy-paste of the userspace implementation, which is not ideal but is a good enough starting place. The main change we'll want to make to this native implementation is to move the tearing checks from the layout phase to an earlier, pre-commit phase so that code that runs in the commit phase always observes a consistent tree. Follow-ups: - Implement in Fizz - Implement in old SSR renderer - Implement in react-debug-hooks
Andrew Clark committed
Sep 7, 2021 at 13:22 UTC
06f98c168060ef0213526a1aee696a568cec7f54
3 files changed
+305
-76
packages/react-reconciler/src/ReactFiberHooks.new.js
+117
-2
@@ -51,6 +51,7 @@ import {
51
markRootMutableRead,
52
} from './ReactFiberLane.new';
53
import {
54
+ DiscreteEventPriority,
55
ContinuousEventPriority,
56
getCurrentUpdatePriority,
57
setCurrentUpdatePriority,
@@ -136,6 +137,7 @@ export type UpdateQueue<S, A> = {|
137
138
let didWarnAboutMismatchedHooksForComponent;
139
let didWarnAboutUseOpaqueIdentifier;
140
+let didWarnUncachedGetSnapshot;
141
if (__DEV__) {
142
didWarnAboutUseOpaqueIdentifier = {};
143
didWarnAboutMismatchedHooksForComponent = new Set();
@@ -1246,14 +1248,127 @@ function mountSyncExternalStore<T>(
1248
subscribe: (() => void) => () => void,
1249
getSnapshot: () => T,
1250
): T {
1249
- throw new Error('Not yet implemented');
1251
+ const hook = mountWorkInProgressHook();
1252
+ return useSyncExternalStore(hook, subscribe, getSnapshot);
1253
}
1254
1255
function updateSyncExternalStore<T>(
1256
subscribe: (() => void) => () => void,
1257
getSnapshot: () => T,
1258
): T {
1256
- throw new Error('Not yet implemented');
1259
+ const hook = updateWorkInProgressHook();
1260
+ return useSyncExternalStore(hook, subscribe, getSnapshot);
1261
+}
1262
+
1263
+function useSyncExternalStore<T>(
1264
+ hook: Hook,
1265
+ subscribe: (() => void) => () => void,
1266
+ getSnapshot: () => T,
1267
+): T {
1268
+ // TODO: This is a copy-paste of the userspace shim. We can improve the
1269
+ // built-in implementation using lower-level APIs. We also intend to move
1270
+ // the tearing checks to an earlier, pre-commit phase so that the layout
1271
+ // effects always observe a consistent tree.
1272
+
1273
+ const dispatcher = ReactCurrentDispatcher.current;
1274
+
1275
+ // Read the current snapshot from the store on every render. Again, this
1276
+ // breaks the rules of React, and only works here because of specific
1277
+ // implementation details, most importantly that updates are
1278
+ // always synchronous.
1279
+ const value = getSnapshot();
1280
+ if (__DEV__) {
1281
+ if (!didWarnUncachedGetSnapshot) {
1282
+ if (value !== getSnapshot()) {
1283
+ console.error(
1284
+ 'The result of getSnapshot should be cached to avoid an infinite loop',
1285
+ );
1286
+ didWarnUncachedGetSnapshot = true;
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ // Because updates are synchronous, we don't queue them. Instead we force a
1292
+ // re-render whenever the subscribed state changes by updating an some
1293
+ // arbitrary useState hook. Then, during render, we call getSnapshot to read
1294
+ // the current value.
1295
+ //
1296
+ // Because we don't actually use the state returned by the useState hook, we
1297
+ // can save a bit of memory by storing other stuff in that slot.
1298
+ //
1299
+ // To implement the early bailout, we need to track some things on a mutable
1300
+ // object. Usually, we would put that in a useRef hook, but we can stash it in
1301
+ // our useState hook instead.
1302
+ //
1303
+ // To force a re-render, we call forceUpdate({inst}). That works because the
1304
+ // new object always fails an equality check.
1305
+ const [{inst}, forceUpdate] = dispatcher.useState({
1306
+ inst: {value, getSnapshot},
1307
+ });
1308
+
1309
+ // Track the latest getSnapshot function with a ref. This needs to be updated
1310
+ // in the layout phase so we can access it during the tearing check that
1311
+ // happens on subscribe.
1312
+ // TODO: Circumvent SSR warning
1313
+ dispatcher.useLayoutEffect(() => {
1314
+ inst.value = value;
1315
+ inst.getSnapshot = getSnapshot;
1316
+
1317
+ // Whenever getSnapshot or subscribe changes, we need to check in the
1318
+ // commit phase if there was an interleaved mutation. In concurrent mode
1319
+ // this can happen all the time, but even in synchronous mode, an earlier
1320
+ // effect may have mutated the store.
1321
+ if (checkIfSnapshotChanged(inst)) {
1322
+ // Force a re-render.
1323
+ const prevTransition = ReactCurrentBatchConfig.transition;
1324
+ const prevPriority = getCurrentUpdatePriority();
1325
+ ReactCurrentBatchConfig.transition = 0;
1326
+ setCurrentUpdatePriority(DiscreteEventPriority);
1327
+ forceUpdate({inst});
1328
+ setCurrentUpdatePriority(prevPriority);
1329
+ ReactCurrentBatchConfig.transition = prevTransition;
1330
+ }
1331
+ }, [subscribe, value, getSnapshot]);
1332
+
1333
+ dispatcher.useEffect(() => {
1334
+ const handleStoreChange = () => {
1335
+ // TODO: Because there is no cross-renderer API for batching updates, it's
1336
+ // up to the consumer of this library to wrap their subscription event
1337
+ // with unstable_batchedUpdates. Should we try to detect when this isn't
1338
+ // the case and print a warning in development?
1339
+
1340
+ // The store changed. Check if the snapshot changed since the last time we
1341
+ // read from the store.
1342
+ if (checkIfSnapshotChanged(inst)) {
1343
+ // Force a re-render.
1344
+ const prevTransition = ReactCurrentBatchConfig.transition;
1345
+ const prevPriority = getCurrentUpdatePriority();
1346
+ ReactCurrentBatchConfig.transition = 0;
1347
+ setCurrentUpdatePriority(DiscreteEventPriority);
1348
+ forceUpdate({inst});
1349
+ setCurrentUpdatePriority(prevPriority);
1350
+ ReactCurrentBatchConfig.transition = prevTransition;
1351
+ }
1352
+ };
1353
+ // Check for changes right before subscribing. Subsequent changes will be
1354
+ // detected in the subscription handler.
1355
+ handleStoreChange();
1356
+ // Subscribe to the store and return a clean-up function.
1357
+ return subscribe(handleStoreChange);
1358
+ }, [subscribe]);
1359
+
1360
+ return value;
1361
+}
1362
+
1363
+function checkIfSnapshotChanged(inst) {
1364
+ const latestGetSnapshot = inst.getSnapshot;
1365
+ const prevValue = inst.value;
1366
+ try {
1367
+ const nextValue = latestGetSnapshot();
1368
+ return !is(prevValue, nextValue);
1369
+ } catch (error) {
1370
+ return true;
1371
+ }
1372
}
1373
1374
function mountState<S>(
packages/react-reconciler/src/ReactFiberHooks.old.js
+117
-2
@@ -51,6 +51,7 @@ import {
51
markRootMutableRead,
52
} from './ReactFiberLane.old';
53
import {
54
+ DiscreteEventPriority,
55
ContinuousEventPriority,
56
getCurrentUpdatePriority,
57
setCurrentUpdatePriority,
@@ -136,6 +137,7 @@ export type UpdateQueue<S, A> = {|
137
138
let didWarnAboutMismatchedHooksForComponent;
139
let didWarnAboutUseOpaqueIdentifier;
140
+let didWarnUncachedGetSnapshot;
141
if (__DEV__) {
142
didWarnAboutUseOpaqueIdentifier = {};
143
didWarnAboutMismatchedHooksForComponent = new Set();
@@ -1246,14 +1248,127 @@ function mountSyncExternalStore<T>(
1248
subscribe: (() => void) => () => void,
1249
getSnapshot: () => T,
1250
): T {
1249
- throw new Error('Not yet implemented');
1251
+ const hook = mountWorkInProgressHook();
1252
+ return useSyncExternalStore(hook, subscribe, getSnapshot);
1253
}
1254
1255
function updateSyncExternalStore<T>(
1256
subscribe: (() => void) => () => void,
1257
getSnapshot: () => T,
1258
): T {
1256
- throw new Error('Not yet implemented');
1259
+ const hook = updateWorkInProgressHook();
1260
+ return useSyncExternalStore(hook, subscribe, getSnapshot);
1261
+}
1262
+
1263
+function useSyncExternalStore<T>(
1264
+ hook: Hook,
1265
+ subscribe: (() => void) => () => void,
1266
+ getSnapshot: () => T,
1267
+): T {
1268
+ // TODO: This is a copy-paste of the userspace shim. We can improve the
1269
+ // built-in implementation using lower-level APIs. We also intend to move
1270
+ // the tearing checks to an earlier, pre-commit phase so that the layout
1271
+ // effects always observe a consistent tree.
1272
+
1273
+ const dispatcher = ReactCurrentDispatcher.current;
1274
+
1275
+ // Read the current snapshot from the store on every render. Again, this
1276
+ // breaks the rules of React, and only works here because of specific
1277
+ // implementation details, most importantly that updates are
1278
+ // always synchronous.
1279
+ const value = getSnapshot();
1280
+ if (__DEV__) {
1281
+ if (!didWarnUncachedGetSnapshot) {
1282
+ if (value !== getSnapshot()) {
1283
+ console.error(
1284
+ 'The result of getSnapshot should be cached to avoid an infinite loop',
1285
+ );
1286
+ didWarnUncachedGetSnapshot = true;
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ // Because updates are synchronous, we don't queue them. Instead we force a
1292
+ // re-render whenever the subscribed state changes by updating an some
1293
+ // arbitrary useState hook. Then, during render, we call getSnapshot to read
1294
+ // the current value.
1295
+ //
1296
+ // Because we don't actually use the state returned by the useState hook, we
1297
+ // can save a bit of memory by storing other stuff in that slot.
1298
+ //
1299
+ // To implement the early bailout, we need to track some things on a mutable
1300
+ // object. Usually, we would put that in a useRef hook, but we can stash it in
1301
+ // our useState hook instead.
1302
+ //
1303
+ // To force a re-render, we call forceUpdate({inst}). That works because the
1304
+ // new object always fails an equality check.
1305
+ const [{inst}, forceUpdate] = dispatcher.useState({
1306
+ inst: {value, getSnapshot},
1307
+ });
1308
+
1309
+ // Track the latest getSnapshot function with a ref. This needs to be updated
1310
+ // in the layout phase so we can access it during the tearing check that
1311
+ // happens on subscribe.
1312
+ // TODO: Circumvent SSR warning
1313
+ dispatcher.useLayoutEffect(() => {
1314
+ inst.value = value;
1315
+ inst.getSnapshot = getSnapshot;
1316
+
1317
+ // Whenever getSnapshot or subscribe changes, we need to check in the
1318
+ // commit phase if there was an interleaved mutation. In concurrent mode
1319
+ // this can happen all the time, but even in synchronous mode, an earlier
1320
+ // effect may have mutated the store.
1321
+ if (checkIfSnapshotChanged(inst)) {
1322
+ // Force a re-render.
1323
+ const prevTransition = ReactCurrentBatchConfig.transition;
1324
+ const prevPriority = getCurrentUpdatePriority();
1325
+ ReactCurrentBatchConfig.transition = 0;
1326
+ setCurrentUpdatePriority(DiscreteEventPriority);
1327
+ forceUpdate({inst});
1328
+ setCurrentUpdatePriority(prevPriority);
1329
+ ReactCurrentBatchConfig.transition = prevTransition;
1330
+ }
1331
+ }, [subscribe, value, getSnapshot]);
1332
+
1333
+ dispatcher.useEffect(() => {
1334
+ const handleStoreChange = () => {
1335
+ // TODO: Because there is no cross-renderer API for batching updates, it's
1336
+ // up to the consumer of this library to wrap their subscription event
1337
+ // with unstable_batchedUpdates. Should we try to detect when this isn't
1338
+ // the case and print a warning in development?
1339
+
1340
+ // The store changed. Check if the snapshot changed since the last time we
1341
+ // read from the store.
1342
+ if (checkIfSnapshotChanged(inst)) {
1343
+ // Force a re-render.
1344
+ const prevTransition = ReactCurrentBatchConfig.transition;
1345
+ const prevPriority = getCurrentUpdatePriority();
1346
+ ReactCurrentBatchConfig.transition = 0;
1347
+ setCurrentUpdatePriority(DiscreteEventPriority);
1348
+ forceUpdate({inst});
1349
+ setCurrentUpdatePriority(prevPriority);
1350
+ ReactCurrentBatchConfig.transition = prevTransition;
1351
+ }
1352
+ };
1353
+ // Check for changes right before subscribing. Subsequent changes will be
1354
+ // detected in the subscription handler.
1355
+ handleStoreChange();
1356
+ // Subscribe to the store and return a clean-up function.
1357
+ return subscribe(handleStoreChange);
1358
+ }, [subscribe]);
1359
+
1360
+ return value;
1361
+}
1362
+
1363
+function checkIfSnapshotChanged(inst) {
1364
+ const latestGetSnapshot = inst.getSnapshot;
1365
+ const prevValue = inst.value;
1366
+ try {
1367
+ const nextValue = latestGetSnapshot();
1368
+ return !is(prevValue, nextValue);
1369
+ } catch (error) {
1370
+ return true;
1371
+ }
1372
}
1373
1374
function mountState<S>(
packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js
+71
-72
@@ -116,8 +116,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
116
};
117
}
118
119
- // @gate !variant
120
- test('basic usage', () => {
119
+ test('basic usage', async () => {
120
const store = createExternalStore('Initial');
121
122
function App() {
@@ -126,19 +125,18 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
125
}
126
127
const root = createRoot();
129
- act(() => root.render(<App />));
128
+ await act(() => root.render(<App />));
129
130
expect(Scheduler).toHaveYielded(['Initial']);
131
expect(root).toMatchRenderedOutput('Initial');
132
134
- act(() => {
133
+ await act(() => {
134
store.set('Updated');
135
});
136
expect(Scheduler).toHaveYielded(['Updated']);
137
expect(root).toMatchRenderedOutput('Updated');
138
});
139
141
- // @gate !variant
140
test('skips re-rendering if nothing changes', () => {
141
const store = createExternalStore('Initial');
142
@@ -162,8 +160,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
160
expect(root).toMatchRenderedOutput('Initial');
161
});
162
165
- // @gate !variant
166
- test('switch to a different store', () => {
163
+ test('switch to a different store', async () => {
164
const storeA = createExternalStore(0);
165
const storeB = createExternalStore(0);
166
@@ -176,29 +173,31 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
173
}
174
175
const root = createRoot();
179
- act(() => root.render(<App />));
176
+ await act(() => root.render(<App />));
177
178
expect(Scheduler).toHaveYielded([0]);
179
expect(root).toMatchRenderedOutput('0');
180
184
- act(() => {
181
+ await act(() => {
182
storeA.set(1);
183
});
184
expect(Scheduler).toHaveYielded([1]);
185
expect(root).toMatchRenderedOutput('1');
186
190
- // Switch stores
187
+ // Switch stores and update in the same batch
188
act(() => {
192
- // This update will be disregarded
193
- storeA.set(2);
194
- setStore(storeB);
189
+ ReactNoop.flushSync(() => {
190
+ // This update will be disregarded
191
+ storeA.set(2);
192
+ setStore(storeB);
193
+ });
194
});
195
// Now reading from B instead of A
196
expect(Scheduler).toHaveYielded([0]);
197
expect(root).toMatchRenderedOutput('0');
198
199
// Update A
201
- act(() => {
200
+ await act(() => {
201
storeA.set(3);
202
});
203
// Nothing happened, because we're no longer subscribed to A
@@ -206,15 +205,14 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
205
expect(root).toMatchRenderedOutput('0');
206
207
// Update B
209
- act(() => {
208
+ await act(() => {
209
storeB.set(1);
210
});
211
expect(Scheduler).toHaveYielded([1]);
212
expect(root).toMatchRenderedOutput('1');
213
});
214
216
- // @gate !variant
217
- test('selecting a specific value inside getSnapshot', () => {
215
+ test('selecting a specific value inside getSnapshot', async () => {
216
const store = createExternalStore({a: 0, b: 0});
217
218
function A() {
@@ -242,7 +240,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
240
expect(root).toMatchRenderedOutput('A0B0');
241
242
// Update b but not a
245
- act(() => {
243
+ await act(() => {
244
store.set({a: 0, b: 1});
245
});
246
// Only b re-renders
@@ -250,7 +248,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
248
expect(root).toMatchRenderedOutput('A0B1');
249
250
// Update a but not b
253
- act(() => {
251
+ await act(() => {
252
store.set({a: 1, b: 1});
253
});
254
// Only a re-renders
@@ -258,11 +256,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
256
expect(root).toMatchRenderedOutput('A1B1');
257
});
258
259
+ // In React 18, you can't observe in between a sync render and its
260
+ // passive effects, so this is only relevant to legacy roots
261
// @gate !variant
262
test(
263
"compares to current state before bailing out, even when there's a " +
264
'mutation in between the sync and passive effects',
265
- () => {
265
+ async () => {
266
const store = createExternalStore(0);
267
268
function App() {
@@ -302,7 +302,6 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
302
},
303
);
304
305
- // @gate !variant
305
test('mutating the store in between render and commit when getSnapshot has changed', () => {
306
const store = createExternalStore({a: 1, b: 1});
307
@@ -362,7 +361,6 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
361
expect(root).toMatchRenderedOutput('B2');
362
});
363
365
- // @gate !variant
364
test('mutating the store in between render and commit when getSnapshot has _not_ changed', () => {
365
// Same as previous test, but `getSnapshot` does not change
366
const store = createExternalStore({a: 1, b: 1});
@@ -421,8 +419,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
419
expect(root).toMatchRenderedOutput('A1');
420
});
421
424
- // @gate !variant
425
- test("does not bail out if the previous update hasn't finished yet", () => {
422
+ test("does not bail out if the previous update hasn't finished yet", async () => {
423
const store = createExternalStore(0);
424
425
function Child1() {
@@ -453,14 +450,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
450
expect(Scheduler).toHaveYielded([0, 0]);
451
expect(root).toMatchRenderedOutput('00');
452
456
- act(() => {
453
+ await act(() => {
454
store.set(1);
455
});
456
expect(Scheduler).toHaveYielded([1, 1, 'Reset back to 0', 0, 0]);
457
expect(root).toMatchRenderedOutput('00');
458
});
459
463
- // @gate !variant
460
test('uses the latest getSnapshot, even if it changed in the same batch as a store update', () => {
461
const store = createExternalStore({a: 0, b: 0});
462
@@ -481,16 +477,17 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
477
478
// Update the store and getSnapshot at the same time
479
act(() => {
484
- setGetSnapshot(() => getSnapshotB);
485
- store.set({a: 1, b: 2});
480
+ ReactNoop.flushSync(() => {
481
+ setGetSnapshot(() => getSnapshotB);
482
+ store.set({a: 1, b: 2});
483
+ });
484
});
485
// It should read from B instead of A
486
expect(Scheduler).toHaveYielded([2]);
487
expect(root).toMatchRenderedOutput('2');
488
});
489
492
- // @gate !variant
493
- test('handles errors thrown by getSnapshot or isEqual', () => {
490
+ test('handles errors thrown by getSnapshot', async () => {
491
class ErrorBoundary extends React.Component {
492
state = {error: null};
493
static getDerivedStateFromError(error) {
@@ -511,24 +508,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
508
});
509
510
function App() {
514
- const {value} = useSyncExternalStore(
515
- store.subscribe,
516
- () => {
517
- const state = store.getState();
518
- if (state.throwInGetSnapshot) {
519
- throw new Error('Error in getSnapshot');
520
- }
521
- return state;
522
- },
523
- {
524
- isEqual: (a, b) => {
525
- if (a.throwInIsEqual || b.throwInIsEqual) {
526
- throw new Error('Error in isEqual');
527
- }
528
- return a.value === b.value;
529
- },
530
- },
531
- );
511
+ const {value} = useSyncExternalStore(store.subscribe, () => {
512
+ const state = store.getState();
513
+ if (state.throwInGetSnapshot) {
514
+ throw new Error('Error in getSnapshot');
515
+ }
516
+ return state;
517
+ });
518
return <Text text={value} />;
519
}
520
@@ -545,30 +531,45 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
531
expect(root).toMatchRenderedOutput('0');
532
533
// Update that throws in a getSnapshot. We can catch it with an error boundary.
548
- act(() => {
534
+ await act(() => {
535
store.set({value: 1, throwInGetSnapshot: true, throwInIsEqual: false});
536
});
551
- expect(Scheduler).toHaveYielded(['Error in getSnapshot']);
537
+ if (gate(flags => flags.variant)) {
538
+ expect(Scheduler).toHaveYielded([
539
+ 'Error in getSnapshot',
540
+ // In a concurrent root, React renders a second time to attempt to
541
+ // recover from the error.
542
+ 'Error in getSnapshot',
543
+ ]);
544
+ } else {
545
+ expect(Scheduler).toHaveYielded(['Error in getSnapshot']);
546
+ }
547
expect(root).toMatchRenderedOutput('Error in getSnapshot');
548
+ });
549
554
- // Clear the error.
555
- act(() => {
556
- store.set({value: 1, throwInGetSnapshot: false, throwInIsEqual: false});
557
- errorBoundary.current.setState({error: null});
558
- });
559
- expect(Scheduler).toHaveYielded([1]);
560
- expect(root).toMatchRenderedOutput('1');
550
+ test('Infinite loop if getSnapshot keeps returning new reference', () => {
551
+ const store = createExternalStore({});
552
562
- // Update that throws in isEqual. Since isEqual only prevents a bail out,
563
- // we don't need to surface an error. But we do have to re-render.
564
- act(() => {
565
- store.set({value: 1, throwInGetSnapshot: false, throwInIsEqual: true});
566
- });
567
- expect(Scheduler).toHaveYielded([1]);
568
- expect(root).toMatchRenderedOutput('1');
553
+ function App() {
554
+ const text = useSyncExternalStore(store.subscribe, () => ({}));
555
+ return <Text text={JSON.stringify(text)} />;
556
+ }
557
+
558
+ spyOnDev(console, 'error');
559
+ const root = createRoot();
560
+
561
+ expect(() => act(() => root.render(<App />))).toThrow(
562
+ 'Maximum update depth exceeded. This can happen when a component repeatedly ' +
563
+ 'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' +
564
+ 'the number of nested updates to prevent infinite loops.',
565
+ );
566
+ if (__DEV__) {
567
+ expect(console.error.calls.argsFor(0)[0]).toMatch(
568
+ 'The result of getSnapshot should be cached to avoid an infinite loop',
569
+ );
570
+ }
571
});
572
571
- // @gate !variant
573
test('Infinite loop if getSnapshot keeps returning new reference', () => {
574
const store = createExternalStore({});
575
@@ -593,8 +594,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
594
});
595
596
describe('extra features implemented in user-space', () => {
596
- // @gate !variant
597
- test('memoized selectors are only called once per update', () => {
597
+ test('memoized selectors are only called once per update', async () => {
598
const store = createExternalStore({a: 0, b: 0});
599
600
function selector(state) {
@@ -619,7 +619,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
619
expect(root).toMatchRenderedOutput('A0');
620
621
// Update the store
622
- act(() => {
622
+ await act(() => {
623
store.set({a: 1, b: 0});
624
});
625
expect(Scheduler).toHaveYielded([
@@ -633,8 +633,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
633
expect(root).toMatchRenderedOutput('A1');
634
});
635
636
- // @gate !variant
637
- test('Using isEqual to bailout', () => {
636
+ test('Using isEqual to bailout', async () => {
637
const store = createExternalStore({a: 0, b: 0});
638
639
function A() {
@@ -674,7 +673,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
673
expect(root).toMatchRenderedOutput('A0B0');
674
675
// Update b but not a
677
- act(() => {
676
+ await act(() => {
677
store.set({a: 0, b: 1});
678
});
679
// Only b re-renders
@@ -682,7 +681,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
681
expect(root).toMatchRenderedOutput('A0B1');
682
683
// Update a but not b
685
- act(() => {
684
+ await act(() => {
685
store.set({a: 1, b: 1});
686
});
687
// Only a re-renders