Skip to content

[Fizz] Emit link rel="expect" to block render before the shell has fully loaded #33016

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 6 commits into from
Apr 25, 2025

Conversation

sebmarkbage
Copy link
Collaborator

@sebmarkbage sebmarkbage commented Apr 25, 2025

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 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:

<!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.

@sebmarkbage sebmarkbage requested a review from gnoff April 25, 2025 03:48
@github-actions github-actions bot added the React Core Team Opened by a member of the React Core Team label Apr 25, 2025
@react-sizebot
Copy link

react-sizebot commented Apr 25, 2025

Comparing: 693803a...d5e45b9

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB = 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 527.72 kB 527.72 kB = 93.07 kB 93.07 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB = 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 633.34 kB 633.34 kB = 111.25 kB 111.25 kB
facebook-www/ReactDOM-prod.classic.js = 671.13 kB 671.13 kB = 117.70 kB 117.70 kB
facebook-www/ReactDOM-prod.modern.js = 661.41 kB 661.41 kB = 116.14 kB 116.14 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable-semver/react-dom/cjs/react-dom-server.bun.production.js +1.33% 215.34 kB 218.19 kB +1.12% 39.61 kB 40.05 kB
oss-stable/react-dom/cjs/react-dom-server.bun.production.js +1.33% 215.41 kB 218.27 kB +1.12% 39.64 kB 40.08 kB
oss-stable-semver/react-dom/cjs/react-dom-server-legacy.browser.production.js +1.33% 211.15 kB 213.95 kB +1.13% 38.43 kB 38.87 kB
oss-stable/react-dom/cjs/react-dom-server-legacy.browser.production.js +1.33% 211.18 kB 213.98 kB +1.13% 38.46 kB 38.89 kB
oss-stable-semver/react-dom/cjs/react-dom-server-legacy.node.production.js +1.30% 215.67 kB 218.47 kB +1.09% 40.19 kB 40.63 kB
oss-stable/react-dom/cjs/react-dom-server-legacy.node.production.js +1.30% 215.69 kB 218.49 kB +1.08% 40.22 kB 40.65 kB
oss-stable-semver/react-dom/cjs/react-dom-server.browser.production.js +1.20% 230.19 kB 232.96 kB +1.26% 41.17 kB 41.69 kB
oss-stable/react-dom/cjs/react-dom-server.browser.production.js +1.20% 230.26 kB 233.03 kB +1.26% 41.20 kB 41.71 kB
oss-stable-semver/react-dom/cjs/react-dom-server.node.production.js +1.20% 231.93 kB 234.72 kB +1.14% 42.15 kB 42.63 kB
oss-stable/react-dom/cjs/react-dom-server.node.production.js +1.20% 232.01 kB 234.80 kB +1.14% 42.17 kB 42.66 kB
facebook-www/ReactDOMServerStreaming-prod.modern.js +1.18% 223.80 kB 226.44 kB +1.02% 41.11 kB 41.53 kB
oss-stable-semver/react-dom/cjs/react-dom-server.edge.production.js +1.18% 235.39 kB 238.16 kB +1.16% 43.12 kB 43.62 kB
oss-stable/react-dom/cjs/react-dom-server.edge.production.js +1.18% 235.47 kB 238.24 kB +1.16% 43.15 kB 43.65 kB
facebook-www/ReactDOMServer-prod.modern.js +1.17% 219.61 kB 222.18 kB +1.05% 39.54 kB 39.96 kB
facebook-www/ReactDOMServer-prod.classic.js +1.16% 222.29 kB 224.87 kB +1.02% 39.88 kB 40.28 kB
oss-experimental/react-dom/cjs/react-dom-server-legacy.browser.production.js +1.12% 230.37 kB 232.95 kB +0.97% 41.11 kB 41.51 kB
oss-experimental/react-dom/cjs/react-dom-server-legacy.node.production.js +1.09% 235.45 kB 238.03 kB +0.93% 43.02 kB 43.42 kB
oss-experimental/react-dom/cjs/react-dom-server.bun.production.js +1.09% 235.99 kB 238.57 kB +0.95% 42.42 kB 42.82 kB
oss-experimental/react-dom/cjs/react-dom-server.node.production.js +1.00% 259.74 kB 262.34 kB +0.97% 45.76 kB 46.20 kB
oss-experimental/react-dom/cjs/react-dom-server.browser.production.js +0.98% 257.94 kB 260.47 kB +1.05% 44.60 kB 45.07 kB
oss-experimental/react-dom/cjs/react-dom-server.edge.production.js +0.96% 263.89 kB 266.42 kB +1.03% 46.73 kB 47.21 kB
oss-stable-semver/react-dom/cjs/react-dom-server.browser.development.js +0.89% 378.18 kB 381.54 kB +0.80% 67.97 kB 68.52 kB
oss-stable/react-dom/cjs/react-dom-server.browser.development.js +0.89% 378.25 kB 381.61 kB +0.80% 68.02 kB 68.57 kB
oss-stable-semver/react-dom/cjs/react-dom-server.edge.development.js +0.89% 378.96 kB 382.32 kB +0.80% 68.12 kB 68.66 kB
oss-stable/react-dom/cjs/react-dom-server.edge.development.js +0.89% 379.03 kB 382.39 kB +0.80% 68.17 kB 68.72 kB
oss-stable-semver/react-dom/cjs/react-dom-server.bun.development.js +0.88% 322.43 kB 325.25 kB +0.82% 62.65 kB 63.17 kB
oss-stable/react-dom/cjs/react-dom-server.bun.development.js +0.88% 322.50 kB 325.33 kB +0.82% 62.68 kB 63.20 kB
oss-stable-semver/react-dom/cjs/react-dom-server.node.development.js +0.87% 374.73 kB 377.99 kB +0.78% 67.35 kB 67.87 kB
oss-stable/react-dom/cjs/react-dom-server.node.development.js +0.87% 374.81 kB 378.07 kB +0.77% 67.41 kB 67.93 kB
oss-stable-semver/react-dom/cjs/react-dom-server-legacy.node.development.js +0.85% 362.34 kB 365.41 kB +0.77% 65.74 kB 66.24 kB
oss-stable-semver/react-dom/cjs/react-dom-server-legacy.browser.development.js +0.85% 362.34 kB 365.41 kB +0.77% 65.74 kB 66.24 kB
oss-stable/react-dom/cjs/react-dom-server-legacy.node.development.js +0.85% 362.37 kB 365.43 kB +0.76% 65.77 kB 66.27 kB
oss-stable/react-dom/cjs/react-dom-server-legacy.browser.development.js +0.85% 362.37 kB 365.44 kB +0.76% 65.77 kB 66.27 kB
oss-experimental/react-dom/cjs/react-dom-server.bun.development.js +0.75% 347.41 kB 350.01 kB +0.72% 65.97 kB 66.45 kB
facebook-www/ReactDOMServer-dev.modern.js +0.74% 375.41 kB 378.19 kB +0.71% 67.28 kB 67.76 kB
facebook-www/ReactDOMServer-dev.classic.js +0.73% 378.87 kB 381.64 kB +0.70% 67.82 kB 68.29 kB
oss-experimental/react-dom/cjs/react-dom-server.node.development.js +0.73% 410.41 kB 413.42 kB +0.72% 71.41 kB 71.93 kB
oss-experimental/react-dom/cjs/react-dom-server.browser.development.js +0.73% 414.49 kB 417.50 kB +0.78% 71.98 kB 72.54 kB
oss-experimental/react-dom/cjs/react-dom-server.edge.development.js +0.73% 415.50 kB 418.51 kB +0.79% 72.18 kB 72.75 kB
oss-experimental/react-dom/cjs/react-dom-server-legacy.node.development.js +0.71% 389.33 kB 392.11 kB +0.69% 69.04 kB 69.51 kB
oss-experimental/react-dom/cjs/react-dom-server-legacy.browser.development.js +0.71% 389.33 kB 392.11 kB +0.69% 69.04 kB 69.51 kB
facebook-www/ReactDOMServerStreaming-dev.modern.js +0.69% 366.95 kB 369.48 kB +0.71% 65.95 kB 66.42 kB
oss-experimental/react-markup/cjs/react-markup.production.js +0.69% 218.38 kB 219.88 kB +0.48% 40.29 kB 40.48 kB
oss-experimental/react-markup/cjs/react-markup.react-server.production.js +0.47% 318.83 kB 320.33 kB +0.31% 59.54 kB 59.73 kB
oss-experimental/react-markup/cjs/react-markup.development.js +0.41% 360.60 kB 362.08 kB +0.38% 64.74 kB 64.98 kB
oss-experimental/react-markup/cjs/react-markup.react-server.development.js +0.28% 535.52 kB 537.01 kB +0.25% 95.97 kB 96.21 kB

