@samitouri / QOS-React-2 / commits / 06f98c1680

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