mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
[Fizz] Error and deopt from rel=expect for large documents without boundaries (#33454)
We want to make sure that we can block the reveal of a well designed complete shell reliably. In the Suspense model, client transitions don't have any way to implicitly resolve. This means you need to use Suspense or SuspenseList to explicitly split the document. Relying on implicit would mean you can't add a Suspense boundary later where needed. So we highly encourage the use of them around large content. However, if you have constructed a too large shell (e.g. by not adding any Suspense boundaries at all) then that might take too long to render on the client. We shouldn't punish users (or overzealous metrics tracking tools like search engines) in that scenario. This opts out of render blocking if the shell ends up too large to be intentional and too slow to load. Instead it deopts to showing the content split up in arbitrary ways (browser default). It only does this for SSR, and not client navs so it's not reliable. In fact, we issue an error to `onError`. This error is recoverable in that the document is still produced. It's up to your framework to decide if this errors the build or just surface it for action later. What should be the limit though? There's a trade off here. If this limit is too low then you can't fit a reasonably well built UI within it without getting errors. If it's too high then things that accidentally fall below it might take too long to load. I came up with 512kB of uncompressed shell HTML. See the comment in code for the rationale for this number. TL;DR: Data and theory indicates that having this much content inside `rel="expect"` doesn't meaningfully change metrics. Research of above-the-fold content on various websites indicate that this can comfortable fit all of them which should be enough for any intentional initial paint.
This commit is contained in:
committed by
GitHub
parent
22b929156c
commit
d177272802
@@ -5465,7 +5465,7 @@ export function writePreambleStart(
|
||||
destination: Destination,
|
||||
resumableState: ResumableState,
|
||||
renderState: RenderState,
|
||||
skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
|
||||
skipBlockingShell: boolean,
|
||||
): void {
|
||||
// This function must be called exactly once on every request
|
||||
if (enableFizzExternalRuntime && renderState.externalRuntimeScript) {
|
||||
@@ -5549,7 +5549,7 @@ export function writePreambleStart(
|
||||
renderState.bulkPreloads.forEach(flushResource, destination);
|
||||
renderState.bulkPreloads.clear();
|
||||
|
||||
if ((htmlChunks || headChunks) && !skipExpect) {
|
||||
if ((htmlChunks || headChunks) && !skipBlockingShell) {
|
||||
// If we have any html or head chunks we know that we're rendering a full document.
|
||||
// A full document should block display until the full shell has downloaded.
|
||||
// Therefore we insert a render blocking instruction referring to the last body
|
||||
@@ -5557,6 +5557,10 @@ export function writePreambleStart(
|
||||
// have already been emitted so we don't do anything to delay them but early so that
|
||||
// the browser doesn't risk painting too early.
|
||||
writeBlockingRenderInstruction(destination, resumableState, renderState);
|
||||
} else {
|
||||
// We don't need to add the shell id so mark it as if sent.
|
||||
// Currently it might still be sent if it was already added to a bootstrap script.
|
||||
resumableState.instructions |= SentCompletedShellId;
|
||||
}
|
||||
|
||||
// Write embedding hoistableChunks
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
|
||||
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
|
||||
writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl,
|
||||
writePreambleStart as writePreambleStartImpl,
|
||||
} from './ReactFizzConfigDOM';
|
||||
|
||||
import type {
|
||||
@@ -170,7 +171,6 @@ export {
|
||||
createResumableState,
|
||||
createPreambleState,
|
||||
createHoistableState,
|
||||
writePreambleStart,
|
||||
writePreambleEnd,
|
||||
writeHoistables,
|
||||
writePostamble,
|
||||
@@ -311,5 +311,19 @@ export function writeEndClientRenderedSuspenseBoundary(
|
||||
return writeEndClientRenderedSuspenseBoundaryImpl(destination, renderState);
|
||||
}
|
||||
|
||||
export function writePreambleStart(
|
||||
destination: Destination,
|
||||
resumableState: ResumableState,
|
||||
renderState: RenderState,
|
||||
skipBlockingShell: boolean,
|
||||
): void {
|
||||
return writePreambleStartImpl(
|
||||
destination,
|
||||
resumableState,
|
||||
renderState,
|
||||
true, // skipBlockingShell
|
||||
);
|
||||
}
|
||||
|
||||
export type TransitionStatus = FormStatus;
|
||||
export const NotPendingTransition: TransitionStatus = NotPending;
|
||||
|
||||
@@ -18,12 +18,14 @@ global.AsyncLocalStorage = require('async_hooks').AsyncLocalStorage;
|
||||
let React;
|
||||
let ReactDOM;
|
||||
let ReactDOMFizzServer;
|
||||
let Suspense;
|
||||
|
||||
describe('ReactDOMFizzServerEdge', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.useRealTimers();
|
||||
React = require('react');
|
||||
Suspense = React.Suspense;
|
||||
ReactDOM = require('react-dom');
|
||||
ReactDOMFizzServer = require('react-dom/server.edge');
|
||||
});
|
||||
@@ -81,4 +83,102 @@ describe('ReactDOMFizzServerEdge', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('recoverably errors and does not add rel="expect" for large shells', async () => {
|
||||
function Paragraph() {
|
||||
return (
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris
|
||||
porttitor tortor ac lectus faucibus, eget eleifend elit hendrerit.
|
||||
Integer porttitor nisi in leo congue rutrum. Morbi sed ante posuere,
|
||||
aliquam lorem ac, imperdiet orci. Duis malesuada gravida pharetra.
|
||||
Cras facilisis arcu diam, id dictum lorem imperdiet a. Suspendisse
|
||||
aliquet tempus tortor et ultricies. Aliquam libero velit, posuere
|
||||
tempus ante sed, pellentesque tincidunt lorem. Nullam iaculis, eros a
|
||||
varius aliquet, tortor felis tempor metus, nec cursus felis eros
|
||||
aliquam nulla. Vivamus ut orci sed mauris congue lacinia. Cras eget
|
||||
blandit neque. Pellentesque a massa in turpis ullamcorper volutpat vel
|
||||
at massa. Sed ante est, auctor non diam non, vulputate ultrices metus.
|
||||
Maecenas dictum fermentum quam id aliquam. Donec porta risus vitae
|
||||
pretium posuere. Fusce facilisis eros in lacus tincidunt congue.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function App({suspense}) {
|
||||
const paragraphs = [];
|
||||
for (let i = 0; i < 600; i++) {
|
||||
paragraphs.push(<Paragraph key={i} />);
|
||||
}
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{suspense ? (
|
||||
// This is ok
|
||||
<Suspense fallback="Loading">{paragraphs}</Suspense>
|
||||
) : (
|
||||
// This is not
|
||||
paragraphs
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
const errors = [];
|
||||
const stream = await ReactDOMFizzServer.renderToReadableStream(
|
||||
<App suspense={false} />,
|
||||
{
|
||||
onError(error) {
|
||||
errors.push(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await readResult(stream);
|
||||
expect(result).not.toContain('rel="expect"');
|
||||
if (gate(flags => flags.enableFizzBlockingRender)) {
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0].message).toContain(
|
||||
'This rendered a large document (>512) without any Suspense boundaries around most of it.',
|
||||
);
|
||||
} else {
|
||||
expect(errors.length).toBe(0);
|
||||
}
|
||||
|
||||
// If we wrap in a Suspense boundary though, then it should be ok.
|
||||
const errors2 = [];
|
||||
const stream2 = await ReactDOMFizzServer.renderToReadableStream(
|
||||
<App suspense={true} />,
|
||||
{
|
||||
onError(error) {
|
||||
errors2.push(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
const result2 = await readResult(stream2);
|
||||
if (gate(flags => flags.enableFizzBlockingRender)) {
|
||||
expect(result2).toContain('rel="expect"');
|
||||
} else {
|
||||
expect(result2).not.toContain('rel="expect"');
|
||||
}
|
||||
expect(errors2.length).toBe(0);
|
||||
|
||||
// Or if we increase the progressiveChunkSize.
|
||||
const errors3 = [];
|
||||
const stream3 = await ReactDOMFizzServer.renderToReadableStream(
|
||||
<App suspense={false} />,
|
||||
{
|
||||
progressiveChunkSize: 100000,
|
||||
onError(error) {
|
||||
errors3.push(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
const result3 = await readResult(stream3);
|
||||
if (gate(flags => flags.enableFizzBlockingRender)) {
|
||||
expect(result3).toContain('rel="expect"');
|
||||
} else {
|
||||
expect(result3).not.toContain('rel="expect"');
|
||||
}
|
||||
expect(errors3.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,13 +35,7 @@ describe('ReactDOMFloat', () => {
|
||||
|
||||
expect(result).toEqual(
|
||||
'<html><head><meta charSet="utf-8"/>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#_R_" blocking="render"/>'
|
||||
: '') +
|
||||
'<title>title</title><script src="foo"></script></head>' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="_R_"></template>'
|
||||
: '') +
|
||||
'</html>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('rendering React components at document', () => {
|
||||
|
||||
const markup = ReactDOMServer.renderToString(<Root hello="world" />);
|
||||
expect(markup).not.toContain('DOCTYPE');
|
||||
expect(markup).not.toContain('rel="expect"');
|
||||
const testDocument = getTestDocument(markup);
|
||||
const body = testDocument.body;
|
||||
|
||||
@@ -77,22 +78,12 @@ describe('rendering React components at document', () => {
|
||||
await act(() => {
|
||||
root = ReactDOMClient.hydrateRoot(testDocument, <Root hello="world" />);
|
||||
});
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="_R_"></template>'
|
||||
: ''),
|
||||
);
|
||||
expect(testDocument.body.innerHTML).toBe('Hello world');
|
||||
|
||||
await act(() => {
|
||||
root.render(<Root hello="moon" />);
|
||||
});
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello moon' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="_R_"></template>'
|
||||
: ''),
|
||||
);
|
||||
expect(testDocument.body.innerHTML).toBe('Hello moon');
|
||||
|
||||
expect(body === testDocument.body).toBe(true);
|
||||
});
|
||||
@@ -117,12 +108,7 @@ describe('rendering React components at document', () => {
|
||||
await act(() => {
|
||||
root = ReactDOMClient.hydrateRoot(testDocument, <Root />);
|
||||
});
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="_R_"></template>'
|
||||
: ''),
|
||||
);
|
||||
expect(testDocument.body.innerHTML).toBe('Hello world');
|
||||
|
||||
const originalDocEl = testDocument.documentElement;
|
||||
const originalHead = testDocument.head;
|
||||
@@ -133,16 +119,8 @@ describe('rendering React components at document', () => {
|
||||
expect(testDocument.firstChild).toBe(originalDocEl);
|
||||
expect(testDocument.head).toBe(originalHead);
|
||||
expect(testDocument.body).toBe(originalBody);
|
||||
expect(originalBody.innerHTML).toBe(
|
||||
gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="_R_"></template>'
|
||||
: '',
|
||||
);
|
||||
expect(originalHead.innerHTML).toBe(
|
||||
gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<link rel="expect" href="#_R_" blocking="render">'
|
||||
: '',
|
||||
);
|
||||
expect(originalBody.innerHTML).toBe('');
|
||||
expect(originalHead.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should not be able to switch root constructors', async () => {
|
||||
@@ -180,22 +158,13 @@ describe('rendering React components at document', () => {
|
||||
root = ReactDOMClient.hydrateRoot(testDocument, <Component />);
|
||||
});
|
||||
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="_R_"></template>'
|
||||
: ''),
|
||||
);
|
||||
expect(testDocument.body.innerHTML).toBe('Hello world');
|
||||
|
||||
await act(() => {
|
||||
root.render(<Component2 />);
|
||||
});
|
||||
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="_R_"></template>'
|
||||
: '') + 'Goodbye world',
|
||||
);
|
||||
expect(testDocument.body.innerHTML).toBe('Goodbye world');
|
||||
});
|
||||
|
||||
it('should be able to mount into document', async () => {
|
||||
@@ -224,12 +193,7 @@ describe('rendering React components at document', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
'Hello world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="_R_"></template>'
|
||||
: ''),
|
||||
);
|
||||
expect(testDocument.body.innerHTML).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('cannot render over an existing text child at the root', async () => {
|
||||
@@ -362,12 +326,7 @@ describe('rendering React components at document', () => {
|
||||
: [],
|
||||
);
|
||||
expect(testDocument.body.innerHTML).toBe(
|
||||
favorSafetyOverHydrationPerf
|
||||
? 'Hello world'
|
||||
: 'Goodbye world' +
|
||||
(gate(flags => flags.enableFizzBlockingRender)
|
||||
? '<template id="_R_"></template>'
|
||||
: ''),
|
||||
favorSafetyOverHydrationPerf ? 'Hello world' : 'Goodbye world',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -222,13 +222,13 @@ export function writePreambleStart(
|
||||
destination: Destination,
|
||||
resumableState: ResumableState,
|
||||
renderState: RenderState,
|
||||
skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
|
||||
skipBlockingShell: boolean,
|
||||
): void {
|
||||
return writePreambleStartImpl(
|
||||
destination,
|
||||
resumableState,
|
||||
renderState,
|
||||
true, // skipExpect
|
||||
true, // skipBlockingShell
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
65
packages/react-server/src/ReactFizzServer.js
vendored
65
packages/react-server/src/ReactFizzServer.js
vendored
@@ -182,6 +182,7 @@ import {
|
||||
disableDefaultPropsExceptForClasses,
|
||||
enableAsyncIterableChildren,
|
||||
enableViewTransition,
|
||||
enableFizzBlockingRender,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
|
||||
import assign from 'shared/assign';
|
||||
@@ -418,6 +419,41 @@ type Preamble = PreambleState;
|
||||
// 500 * 1024 / 8 * .8 * 0.5 / 2
|
||||
const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800;
|
||||
|
||||
function getBlockingRenderMaxSize(request: Request): number {
|
||||
// We want to make sure that we can block the reveal of a well designed complete
|
||||
// shell but if you have constructed a too large shell (e.g. by not adding any
|
||||
// Suspense boundaries) then that might take too long to render. We shouldn't
|
||||
// punish users (or overzealous metrics tracking) in that scenario.
|
||||
// There's a trade off here. If this limit is too low then you can't fit a
|
||||
// reasonably well built UI within it without getting errors. If it's too high
|
||||
// then things that accidentally fall below it might take too long to load.
|
||||
// Web Vitals target 1.8 seconds for first paint and our goal to have the limit
|
||||
// be fast enough to hit that. For this argument we assume that most external
|
||||
// resources are already cached because it's a return visit, or inline styles.
|
||||
// If it's not, then it's highly unlikely that any render blocking instructions
|
||||
// we add has any impact what so ever on the paint.
|
||||
// Assuming a first byte of about 600ms which is kind of bad but common with a
|
||||
// decent static host. If it's longer e.g. due to dynamic rendering, then you
|
||||
// are going to bound by dynamic production of the content and you're better off
|
||||
// with Suspense boundaries anyway. This number doesn't matter much. Then you
|
||||
// have about 1.2 seconds left for bandwidth. On 3G that gives you about 112.5kb
|
||||
// worth of data. That's worth about 10x in terms of uncompressed bytes. Then we
|
||||
// half that just to account for longer latency, slower bandwidth and CPU processing.
|
||||
// Now we're down to about 500kb. In fact, looking at metrics we've collected with
|
||||
// rel="expect" examples and other documents, the impact on documents smaller than
|
||||
// that is within the noise. That's because there's enough happening within that
|
||||
// start up to not make HTML streaming not significantly better.
|
||||
// Content above the fold tends to be about 100-200kb tops. Therefore 500kb should
|
||||
// be enough head room for a good loading state. After that you should use
|
||||
// Suspense or SuspenseList to improve it.
|
||||
// Since this is highly related to the reason you would adjust the
|
||||
// progressiveChunkSize option, and always has to be higher, we define this limit
|
||||
// in terms of it. So if you want to increase the limit because you have high
|
||||
// bandwidth users, then you can adjust it up. If you are concerned about even
|
||||
// slower bandwidth then you can adjust it down.
|
||||
return request.progressiveChunkSize * 40; // 512kb by default.
|
||||
}
|
||||
|
||||
function isEligibleForOutlining(
|
||||
request: Request,
|
||||
boundary: SuspenseBoundary,
|
||||
@@ -5476,9 +5512,15 @@ function flushPreamble(
|
||||
destination: Destination,
|
||||
rootSegment: Segment,
|
||||
preambleSegments: Array<Array<Segment>>,
|
||||
skipBlockingShell: boolean,
|
||||
) {
|
||||
// The preamble is ready.
|
||||
writePreambleStart(destination, request.resumableState, request.renderState);
|
||||
writePreambleStart(
|
||||
destination,
|
||||
request.resumableState,
|
||||
request.renderState,
|
||||
skipBlockingShell,
|
||||
);
|
||||
for (let i = 0; i < preambleSegments.length; i++) {
|
||||
const segments = preambleSegments[i];
|
||||
for (let j = 0; j < segments.length; j++) {
|
||||
@@ -5888,11 +5930,32 @@ function flushCompletedQueues(
|
||||
|
||||
flushedByteSize = request.byteSize; // Start counting bytes
|
||||
// TODO: Count the size of the preamble chunks too.
|
||||
let skipBlockingShell = false;
|
||||
if (enableFizzBlockingRender) {
|
||||
const blockingRenderMaxSize = getBlockingRenderMaxSize(request);
|
||||
if (flushedByteSize > blockingRenderMaxSize) {
|
||||
skipBlockingShell = true;
|
||||
const maxSizeKb = Math.round(blockingRenderMaxSize / 1000);
|
||||
const error = new Error(
|
||||
'This rendered a large document (>' +
|
||||
maxSizeKb +
|
||||
') without any Suspense ' +
|
||||
'boundaries around most of it. That can delay initial paint longer than ' +
|
||||
'necessary. To improve load performance, add a <Suspense> or <SuspenseList> ' +
|
||||
'around the content you expect to be below the header or below the fold. ' +
|
||||
'In the meantime, the content will deopt to paint arbitrary incomplete ' +
|
||||
'pieces of HTML.',
|
||||
);
|
||||
const errorInfo: ThrownInfo = {};
|
||||
logRecoverableError(request, error, errorInfo, null);
|
||||
}
|
||||
}
|
||||
flushPreamble(
|
||||
request,
|
||||
destination,
|
||||
completedRootSegment,
|
||||
completedPreambleSegments,
|
||||
skipBlockingShell,
|
||||
);
|
||||
flushSegment(request, destination, completedRootSegment, null);
|
||||
request.completedRootSegment = null;
|
||||
|
||||
@@ -545,5 +545,6 @@
|
||||
"557": "Expected to have a hydrated activity instance. This error is likely caused by a bug in React. Please file an issue.",
|
||||
"558": "Client rendering an Activity suspended it again. This is a bug in React.",
|
||||
"559": "Expected to find a host node. This is a bug in React.",
|
||||
"560": "Cannot use a startGestureTransition() with a comment node root."
|
||||
"560": "Cannot use a startGestureTransition() with a comment node root.",
|
||||
"561": "This rendered a large document (>%s) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a <Suspense> or <SuspenseList> around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user