Generated by 🚫 dangerJS against d5e45b9

Comment on lines +1985 to +1999
if (
(preamble.htmlChunks || preamble.headChunks) &&
bootstrapChunks.length === 0
) {
// If we rendered the whole document, then we emitted a rel="expect" that needs a
// matching target. If we haven't emitted that yet, we need to include it in this
// script tag.
bootstrapChunks.push(renderState.startInlineScript);
pushCompletedShellIdAttribute(bootstrapChunks, resumableState);
bootstrapChunks.push(
endOfStartTag,
formReplayingRuntimeScript,
endInlineScript,
);
} else {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not following how this doesn't lead to possible duplicate writing of the id.

If we included it as part of the bootstrap scripts and sent the shell and then later we render a form that requires this runtime the bootstrapChunks will be empty and we'll end up emitting it again.

it may just require that we track whether the bootstrap scripts were actually emitted. Or maybe we just do the template trick and never try to use this script as a vehicle for the expected id.

Also, i don't understand how in the above implementation you are deciding whether or not this runtime injection is happening within the shell vs at arbitrary later point. If we knew it was the shell we could then use the bootstrapChunks.length === 0 as the signal that no other thing was about to emit this id but I don't think that actually works here

renderState: RenderState,
): boolean {
const preamble = renderState.preamble;
if (preamble.htmlChunks || preamble.headChunks) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

The condition here isn't symmetrical with the one in writePreambleStart. Maybe that's ok because the ID is essentially reserved and inert without the associated expect link but if we want to actually be precise we need to know if we are in a mode that never emits the paint blocking link

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You mean the skipExpect hack? I override all of writeCompletedRoot in ReactMarkup. That's in line with how the rest of those overrides work.

987bb1f

However, for the preamble, I still need to write most of it and just exclude this one thing. I'm not sure how to layer that override yet. Maybe it becomes a render state config at some point but for now I wanted it to be compiled out.

Copy link
Collaborator

Choose a reason for hiding this comment

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

oh yeah, and the only other place is in the form replaying runtime but that is not part of markup output either?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

those error if you use functions in forms in markup

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

but also markup can't outline anything so there's no "completed boundary" instructions written. In fact that whole thing is stubbed out.

@sebmarkbage sebmarkbage merged commit 143d3e1 into facebook:main Apr 25, 2025
239 checks passed
github-actions bot pushed a commit that referenced this pull request Apr 25, 2025
…lly 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.

DiffTrain build for [143d3e1](143d3e1)
sebmarkbage added a commit that referenced this pull request Apr 25, 2025
Since the very beginning we have had the `progressiveChunkSize` option
but we never actually took advantage of it because we didn't count the
bytes that we emitted. This starts counting the bytes by taking a pass
over the added chunks each time a segment completes.

That allows us to outline a Suspense boundary to stream in late even if
it is already loaded by the time that back-pressure flow and in a
`prerender`. Meaning it gets inserted with script.

The effect can be seen in the fixture where if you have large HTML
content that can block initial paint (thanks to
[`rel="expect"`](#33016) but also
nested Suspense boundaries). Before this fix, the paint would be blocked
until the large content loaded. This lets us paint the fallback first in
the case that the raw bytes of the content takes a while to download.

You can set it to `Infinity` to opt-out. E.g. if you want to ensure
there's never any scripts. It's always set to `Infinity` in
`renderToHTML` and the legacy `renderToString`.

One downside is that if we might choose to outline a boundary, we need
to let its fallback complete.

We don't currently discount the size of the fallback but really just
consider them additive even though in theory the fallback itself could
also add significant size or even more than the content. It should maybe
really be considered the delta but that would require us to track the
size of the fallback separately which is tricky.

One problem with the current heuristic is that we just consider the size
of the boundary content itself down to the next boundary. If you have a
lot of small boundaries adding up, it'll never kick in. I intend to
address that in a follow up.
github-actions bot pushed a commit that referenced this pull request Apr 25, 2025
Since the very beginning we have had the `progressiveChunkSize` option
but we never actually took advantage of it because we didn't count the
bytes that we emitted. This starts counting the bytes by taking a pass
over the added chunks each time a segment completes.

That allows us to outline a Suspense boundary to stream in late even if
it is already loaded by the time that back-pressure flow and in a
`prerender`. Meaning it gets inserted with script.

The effect can be seen in the fixture where if you have large HTML
content that can block initial paint (thanks to
[`rel="expect"`](#33016) but also
nested Suspense boundaries). Before this fix, the paint would be blocked
until the large content loaded. This lets us paint the fallback first in
the case that the raw bytes of the content takes a while to download.

You can set it to `Infinity` to opt-out. E.g. if you want to ensure
there's never any scripts. It's always set to `Infinity` in
`renderToHTML` and the legacy `renderToString`.

One downside is that if we might choose to outline a boundary, we need
to let its fallback complete.

We don't currently discount the size of the fallback but really just
consider them additive even though in theory the fallback itself could
also add significant size or even more than the content. It should maybe
really be considered the delta but that would require us to track the
size of the fallback separately which is tricky.

One problem with the current heuristic is that we just consider the size
of the boundary content itself down to the next boundary. If you have a
lot of small boundaries adding up, it'll never kick in. I intend to
address that in a follow up.

DiffTrain build for [8e9a5fc](8e9a5fc)
github-actions bot pushed a commit to code/lib-react that referenced this pull request Apr 26, 2025
Since the very beginning we have had the `progressiveChunkSize` option
but we never actually took advantage of it because we didn't count the
bytes that we emitted. This starts counting the bytes by taking a pass
over the added chunks each time a segment completes.

That allows us to outline a Suspense boundary to stream in late even if
it is already loaded by the time that back-pressure flow and in a
`prerender`. Meaning it gets inserted with script.

The effect can be seen in the fixture where if you have large HTML
content that can block initial paint (thanks to
[`rel="expect"`](facebook#33016) but also
nested Suspense boundaries). Before this fix, the paint would be blocked
until the large content loaded. This lets us paint the fallback first in
the case that the raw bytes of the content takes a while to download.

You can set it to `Infinity` to opt-out. E.g. if you want to ensure
there's never any scripts. It's always set to `Infinity` in
`renderToHTML` and the legacy `renderToString`.

One downside is that if we might choose to outline a boundary, we need
to let its fallback complete.

We don't currently discount the size of the fallback but really just
consider them additive even though in theory the fallback itself could
also add significant size or even more than the content. It should maybe
really be considered the delta but that would require us to track the
size of the fallback separately which is tricky.

One problem with the current heuristic is that we just consider the size
of the boundary content itself down to the next boundary. If you have a
lot of small boundaries adding up, it'll never kick in. I intend to
address that in a follow up.

DiffTrain build for [8e9a5fc](facebook@8e9a5fc)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants