@samitouri / QOS-React-1 / commits / b836de613d

Fix continuation bug (#31434)

## Overview In `scheduleTaskForRootDuringMicrotask` we clear `root.callbackNode` if the work loop is [suspended waiting on data](https://github.com/facebook/react/blob/ac3ca097aeecae8fe3ec7f9b286307a923676518/packages/react-reconciler/src/ReactFiberRootScheduler.js#L338). But we don't null check `root.callbackNode` before returning a continuation in `performWorkOnRootViaSchedulerTask` where `scheduleTaskForRootDuringMicrotask` is synchronously called, causing an infinite loop when the only thing in the queue is something suspended waiting on data. This essentially restores the behavior from here: https://github.com/facebook/react/pull/26328/files#diff-72ff2175ae3569037f0b16802a41b0cda2b2d66bb97f2bda78ed8445ed487b58L1168 Found by investigating the failures for https://github.com/facebook/react/pull/31417 ## TODO - add a test --------- Co-authored-by: Joe Savona <joesavona@fb.com>

Ricky committed Nov 11, 2024 at 17:25 UTC b836de613d66ff36574af95cb93ad15fd743d1f4
3 files changed +681 -19
packages/react-reconciler/src/ReactFiberRootScheduler.js
+1 -1
@@ -482,7 +482,7 @@ function performWorkOnRootViaSchedulerTask(
482 // only safe to do because we know we're at the end of the browser task.
483 // So although it's not an actual microtask, it might as well be.
484 scheduleTaskForRootDuringMicrotask(root, now());
485 - if (root.callbackNode === originalCallbackNode) {
485 + if (root.callbackNode != null && root.callbackNode === originalCallbackNode) {
486 // The task node scheduled for this root is the same one that's
487 // currently executed. Need to return a continuation.
488 return performWorkOnRootViaSchedulerTask.bind(null, root);
packages/react-reconciler/src/__tests__/ActivityLegacySuspense-test.js new
+604
@@ -0,0 +1,604 @@
1 +let React;
2 +let ReactNoop;
3 +let Scheduler;
4 +let act;
5 +let LegacyHidden;
6 +let Activity;
7 +let Suspense;
8 +let useState;
9 +let useEffect;
10 +let startTransition;
11 +let textCache;
12 +let waitFor;
13 +let waitForPaint;
14 +let assertLog;
15 +
16 +describe('Activity Suspense', () => {
17 + beforeEach(() => {
18 + jest.resetModules();
19 +
20 + React = require('react');
21 + ReactNoop = require('react-noop-renderer');
22 + Scheduler = require('scheduler');
23 + act = require('internal-test-utils').act;
24 + LegacyHidden = React.unstable_LegacyHidden;
25 + Activity = React.unstable_Activity;
26 + Suspense = React.Suspense;
27 + useState = React.useState;
28 + useEffect = React.useEffect;
29 + startTransition = React.startTransition;
30 +
31 + const InternalTestUtils = require('internal-test-utils');
32 + waitFor = InternalTestUtils.waitFor;
33 + waitForPaint = InternalTestUtils.waitForPaint;
34 + assertLog = InternalTestUtils.assertLog;
35 +
36 + textCache = new Map();
37 + });
38 +
39 + function resolveText(text) {
40 + const record = textCache.get(text);
41 + if (record === undefined) {
42 + const newRecord = {
43 + status: 'resolved',
44 + value: text,
45 + };
46 + textCache.set(text, newRecord);
47 + } else if (record.status === 'pending') {
48 + const thenable = record.value;
49 + record.status = 'resolved';
50 + record.value = text;
51 + thenable.pings.forEach(t => t());
52 + }
53 + }
54 +
55 + function readText(text) {
56 + const record = textCache.get(text);
57 + if (record !== undefined) {
58 + switch (record.status) {
59 + case 'pending':
60 + Scheduler.log(`Suspend! [${text}]`);
61 + throw record.value;
62 + case 'rejected':
63 + throw record.value;
64 + case 'resolved':
65 + return record.value;
66 + }
67 + } else {
68 + Scheduler.log(`Suspend! [${text}]`);
69 + const thenable = {
70 + pings: [],
71 + then(resolve) {
72 + if (newRecord.status === 'pending') {
73 + thenable.pings.push(resolve);
74 + } else {
75 + Promise.resolve().then(() => resolve(newRecord.value));
76 + }
77 + },
78 + };
79 +
80 + const newRecord = {
81 + status: 'pending',
82 + value: thenable,
83 + };
84 + textCache.set(text, newRecord);
85 +
86 + throw thenable;
87 + }
88 + }
89 +
90 + function Text({text}) {
91 + Scheduler.log(text);
92 + return text;
93 + }
94 +
95 + function AsyncText({text}) {
96 + readText(text);
97 + Scheduler.log(text);
98 + return text;
99 + }
100 +
101 + // @gate enableActivity
102 + it('basic example of suspending inside hidden tree', async () => {
103 + const root = ReactNoop.createRoot();
104 +
105 + function App() {
106 + return (
107 + <Suspense fallback={<Text text="Loading..." />}>
108 + <span>
109 + <Text text="Visible" />
110 + </span>
111 + <Activity mode="hidden">
112 + <span>
113 + <AsyncText text="Hidden" />
114 + </span>
115 + </Activity>
116 + </Suspense>
117 + );
118 + }
119 +
120 + // The hidden tree hasn't finished loading, but we should still be able to
121 + // show the surrounding contents. The outer Suspense boundary
122 + // isn't affected.
123 + await act(() => {
124 + root.render(<App />);
125 + });
126 + assertLog(['Visible', 'Suspend! [Hidden]']);
127 + expect(root).toMatchRenderedOutput(<span>Visible</span>);
128 +
129 + // When the data resolves, we should be able to finish prerendering
130 + // the hidden tree.
131 + await act(async () => {
132 + await resolveText('Hidden');
133 + });
134 + assertLog(['Hidden']);
135 + expect(root).toMatchRenderedOutput(
136 + <>
137 + <span>Visible</span>
138 + <span hidden={true}>Hidden</span>
139 + </>,
140 + );
141 + });
142 +
143 + // @gate enableLegacyHidden
144 + test('LegacyHidden does not handle suspense', async () => {
145 + const root = ReactNoop.createRoot();
146 +
147 + function App() {
148 + return (
149 + <Suspense fallback={<Text text="Loading..." />}>
150 + <span>
151 + <Text text="Visible" />
152 + </span>
153 + <LegacyHidden mode="hidden">
154 + <span>
155 + <AsyncText text="Hidden" />
156 + </span>
157 + </LegacyHidden>
158 + </Suspense>
159 + );
160 + }
161 +
162 + // Unlike Activity, LegacyHidden never captures if something suspends
163 + await act(() => {
164 + root.render(<App />);
165 + });
166 + assertLog(['Visible', 'Suspend! [Hidden]', 'Loading...']);
167 + // Nearest Suspense boundary switches to a fallback even though the
168 + // suspended content is hidden.
169 + expect(root).toMatchRenderedOutput(
170 + <>
171 + <span hidden={true}>Visible</span>
172 + Loading...
173 + </>,
174 + );
175 + });
176 +
177 + // @gate __DEV__ && enableActivity
178 + test('Regression: Suspending on hide should not infinite loop.', async () => {
179 + // This regression only repros in public act.
180 + global.IS_REACT_ACT_ENVIRONMENT = true;
181 + const root = ReactNoop.createRoot();
182 +
183 + let setMode;
184 + function Container({text}) {
185 + const [mode, _setMode] = React.useState('visible');
186 + setMode = _setMode;
187 + useEffect(() => {
188 + return () => {
189 + Scheduler.log(`Clear [${text}]`);
190 + textCache.delete(text);
191 + };
192 + });
193 + return (
194 + //$FlowFixMe
195 + <Suspense fallback="Loading">
196 + <Activity mode={mode}>
197 + <AsyncText text={text} />
198 + </Activity>
199 + </Suspense>
200 + );
201 + }
202 +
203 + await React.act(() => {
204 + root.render(<Container text="hello" />);
205 + });
206 + assertLog([
207 + 'Suspend! [hello]',
208 + ...(gate(flags => flags.enableSiblingPrerendering)
209 + ? ['Suspend! [hello]']
210 + : []),
211 + ]);
212 + expect(root).toMatchRenderedOutput('Loading');
213 +
214 + await React.act(async () => {
215 + await resolveText('hello');
216 + });
217 + assertLog(['hello']);
218 + expect(root).toMatchRenderedOutput('hello');
219 +
220 + await React.act(async () => {
221 + setMode('hidden');
222 + });
223 + assertLog(['Clear [hello]', 'Suspend! [hello]']);
224 + expect(root).toMatchRenderedOutput('');
225 + });
226 +
227 + // @gate enableActivity
228 + test("suspending inside currently hidden tree that's switching to visible", async () => {
229 + const root = ReactNoop.createRoot();
230 +
231 + function Details({open, children}) {
232 + return (
233 + <Suspense fallback={<Text text="Loading..." />}>
234 + <span>
235 + <Text text={open ? 'Open' : 'Closed'} />
236 + </span>
237 + <Activity mode={open ? 'visible' : 'hidden'}>
238 + <span>{children}</span>
239 + </Activity>
240 + </Suspense>
241 + );
242 + }
243 +
244 + // The hidden tree hasn't finished loading, but we should still be able to
245 + // show the surrounding contents. It doesn't matter that there's no
246 + // Suspense boundary because the unfinished content isn't visible.
247 + await act(() => {
248 + root.render(
249 + <Details open={false}>
250 + <AsyncText text="Async" />
251 + </Details>,
252 + );
253 + });
254 + assertLog(['Closed', 'Suspend! [Async]']);
255 + expect(root).toMatchRenderedOutput(<span>Closed</span>);
256 +
257 + // But when we switch the boundary from hidden to visible, it should
258 + // now bubble to the nearest Suspense boundary.
259 + await act(() => {
260 + startTransition(() => {
261 + root.render(
262 + <Details open={true}>
263 + <AsyncText text="Async" />
264 + </Details>,
265 + );
266 + });
267 + });
268 + assertLog(['Open', 'Suspend! [Async]', 'Loading...']);
269 + // It should suspend with delay to prevent the already-visible Suspense
270 + // boundary from switching to a fallback
271 + expect(root).toMatchRenderedOutput(<span>Closed</span>);
272 +
273 + // Resolve the data and finish rendering
274 + await act(async () => {
275 + await resolveText('Async');
276 + });
277 + assertLog(['Open', 'Async']);
278 + expect(root).toMatchRenderedOutput(
279 + <>
280 + <span>Open</span>
281 + <span>Async</span>
282 + </>,
283 + );
284 + });
285 +
286 + // @gate enableActivity
287 + test("suspending inside currently visible tree that's switching to hidden", async () => {
288 + const root = ReactNoop.createRoot();
289 +
290 + function Details({open, children}) {
291 + return (
292 + <Suspense fallback={<Text text="Loading..." />}>
293 + <span>
294 + <Text text={open ? 'Open' : 'Closed'} />
295 + </span>
296 + <Activity mode={open ? 'visible' : 'hidden'}>
297 + <span>{children}</span>
298 + </Activity>
299 + </Suspense>
300 + );
301 + }
302 +
303 + // Initial mount. Nothing suspends
304 + await act(() => {
305 + root.render(
306 + <Details open={true}>
307 + <Text text="(empty)" />
308 + </Details>,
309 + );
310 + });
311 + assertLog(['Open', '(empty)']);
312 + expect(root).toMatchRenderedOutput(
313 + <>
314 + <span>Open</span>
315 + <span>(empty)</span>
316 + </>,
317 + );
318 +
319 + // Update that suspends inside the currently visible tree
320 + await act(() => {
321 + startTransition(() => {
322 + root.render(
323 + <Details open={true}>
324 + <AsyncText text="Async" />
325 + </Details>,
326 + );
327 + });
328 + });
329 + assertLog(['Open', 'Suspend! [Async]', 'Loading...']);
330 + // It should suspend with delay to prevent the already-visible Suspense
331 + // boundary from switching to a fallback
332 + expect(root).toMatchRenderedOutput(
333 + <>
334 + <span>Open</span>
335 + <span>(empty)</span>
336 + </>,
337 + );
338 +
339 + // Update that hides the suspended tree
340 + await act(() => {
341 + startTransition(() => {
342 + root.render(
343 + <Details open={false}>
344 + <AsyncText text="Async" />
345 + </Details>,
346 + );
347 + });
348 + });
349 + // Now the visible part of the tree can commit without being blocked
350 + // by the suspended content, which is hidden.
351 + assertLog(['Closed', 'Suspend! [Async]']);
352 + expect(root).toMatchRenderedOutput(
353 + <>
354 + <span>Closed</span>
355 + <span hidden={true}>(empty)</span>
356 + </>,
357 + );
358 +
359 + // Resolve the data and finish rendering
360 + await act(async () => {
361 + await resolveText('Async');
362 + });
363 + assertLog(['Async']);
364 + expect(root).toMatchRenderedOutput(
365 + <>
366 + <span>Closed</span>
367 + <span hidden={true}>Async</span>
368 + </>,
369 + );
370 + });
371 +
372 + // @gate enableActivity
373 + test('update that suspends inside hidden tree', async () => {
374 + let setText;
375 + function Child() {
376 + const [text, _setText] = useState('A');
377 + setText = _setText;
378 + return <AsyncText text={text} />;
379 + }
380 +
381 + function App({show}) {
382 + return (
383 + <Activity mode={show ? 'visible' : 'hidden'}>
384 + <span>
385 + <Child />
386 + </span>
387 + </Activity>
388 + );
389 + }
390 +
391 + const root = ReactNoop.createRoot();
392 + resolveText('A');
393 + await act(() => {
394 + root.render(<App show={false} />);
395 + });
396 + assertLog(['A']);
397 +
398 + await act(() => {
399 + startTransition(() => {
400 + setText('B');
401 + });
402 + });
403 + });
404 +
405 + // @gate enableActivity
406 + test('updates at multiple priorities that suspend inside hidden tree', async () => {
407 + let setText;
408 + let setStep;
409 + function Child() {
410 + const [text, _setText] = useState('A');
411 + setText = _setText;
412 +
413 + const [step, _setStep] = useState(0);
414 + setStep = _setStep;
415 +
416 + return <AsyncText text={text + step} />;
417 + }
418 +
419 + function App({show}) {
420 + return (
421 + <Activity mode={show ? 'visible' : 'hidden'}>
422 + <span>
423 + <Child />
424 + </span>
425 + </Activity>
426 + );
427 + }
428 +
429 + const root = ReactNoop.createRoot();
430 + resolveText('A0');
431 + await act(() => {
432 + root.render(<App show={false} />);
433 + });
434 + assertLog(['A0']);
435 + expect(root).toMatchRenderedOutput(<span hidden={true}>A0</span>);
436 +
437 + await act(() => {
438 + React.startTransition(() => {
439 + setStep(1);
440 + });
441 + ReactNoop.flushSync(() => {
442 + setText('B');
443 + });
444 + });
445 + assertLog([
446 + // The high priority render suspends again
447 + 'Suspend! [B0]',
448 + // There's still pending work in another lane, so we should attempt
449 + // that, too.
450 + 'Suspend! [B1]',
451 + ]);
452 + expect(root).toMatchRenderedOutput(<span hidden={true}>A0</span>);
453 +
454 + // Resolve the data and finish rendering
455 + await act(() => {
456 + resolveText('B1');
457 + });
458 + assertLog(['B1']);
459 + expect(root).toMatchRenderedOutput(<span hidden={true}>B1</span>);
460 + });
461 +
462 + // @gate enableActivity
463 + test('detect updates to a hidden tree during a concurrent event', async () => {
464 + // This is a pretty complex test case. It relates to how we detect if an
465 + // update is made to a hidden tree: when scheduling the update, we walk up
466 + // the fiber return path to see if any of the parents is a hidden Activity
467 + // component. This doesn't work if there's already a render in progress,
468 + // because the tree might be about to flip to hidden. To avoid a data race,
469 + // queue updates atomically: wait to queue the update until after the
470 + // current render has finished.
471 +
472 + let setInner;
473 + function Child({outer}) {
474 + const [inner, _setInner] = useState(0);
475 + setInner = _setInner;
476 +
477 + useEffect(() => {
478 + // Inner and outer values are always updated simultaneously, so they
479 + // should always be consistent.
480 + if (inner !== outer) {
481 + Scheduler.log('Tearing! Inner and outer are inconsistent!');
482 + } else {
483 + Scheduler.log('Inner and outer are consistent');
484 + }
485 + }, [inner, outer]);
486 +
487 + return <Text text={'Inner: ' + inner} />;
488 + }
489 +
490 + let setOuter;
491 + function App({show}) {
492 + const [outer, _setOuter] = useState(0);
493 + setOuter = _setOuter;
494 + return (
495 + <>
496 + <Activity mode={show ? 'visible' : 'hidden'}>
497 + <span>
498 + <Child outer={outer} />
499 + </span>
500 + </Activity>
501 + <span>
502 + <Text text={'Outer: ' + outer} />
503 + </span>
504 + <Suspense fallback={<Text text="Loading..." />}>
505 + <span>
506 + <Text text={'Sibling: ' + outer} />
507 + </span>
508 + </Suspense>
509 + </>
510 + );
511 + }
512 +
513 + // Render a hidden tree
514 + const root = ReactNoop.createRoot();
515 + resolveText('Async: 0');
516 + await act(() => {
517 + root.render(<App show={true} />);
518 + });
519 + assertLog([
520 + 'Inner: 0',
521 + 'Outer: 0',
522 + 'Sibling: 0',
523 + 'Inner and outer are consistent',
524 + ]);
525 + expect(root).toMatchRenderedOutput(
526 + <>
527 + <span>Inner: 0</span>
528 + <span>Outer: 0</span>
529 + <span>Sibling: 0</span>
530 + </>,
531 + );
532 +
533 + await act(async () => {
534 + // Update a value both inside and outside the hidden tree. These values
535 + // must always be consistent.
536 + startTransition(() => {
537 + setOuter(1);
538 + setInner(1);
539 + // In the same render, also hide the offscreen tree.
540 + root.render(<App show={false} />);
541 + });
542 +
543 + await waitFor([
544 + // The outer update will commit, but the inner update is deferred until
545 + // a later render.
546 + 'Outer: 1',
547 + ]);
548 +
549 + // Assert that we haven't committed quite yet
550 + expect(root).toMatchRenderedOutput(
551 + <>
552 + <span>Inner: 0</span>
553 + <span>Outer: 0</span>
554 + <span>Sibling: 0</span>
555 + </>,
556 + );
557 +
558 + // Before the tree commits, schedule a concurrent event. The inner update
559 + // is to a tree that's just about to be hidden.
560 + startTransition(() => {
561 + setOuter(2);
562 + setInner(2);
563 + });
564 +
565 + // Finish rendering and commit the in-progress render.
566 + await waitForPaint(['Sibling: 1']);
567 + expect(root).toMatchRenderedOutput(
568 + <>
569 + <span hidden={true}>Inner: 0</span>
570 + <span>Outer: 1</span>
571 + <span>Sibling: 1</span>
572 + </>,
573 + );
574 +
575 + // Now reveal the hidden tree at high priority.
576 + ReactNoop.flushSync(() => {
577 + root.render(<App show={true} />);
578 + });
579 + assertLog([
580 + // There are two pending updates on Inner, but only the first one
581 + // is processed, even though they share the same lane. If the second
582 + // update were erroneously processed, then Inner would be inconsistent
583 + // with Outer.
584 + 'Inner: 1',
585 + 'Outer: 1',
586 + 'Sibling: 1',
587 + 'Inner and outer are consistent',
588 + ]);
589 + });
590 + assertLog([
591 + 'Inner: 2',
592 + 'Outer: 2',
593 + 'Sibling: 2',
594 + 'Inner and outer are consistent',
595 + ]);
596 + expect(root).toMatchRenderedOutput(
597 + <>
598 + <span>Inner: 2</span>
599 + <span>Outer: 2</span>
600 + <span>Sibling: 2</span>
601 + </>,
602 + );
603 + });
604 +});
packages/react-reconciler/src/__tests__/ActivitySuspense-test.js
+76 -18
@@ -12,6 +12,7 @@ let textCache;
12 let waitFor;
13 let waitForPaint;
14 let assertLog;
15 +let use;
16
17 describe('Activity Suspense', () => {
18 beforeEach(() => {
@@ -27,6 +28,7 @@ describe('Activity Suspense', () => {
28 useState = React.useState;
29 useEffect = React.useEffect;
30 startTransition = React.startTransition;
31 + use = React.use;
32
33 const InternalTestUtils = require('internal-test-utils');
34 waitFor = InternalTestUtils.waitFor;
@@ -45,10 +47,10 @@ describe('Activity Suspense', () => {
47 };
48 textCache.set(text, newRecord);
49 } else if (record.status === 'pending') {
48 - const thenable = record.value;
50 + const resolve = record.resolve;
51 record.status = 'resolved';
52 record.value = text;
51 - thenable.pings.forEach(t => t());
53 + resolve();
54 }
55 }
56
@@ -58,7 +60,7 @@ describe('Activity Suspense', () => {
60 switch (record.status) {
61 case 'pending':
62 Scheduler.log(`Suspend! [${text}]`);
61 - throw record.value;
63 + return use(record.value);
64 case 'rejected':
65 throw record.value;
66 case 'resolved':
@@ -66,24 +68,19 @@ describe('Activity Suspense', () => {
68 }
69 } else {
70 Scheduler.log(`Suspend! [${text}]`);
69 - const thenable = {
70 - pings: [],
71 - then(resolve) {
72 - if (newRecord.status === 'pending') {
73 - thenable.pings.push(resolve);
74 - } else {
75 - Promise.resolve().then(() => resolve(newRecord.value));
76 - }
77 - },
78 - };
71 + let resolve;
72 + const promise = new Promise(_resolve => {
73 + resolve = _resolve;
74 + });
75
76 const newRecord = {
77 status: 'pending',
82 - value: thenable,
78 + value: promise,
79 + resolve,
80 };
81 textCache.set(text, newRecord);
82
86 - throw thenable;
83 + return use(promise);
84 }
85 }
86
@@ -174,6 +171,56 @@ describe('Activity Suspense', () => {
171 );
172 });
173
174 + // @gate __DEV__ && enableActivity
175 + test('Regression: Suspending on hide should not infinite loop.', async () => {
176 + // This regression only repros in public act.
177 + global.IS_REACT_ACT_ENVIRONMENT = true;
178 + const root = ReactNoop.createRoot();
179 +
180 + let setMode;
181 + function Container({text}) {
182 + const [mode, _setMode] = React.useState('visible');
183 + setMode = _setMode;
184 + useEffect(() => {
185 + return () => {
186 + Scheduler.log(`Clear [${text}]`);
187 + textCache.delete(text);
188 + };
189 + });
190 + return (
191 + //$FlowFixMe
192 + <Suspense fallback="Loading">
193 + <Activity mode={mode}>
194 + <AsyncText text={text} />
195 + </Activity>
196 + </Suspense>
197 + );
198 + }
199 +
200 + await React.act(() => {
201 + root.render(<Container text="hello" />);
202 + });
203 + assertLog([
204 + 'Suspend! [hello]',
205 + ...(gate(flags => flags.enableSiblingPrerendering)
206 + ? ['Suspend! [hello]']
207 + : []),
208 + ]);
209 + expect(root).toMatchRenderedOutput('Loading');
210 +
211 + await React.act(async () => {
212 + await resolveText('hello');
213 + });
214 + assertLog(['hello']);
215 + expect(root).toMatchRenderedOutput('hello');
216 +
217 + await React.act(() => {
218 + setMode('hidden');
219 + });
220 + assertLog(['Clear [hello]', 'Suspend! [hello]']);
221 + expect(root).toMatchRenderedOutput('');
222 + });
223 +
224 // @gate enableActivity
225 test("suspending inside currently hidden tree that's switching to visible", async () => {
226 const root = ReactNoop.createRoot();
@@ -215,7 +262,11 @@ describe('Activity Suspense', () => {
262 );
263 });
264 });
218 - assertLog(['Open', 'Suspend! [Async]', 'Loading...']);
265 + assertLog([
266 + 'Open',
267 + 'Suspend! [Async]',
268 + ...(gate(flags => flags.enableSiblingPrerendering) ? ['Loading...'] : []),
269 + ]);
270 // It should suspend with delay to prevent the already-visible Suspense
271 // boundary from switching to a fallback
272 expect(root).toMatchRenderedOutput(<span>Closed</span>);
@@ -224,7 +275,10 @@ describe('Activity Suspense', () => {
275 await act(async () => {
276 await resolveText('Async');
277 });
227 - assertLog(['Open', 'Async']);
278 + assertLog([
279 + ...(gate(flags => flags.enableSiblingPrerendering) ? ['Open'] : []),
280 + 'Async',
281 + ]);
282 expect(root).toMatchRenderedOutput(
283 <>
284 <span>Open</span>
@@ -276,7 +330,11 @@ describe('Activity Suspense', () => {
330 );
331 });
332 });
279 - assertLog(['Open', 'Suspend! [Async]', 'Loading...']);
333 + assertLog([
334 + 'Open',
335 + 'Suspend! [Async]',
336 + ...(gate(flags => flags.enableSiblingPrerendering) ? ['Loading...'] : []),
337 + ]);
338 // It should suspend with delay to prevent the already-visible Suspense
339 // boundary from switching to a fallback
340 expect(root).toMatchRenderedOutput(