Files
react/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js
Josh Story b25bcd460f [Fizz] Support Suspense boundaries anywhere (#32069)
Suspense is meant to be composable but there has been a lonstanding
limitation with using Suspense above the `<body>` tag of an HTML
document due to peculiarities of how HTML is parsed. For instance if you
used Suspense to render an entire HTML document and had a fallback that
might flush an alternate Document the comment nodes which describe this
boundary scope won't be where they need to be in the DOM for client
React to properly hydrate them. This is somewhat a problem of our own
making in that we have a concept of a Preamble and we leave the closing
body and html tags behind until streaming has completed which produces a
valid HTML document that also matches the DOM structure that would be
parsed from it. However Preambles as a concept are too important to
features like Float to imagine moving away from this model and so we can
either choose to just accept that you cannot use Suspense anywhere
except inside the `<body>` or we can build special support for Suspense
into react-dom that has a coherent semantic with how HTML documents are
written and parsed.

This change implements Suspense support for react-dom/server by
correctly serializing boundaries during rendering, prerendering, and
resumgin on the server. It does not yet support Suspense everywhere on
the client but this will arrive in a subsequent change. In practice
Suspense cannot be used above the `<body>` tag today so this is not a
breaking change since no programs in the wild could be using this
feature anyway.

React's streaming rendering of HTML doesn't lend itself to replacing the
contents of the documentElement, head, or body of a Document. These are
already special cased in fiber as HostSingletons and similarly for Fizz
the values we render for these tags must never be updated by the Fizz
runtime once written. To accomplish these we redefine the Preamble as
the tags that represent these three singletons plus the contents of the
document.head. If you use Suspense above any part of the Preamble then
nothing will be written to the destination until the boundary is no
longer pending. If the boundary completes then the preamble from within
that boudnary will be output. If the boundary postpones or errors then
the preamble from the fallback will be used instead.

Additionally, by default anything that is not part of the preamble is
implicitly in body scope. This leads to the somewhat counterintuitive
consequence that the comment nodes we use to mark the borders of a
Suspense boundary in Fizz can appear INSIDE the preamble that was
rendered within it.

```typescript
render((
  <Suspense>
    <html lang="en">
      <body>
        <div>hello world</div>
      </body>
    </html>
  </Suspense>
))
```
will produce an HTML document like this
```html
<!DOCTYPE html>
<html lang="en">
  <head></head>
  <body>
    <!--$--> <-- this is the comment Node representing the outermost Suspense
    <div>hello world</div>
    <$--/$-->
  </body>
</html>
```

Later when I update Fiber to support Suspense anywhere hydration will
similarly start implicitly in the document body when the root is part of
the preamble (the document or one of it's singletons).
2025-01-17 10:54:11 -08:00

241 lines
6.5 KiB
JavaScript

/**
* 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
*/
'use strict';
import {
insertNodesAndExecuteScripts,
getVisibleChildren,
} from '../test-utils/FizzTestUtils';
import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
// Polyfills for test environment
global.ReadableStream =
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
global.TextEncoder = require('util').TextEncoder;
let act;
let assertLog;
let waitForPaint;
let container;
let React;
let Scheduler;
let ReactDOMServer;
let ReactDOMClient;
let useDeferredValue;
let Suspense;
describe('ReactDOMFizzForm', () => {
beforeEach(() => {
jest.resetModules();
Scheduler = require('scheduler');
patchMessageChannel(Scheduler);
act = require('internal-test-utils').act;
React = require('react');
ReactDOMServer = require('react-dom/server.browser');
ReactDOMClient = require('react-dom/client');
useDeferredValue = React.useDeferredValue;
Suspense = React.Suspense;
assertLog = require('internal-test-utils').assertLog;
waitForPaint = require('internal-test-utils').waitForPaint;
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
async function serverAct(callback) {
let maybePromise;
await act(() => {
maybePromise = callback();
if (maybePromise && typeof maybePromise.catch === 'function') {
maybePromise.catch(() => {});
}
});
return maybePromise;
}
async function readIntoContainer(stream) {
const reader = stream.getReader();
let result = '';
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
result += Buffer.from(value).toString('utf8');
}
const temp = document.createElement('div');
temp.innerHTML = result;
insertNodesAndExecuteScripts(temp, container, null);
}
function Text({text}) {
Scheduler.log(text);
return text;
}
it('returns initialValue argument, if provided', async () => {
function App() {
return useDeferredValue('Final', 'Initial');
}
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
expect(container.textContent).toEqual('Initial');
// After hydration, it's updated to the final value
await act(() => ReactDOMClient.hydrateRoot(container, <App />));
expect(container.textContent).toEqual('Final');
});
// @gate enablePostpone
it(
'if initial value postpones during hydration, it will switch to the ' +
'final value instead',
async () => {
function Content() {
const isInitial = useDeferredValue(false, true);
if (isInitial) {
React.unstable_postpone();
}
return <Text text="Final" />;
}
function App() {
return (
<div>
<Suspense fallback={<Text text="Loading..." />}>
<Content />
</Suspense>
</div>
);
}
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
expect(container.textContent).toEqual('Loading...');
assertLog(['Loading...']);
// After hydration, it's updated to the final value
await act(() => ReactDOMClient.hydrateRoot(container, <App />));
expect(container.textContent).toEqual('Final');
assertLog(['Loading...', 'Final']);
},
);
it(
'useDeferredValue during hydration has higher priority than remaining ' +
'incremental hydration',
async () => {
function B() {
const text = useDeferredValue('B [Final]', 'B [Initial]');
return <Text text={text} />;
}
function App() {
return (
<div>
<span>
<Text text="A" />
</span>
<Suspense fallback={<Text text="Loading..." />}>
<span>
<B />
</span>
<div>
<Suspense fallback={<Text text="Loading..." />}>
<span id="C" ref={cRef}>
<Text text="C" />
</span>
</Suspense>
</div>
</Suspense>
</div>
);
}
const cRef = React.createRef();
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
assertLog(['A', 'B [Initial]', 'C']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>B [Initial]</span>
<div>
<span id="C">C</span>
</div>
</div>,
);
const serverRenderedC = document.getElementById('C');
// On the client, we first hydrate the initial value, then upgrade
// to final.
await act(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
// First the outermost Suspense boundary hydrates.
await waitForPaint(['A']);
expect(cRef.current).toBe(null);
// Then the next level hydrates. This level includes a useDeferredValue,
// so we should prioritize upgrading it before we proceed to hydrating
// additional levels.
await waitForPaint(['B [Initial]']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>B [Initial]</span>
<div>
<span id="C">C</span>
</div>
</div>,
);
expect(cRef.current).toBe(null);
// This paint should only update B. C should still be dehydrated.
await waitForPaint(['B [Final]']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>B [Final]</span>
<div>
<span id="C">C</span>
</div>
</div>,
);
expect(cRef.current).toBe(null);
});
// Finally we can hydrate C
assertLog(['C']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span>A</span>
<span>B [Final]</span>
<div>
<span id="C">C</span>
</div>
</div>,
);
expect(cRef.current).toBe(serverRenderedC);
},
);
});