react/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
Sebastian Markbåge 143d3e1b89
[Fizz] Emit link rel="expect" to block render before the shell has fully loaded (#33016)
The semantics of React is that anything outside of Suspense boundaries
in a transition doesn't display until it has fully unsuspended. With SSR
streaming the intention is to preserve that.

We explicitly don't want to support the mode of document streaming
normally supported by the browser where it can paint content as tags
stream in since that leads to content popping in and thrashing in
unpredictable ways. This should instead be modeled explictly by nested
Suspense boundaries or something like SuspenseList.

After the first shell any nested Suspense boundaries are only revealed,
by script, once they're fully streamed in to the next boundary. So this
is already the case there. However, for the initial shell we have been
at the mercy of browser heuristics for how long it decides to stream
before the first paint.

Chromium now has [an API explicitly for this use
case](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#stabilizing_page_state_to_make_cross-document_transitions_consistent)
that lets us model the semantics that we want. This is always important
but especially so with MPA View Transitions.

After this a simple document looks like this:

```html
<!DOCTYPE html>
<html>
  <head>
     <link rel="expect" href="#«R»" blocking="render"/>
  </head>
  <body>
    <p>hello world</p>
    <script src="bootstrap.js" id="«R»" async=""></script>
    ...
  </body>
</html>
```

The `rel="expect"` tag indicates that we want to wait to paint until we
have streamed far enough to be able to paint the id `"«R»"` which
indicates the shell.

Ideally this `id` would be assigned to the root most HTML element in the
body. However, this is tricky in our implementation because there can be
multiple and we can render them out of order.

So instead, we assign the id to the first bootstrap script if there is
one since these are always added to the end of the shell. If there isn't
a bootstrap script then we emit an empty `<template
id="«R»"></template>` instead as a marker.

Since we currently put as much as possible in the shell if it's loaded
by the time we render, this can have some negative effects for very
large documents. We should instead apply the heuristic where very large
Suspense boundaries get outlined outside the shell even if they're
immediately available. This means that even prerenders can end up with
script tags.

We only emit the `rel="expect"` if you're rendering a whole document.
I.e. if you rendered either a `<html>` or `<head>` tag. If you're
rendering a partial document, then we don't really know where the
streaming parts are anyway and can't provide such guarantees. This does
apply whether you're streaming or not because we still want to block
rendering until the end, but in practice any serialized state that needs
hydrate should still be embedded after the completion id.
2025-04-25 11:52:28 -04:00

663 lines
18 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
* @jest-environment node
*/
'use strict';
let Stream;
let React;
let ReactDOMFizzServer;
let Suspense;
let act;
describe('ReactDOMFizzServerNode', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');
Suspense = React.Suspense;
act = require('internal-test-utils').act;
});
function getTestWritable() {
const writable = new Stream.PassThrough();
writable.setEncoding('utf8');
const output = {result: '', error: undefined};
writable.on('data', chunk => {
output.result += chunk;
});
writable.on('error', error => {
output.error = error;
});
const completed = new Promise(resolve => {
writable.on('finish', () => {
resolve();
});
writable.on('error', () => {
resolve();
});
});
return {writable, completed, output};
}
const theError = new Error('This is an error');
function Throw() {
throw theError;
}
const theInfinitePromise = new Promise(() => {});
function InfiniteSuspend() {
throw theInfinitePromise;
}
it('should call renderToPipeableStream', async () => {
const {writable, output} = getTestWritable();
await act(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>hello world</div>,
);
pipe(writable);
});
expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
});
it('should emit DOCTYPE at the root of the document', async () => {
const {writable, output} = getTestWritable();
await act(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<body>hello world</body>
</html>,
);
pipe(writable);
});
// with Float, we emit empty heads if they are elided when rendering <html>
expect(output.result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><head><link rel="expect" href="#«R»" blocking="render"/></head><body>hello world<template id="«R»"></template></body></html>"`,
);
});
it('should emit bootstrap script src at the end', async () => {
const {writable, output} = getTestWritable();
await act(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>hello world</div>,
{
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
},
);
pipe(writable);
});
expect(output.result).toMatchInlineSnapshot(
`"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script id="«R»">INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
);
});
it('should start writing after pipe', async () => {
const {writable, output} = getTestWritable();
let pipe;
await act(() => {
pipe = ReactDOMFizzServer.renderToPipeableStream(
<div>hello world</div>,
).pipe;
});
// First we write our header.
output.result +=
'<!doctype html><html><head><title>test</title><head><body>';
// Then React starts writing.
pipe(writable);
expect(output.result).toMatchInlineSnapshot(
`"<!doctype html><html><head><title>test</title><head><body><div>hello world</div>"`,
);
});
it('emits all HTML as one unit if we wait until the end to start', async () => {
let hasLoaded = false;
let resolve;
const promise = new Promise(r => (resolve = r));
function Wait() {
if (!hasLoaded) {
throw promise;
}
return 'Done';
}
let isCompleteCalls = 0;
const {writable, output} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback="Loading">
<Wait />
</Suspense>
</div>,
{
onAllReady() {
isCompleteCalls++;
},
},
);
await jest.runAllTimers();
expect(output.result).toBe('');
expect(isCompleteCalls).toBe(0);
// Resolve the loading.
hasLoaded = true;
await resolve();
await jest.runAllTimers();
expect(output.result).toBe('');
expect(isCompleteCalls).toBe(1);
// First we write our header.
output.result +=
'<!doctype html><html><head><title>test</title><head><body>';
// Then React starts writing.
pipe(writable);
expect(output.result).toMatchInlineSnapshot(
`"<!doctype html><html><head><title>test</title><head><body><div><!--$-->Done<!-- --><!--/$--></div>"`,
);
});
it('should error the stream when an error is thrown at the root', async () => {
const reportedErrors = [];
const reportedShellErrors = [];
const {writable, output, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Throw />
</div>,
{
onError(x) {
reportedErrors.push(x);
},
onShellError(x) {
reportedShellErrors.push(x);
},
},
);
// The stream is errored once we start writing.
pipe(writable);
await completed;
expect(output.error).toBe(theError);
expect(output.result).toBe('');
// This type of error is reported to the error callback too.
expect(reportedErrors).toEqual([theError]);
expect(reportedShellErrors).toEqual([theError]);
});
it('should error the stream when an error is thrown inside a fallback', async () => {
const reportedErrors = [];
const reportedShellErrors = [];
const {writable, output, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<Throw />}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x.message);
},
onShellError(x) {
reportedShellErrors.push(x);
},
},
);
pipe(writable);
await completed;
expect(output.error).toBe(theError);
expect(output.result).toBe('');
expect(reportedErrors).toEqual([
theError.message,
'The destination stream errored while writing data.',
]);
expect(reportedShellErrors).toEqual([theError]);
});
it('should not error the stream when an error is thrown inside suspense boundary', async () => {
const reportedErrors = [];
const reportedShellErrors = [];
const {writable, output, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Throw />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
},
onShellError(x) {
reportedShellErrors.push(x);
},
},
);
pipe(writable);
await completed;
expect(output.error).toBe(undefined);
expect(output.result).toContain('Loading');
// While no error is reported to the stream, the error is reported to the callback.
expect(reportedErrors).toEqual([theError]);
expect(reportedShellErrors).toEqual([]);
});
it('should not attempt to render the fallback if the main content completes first', async () => {
const {writable, output, completed} = getTestWritable();
let renderedFallback = false;
function Fallback() {
renderedFallback = true;
return 'Loading...';
}
function Content() {
return 'Hi';
}
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<Suspense fallback={<Fallback />}>
<Content />
</Suspense>,
);
pipe(writable);
await completed;
expect(output.result).toContain('Hi');
expect(output.result).not.toContain('Loading');
expect(renderedFallback).toBe(false);
});
it('should be able to complete by aborting even if the promise never resolves', async () => {
let isCompleteCalls = 0;
const errors = [];
const {writable, output, completed} = getTestWritable();
let abort;
await act(() => {
const pipeable = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
onAllReady() {
isCompleteCalls++;
},
},
);
pipeable.pipe(writable);
abort = pipeable.abort;
});
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(0);
abort(new Error('uh oh'));
await completed;
expect(errors).toEqual(['uh oh']);
expect(output.error).toBe(undefined);
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(1);
});
it('should fail the shell if you abort before work has begun', async () => {
let isCompleteCalls = 0;
const errors = [];
const shellErrors = [];
const {writable, output, completed} = getTestWritable();
const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
onShellError(x) {
shellErrors.push(x.message);
},
onAllReady() {
isCompleteCalls++;
},
},
);
pipe(writable);
// Currently we delay work so if we abort, we abort the remaining CPU
// work as well.
// Abort before running the timers that perform the work
const theReason = new Error('uh oh');
abort(theReason);
jest.runAllTimers();
await completed;
expect(errors).toEqual(['uh oh']);
expect(shellErrors).toEqual(['uh oh']);
expect(output.error).toBe(theReason);
expect(output.result).toBe('');
expect(isCompleteCalls).toBe(0);
});
it('should be able to complete by abort when the fallback is also suspended', async () => {
let isCompleteCalls = 0;
const errors = [];
const {writable, output, completed} = getTestWritable();
let abort;
await act(() => {
const pipeable = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback="Loading">
<Suspense fallback={<InfiniteSuspend />}>
<InfiniteSuspend />
</Suspense>
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
onAllReady() {
isCompleteCalls++;
},
},
);
pipeable.pipe(writable);
abort = pipeable.abort;
});
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(0);
abort();
await completed;
expect(errors).toEqual([
// There are two boundaries that abort
'The render was aborted by the server without a reason.',
'The render was aborted by the server without a reason.',
]);
expect(output.error).toBe(undefined);
expect(output.result).toContain('Loading');
expect(isCompleteCalls).toBe(1);
});
it('should be able to get context value when promise resolves', async () => {
class DelayClient {
get() {
if (this.resolved) return this.resolved;
if (this.pending) return this.pending;
return (this.pending = new Promise(resolve => {
setTimeout(() => {
delete this.pending;
this.resolved = 'OK';
resolve();
}, 500);
}));
}
}
const DelayContext = React.createContext(undefined);
const Component = () => {
const client = React.useContext(DelayContext);
if (!client) {
return 'context not found.';
}
const result = client.get();
if (typeof result === 'string') {
return result;
}
throw result;
};
const client = new DelayClient();
const {writable, output, completed} = getTestWritable();
await act(() => {
ReactDOMFizzServer.renderToPipeableStream(
<DelayContext.Provider value={client}>
<div>
<Suspense fallback="loading">
<Component />
</Suspense>
</div>
</DelayContext.Provider>,
).pipe(writable);
});
expect(output.error).toBe(undefined);
expect(output.result).toContain('loading');
await completed;
expect(output.error).toBe(undefined);
expect(output.result).not.toContain('context never found');
expect(output.result).toContain('OK');
});
it('should be able to get context value when calls renderToPipeableStream twice at the same time', async () => {
class DelayClient {
get() {
if (this.resolved) return this.resolved;
if (this.pending) return this.pending;
return (this.pending = new Promise(resolve => {
setTimeout(() => {
delete this.pending;
this.resolved = 'OK';
resolve();
}, 500);
}));
}
}
const DelayContext = React.createContext(undefined);
const Component = () => {
const client = React.useContext(DelayContext);
if (!client) {
return 'context never found';
}
const result = client.get();
if (typeof result === 'string') {
return result;
}
throw result;
};
const client0 = new DelayClient();
const {
writable: writable0,
output: output0,
completed: completed0,
} = getTestWritable();
const client1 = new DelayClient();
const {
writable: writable1,
output: output1,
completed: completed1,
} = getTestWritable();
await act(() => {
ReactDOMFizzServer.renderToPipeableStream(
<DelayContext.Provider value={client0}>
<div>
<Suspense fallback="loading">
<Component />
</Suspense>
</div>
</DelayContext.Provider>,
).pipe(writable0);
ReactDOMFizzServer.renderToPipeableStream(
<DelayContext.Provider value={client1}>
<div>
<Suspense fallback="loading">
<Component />
</Suspense>
</div>
</DelayContext.Provider>,
).pipe(writable1);
});
expect(output0.error).toBe(undefined);
expect(output0.result).toContain('loading');
expect(output1.error).toBe(undefined);
expect(output1.result).toContain('loading');
await Promise.all([completed0, completed1]);
expect(output0.error).toBe(undefined);
expect(output0.result).not.toContain('context never found');
expect(output0.result).toContain('OK');
expect(output1.error).toBe(undefined);
expect(output1.result).not.toContain('context never found');
expect(output1.result).toContain('OK');
});
it('should be able to pop context after suspending', async () => {
class DelayClient {
get() {
if (this.resolved) return this.resolved;
if (this.pending) return this.pending;
return (this.pending = new Promise(resolve => {
setTimeout(() => {
delete this.pending;
this.resolved = 'OK';
resolve();
}, 500);
}));
}
}
const DelayContext = React.createContext(undefined);
const Component = () => {
const client = React.useContext(DelayContext);
if (!client) {
return 'context not found.';
}
const result = client.get();
if (typeof result === 'string') {
return result;
}
throw result;
};
const client = new DelayClient();
const {writable, output, completed} = getTestWritable();
await act(() => {
ReactDOMFizzServer.renderToPipeableStream(
<div>
<DelayContext.Provider value={client}>
<Suspense fallback="loading">
<Component />
</Suspense>
</DelayContext.Provider>
<DelayContext.Provider value={client}>
<Suspense fallback="loading">
<Component />
</Suspense>
</DelayContext.Provider>
</div>,
).pipe(writable);
});
expect(output.error).toBe(undefined);
expect(output.result).toContain('loading');
await completed;
expect(output.error).toBe(undefined);
expect(output.result).not.toContain('context never found');
expect(output.result).toContain('OK');
});
it('should not continue rendering after the writable ends unexpectedly', async () => {
let hasLoaded = false;
let resolve;
let isComplete = false;
let rendered = false;
const promise = new Promise(r => (resolve = r));
function Wait({prop}) {
if (!hasLoaded) {
throw promise;
}
rendered = true;
return 'Done';
}
const errors = [];
const {writable, completed} = getTestWritable();
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Wait />
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
onAllReady() {
isComplete = true;
},
},
);
pipe(writable);
expect(rendered).toBe(false);
expect(isComplete).toBe(false);
writable.end();
await jest.runAllTimers();
hasLoaded = true;
resolve();
await completed;
expect(errors).toEqual([
'The destination stream errored while writing data.',
]);
expect(rendered).toBe(false);
expect(isComplete).toBe(true);
});
it('should encode multibyte characters correctly without nulls (#24985)', async () => {
const {writable, output} = getTestWritable();
await act(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<div>{Array(700).fill('ののの')}</div>,
);
pipe(writable);
});
expect(output.result.indexOf('\u0000')).toBe(-1);
expect(output.result).toEqual(
'<div>' + Array(700).fill('ののの').join('<!-- -->') + '</div>',
);
});
});