react/packages/react-markup/src/ReactFizzConfigMarkup.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

234 lines
5.7 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.
*
* @flow
*/
import type {ReactNodeList} from 'shared/ReactTypes';
import type {
RenderState,
ResumableState,
PreambleState,
HoistableState,
FormatContext,
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import {
pushStartInstance as pushStartInstanceImpl,
writePreambleStart as writePreambleStartImpl,
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {
Destination,
Chunk,
PrecomputedChunk,
} from 'react-server/src/ReactServerStreamConfig';
import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
import {NotPending} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
import hasOwnProperty from 'shared/hasOwnProperty';
// Allow embedding inside another Fizz render.
export const isPrimaryRenderer = false;
// Disable Client Hooks
export const supportsClientAPIs = false;
import {stringToChunk} from 'react-server/src/ReactServerStreamConfig';
export type {
RenderState,
ResumableState,
HoistableState,
PreambleState,
FormatContext,
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
export {
getChildFormatContext,
makeId,
pushEndInstance,
pushFormStateMarkerIsMatching,
pushFormStateMarkerIsNotMatching,
writeStartSegment,
writeEndSegment,
writeCompletedSegmentInstruction,
writeCompletedBoundaryInstruction,
writeClientRenderBoundaryInstruction,
writeStartPendingSuspenseBoundary,
writeEndPendingSuspenseBoundary,
writeHoistablesForBoundary,
writePlaceholder,
createRootFormatContext,
createRenderState,
createResumableState,
createPreambleState,
createHoistableState,
writePreambleEnd,
writeHoistables,
writePostamble,
hoistHoistables,
resetResumableState,
completeResumableState,
emitEarlyPreloads,
doctypeChunk,
canHavePreamble,
hoistPreambleState,
isPreambleReady,
isPreambleContext,
} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser';
export function pushStartInstance(
target: Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
resumableState: ResumableState,
renderState: RenderState,
preambleState: null | PreambleState,
hoistableState: null | HoistableState,
formatContext: FormatContext,
textEmbedded: boolean,
isFallback: boolean,
): ReactNodeList {
for (const propKey in props) {
if (hasOwnProperty.call(props, propKey)) {
const propValue = props[propKey];
if (propKey === 'ref' && propValue != null) {
throw new Error(
'Cannot pass ref in renderToHTML because they will never be hydrated.',
);
}
if (typeof propValue === 'function') {
throw new Error(
'Cannot pass event handlers (' +
propKey +
') in renderToHTML because ' +
'the HTML will never be hydrated so they can never get called.',
);
}
}
}
return pushStartInstanceImpl(
target,
type,
props,
resumableState,
renderState,
preambleState,
hoistableState,
formatContext,
textEmbedded,
isFallback,
);
}
export function pushTextInstance(
target: Array<Chunk | PrecomputedChunk>,
text: string,
renderState: RenderState,
textEmbedded: boolean,
): boolean {
// Markup doesn't need any termination.
target.push(stringToChunk(escapeTextForBrowser(text)));
return false;
}
export function pushSegmentFinale(
target: Array<Chunk | PrecomputedChunk>,
renderState: RenderState,
lastPushedText: boolean,
textEmbedded: boolean,
): void {
// Markup doesn't need any termination.
return;
}
export function pushStartActivityBoundary(
target: Array<Chunk | PrecomputedChunk>,
renderState: RenderState,
): void {
// Markup doesn't have any instructions.
return;
}
export function pushEndActivityBoundary(
target: Array<Chunk | PrecomputedChunk>,
renderState: RenderState,
): void {
// Markup doesn't have any instructions.
return;
}
export function writeStartCompletedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
): boolean {
// Markup doesn't have any instructions.
return true;
}
export function writeStartClientRenderedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
// flushing these error arguments are not currently supported in this legacy streaming format.
errorDigest: ?string,
errorMessage: ?string,
errorStack: ?string,
errorComponentStack: ?string,
): boolean {
// Markup doesn't have any instructions.
return true;
}
export function writeEndCompletedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
): boolean {
// Markup doesn't have any instructions.
return true;
}
export function writeEndClientRenderedSuspenseBoundary(
destination: Destination,
renderState: RenderState,
): boolean {
// Markup doesn't have any instructions.
return true;
}
export function writePreambleStart(
destination: Destination,
resumableState: ResumableState,
renderState: RenderState,
willFlushAllSegments: boolean,
skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup
): void {
return writePreambleStartImpl(
destination,
resumableState,
renderState,
willFlushAllSegments,
true, // skipExpect
);
}
export function writeCompletedRoot(
destination: Destination,
resumableState: ResumableState,
renderState: RenderState,
): boolean {
// Markup doesn't have any bootstrap scripts nor shell completions.
return true;
}
export type TransitionStatus = FormStatus;
export const NotPendingTransition: TransitionStatus = NotPending;