Skip to content

[Fizz] Support basic SuspenseList forwards/backwards revealOrder #33306

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions fixtures/ssr/src/components/LargeContent.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import React, {Fragment, Suspense} from 'react';
import React, {
Fragment,
Suspense,
unstable_SuspenseList as SuspenseList,
} from 'react';

export default function LargeContent() {
return (
<Fragment>
<SuspenseList revealOrder="forwards">
<Suspense fallback={null}>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris
Expand Down Expand Up @@ -286,6 +290,6 @@ export default function LargeContent() {
interdum a. Proin nec odio in nulla vestibulum.
</p>
</Suspense>
</Fragment>
</SuspenseList>
);
}
14 changes: 5 additions & 9 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1318,10 +1318,8 @@ describe('ReactDOMFizzServer', () => {
expect(ref.current).toBe(null);
expect(getVisibleChildren(container)).toEqual(
<div>
Loading A
{/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
// isn't implemented fully yet. */}
<span>B</span>
{'Loading A'}
{'Loading B'}
</div>,
);

Expand All @@ -1335,11 +1333,9 @@ describe('ReactDOMFizzServer', () => {
// We haven't resolved yet.
expect(getVisibleChildren(container)).toEqual(
<div>
Loading A
{/* // TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
// isn't implemented fully yet. */}
<span>B</span>
Loading C
{'Loading A'}
{'Loading B'}
{'Loading C'}
</div>,
);

Expand Down
327 changes: 327 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
*/

'use strict';
import {
insertNodesAndExecuteScripts,
getVisibleChildren,
} from '../test-utils/FizzTestUtils';

let JSDOM;
let React;
let Suspense;
let SuspenseList;
let assertLog;
let Scheduler;
let ReactDOMFizzServer;
let Stream;
let document;
let writable;
let container;
let buffer = '';
let hasErrored = false;
let fatalError = undefined;

describe('ReactDOMFizSuspenseList', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
React = require('react');
assertLog = require('internal-test-utils').assertLog;
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');

Suspense = React.Suspense;
SuspenseList = React.unstable_SuspenseList;

Scheduler = require('scheduler');

// Test Environment
const jsdom = new JSDOM(
'<!DOCTYPE html><html><head></head><body><div id="container">',
{
runScripts: 'dangerously',
},
);
document = jsdom.window.document;
container = document.getElementById('container');
global.window = jsdom.window;
// The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
setTimeout(cb);

buffer = '';
hasErrored = false;

writable = new Stream.PassThrough();
writable.setEncoding('utf8');
writable.on('data', chunk => {
buffer += chunk;
});
writable.on('error', error => {
hasErrored = true;
fatalError = error;
});
});

afterEach(() => {
jest.restoreAllMocks();
});

async function serverAct(callback) {
await callback();
// Await one turn around the event loop.
// This assumes that we'll flush everything we have so far.
await new Promise(resolve => {
setImmediate(resolve);
});
if (hasErrored) {
throw fatalError;
}
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
// We also want to execute any scripts that are embedded.
// We assume that we have now received a proper fragment of HTML.
const bufferedContent = buffer;
buffer = '';
const temp = document.createElement('body');
temp.innerHTML = bufferedContent;
await insertNodesAndExecuteScripts(temp, container, null);
jest.runAllTimers();
}

function Text(props) {
Scheduler.log(props.text);
return <span>{props.text}</span>;
}

function createAsyncText(text) {
let resolved = false;
const Component = function () {
if (!resolved) {
Scheduler.log('Suspend! [' + text + ']');
throw promise;
}
return <Text text={text} />;
};
const promise = new Promise(resolve => {
Component.resolve = function () {
resolved = true;
return resolve();
};
});
return Component;
}

// @gate enableSuspenseList
it('shows content independently by default', async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a difference between a default SuspenseList and just wrapping it with a Fragment?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so but I always forget the special cases for updates that affect nested SuspenseList.

I forget that the default doesn't do anything a lot but basically you probably always should pick some options. We could make it a required props. It's mainly useful to disable conditionally. However, we don't really an option other than undefined. I've been thinking that maybe should add "independent" as an option for that case.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case is a bit interesting:

<SuspenseList>
<Suspense fallback={<Text text="Loading B" />}>
<B />
</Suspense>
<Suspense fallback={<Text text="Loading C" />}>
<C />
</Suspense>
</SuspenseList>

It's a case where because the outer one is in "together" mode, it blocks the inner ones from revealing independently. That's the same as if it was a Fragment so I think it's the same.

However, if the inner one had an explicit "independent" string, shouldn't that override the outer "together" mode?

const A = createAsyncText('A');
const B = createAsyncText('B');
const C = createAsyncText('C');

function Foo() {
return (
<div>
<SuspenseList>
<Suspense fallback={<Text text="Loading A" />}>
<A />
</Suspense>
<Suspense fallback={<Text text="Loading B" />}>
<B />
</Suspense>
<Suspense fallback={<Text text="Loading C" />}>
<C />
</Suspense>
</SuspenseList>
</div>
);
}

await A.resolve();

await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
pipe(writable);
});

assertLog(['A', 'Suspend! [B]', 'Suspend! [C]', 'Loading B', 'Loading C']);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
</div>,
);

await serverAct(() => C.resolve());
assertLog(['C']);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>Loading B</span>
<span>C</span>
</div>,
);

await serverAct(() => B.resolve());
assertLog(['B']);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>B</span>
<span>C</span>
</div>,
);
});

// @gate enableSuspenseList
it('displays each items in "forwards" order', async () => {
const A = createAsyncText('A');
const B = createAsyncText('B');
const C = createAsyncText('C');

function Foo() {
return (
<div>
<SuspenseList revealOrder="forwards">
<Suspense fallback={<Text text="Loading A" />}>
<A />
</Suspense>
<Suspense fallback={<Text text="Loading B" />}>
<B />
</Suspense>
<Suspense fallback={<Text text="Loading C" />}>
<C />
</Suspense>
</SuspenseList>
</div>
);
}

await C.resolve();

await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
pipe(writable);
});

assertLog([
'Suspend! [A]',
'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
'C',
'Loading A',
'Loading B',
'Loading C',
]);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>Loading A</span>
<span>Loading B</span>
<span>Loading C</span>
</div>,
);

await serverAct(() => A.resolve());
assertLog(['A']);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>Loading B</span>
<span>Loading C</span>
</div>,
);

await serverAct(() => B.resolve());
assertLog(['B']);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>B</span>
<span>C</span>
</div>,
);
});

// @gate enableSuspenseList
it('displays each items in "backwards" order', async () => {
const A = createAsyncText('A');
const B = createAsyncText('B');
const C = createAsyncText('C');

function Foo() {
return (
<div>
<SuspenseList revealOrder="backwards">
<Suspense fallback={<Text text="Loading A" />}>
<A />
</Suspense>
<Suspense fallback={<Text text="Loading B" />}>
<B />
</Suspense>
<Suspense fallback={<Text text="Loading C" />}>
<C />
</Suspense>
</SuspenseList>
</div>
);
}

await A.resolve();

await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
pipe(writable);
});

assertLog([
'Suspend! [C]',
'Suspend! [B]', // TODO: Defer rendering the content after fallback if previous suspended,
'A',
'Loading C',
'Loading B',
'Loading A',
]);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>Loading A</span>
<span>Loading B</span>
<span>Loading C</span>
</div>,
);

await serverAct(() => C.resolve());
assertLog(['C']);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>Loading A</span>
<span>Loading B</span>
<span>C</span>
</div>,
);

await serverAct(() => B.resolve());
assertLog(['B']);

expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>B</span>
<span>C</span>
</div>,
);
});
});
Loading
Loading