Stacked on #36584
`abort()` currently performs both the synchronous transition into an
aborted request and the reporting/completion of every unfinished task in
the same call. This change splits those phases. Aborting now
synchronously marks the request as aborted, captures the abort reason,
claims pending tasks so already scheduled work cannot continue rendering
them, and captures any DEV async debug information needed at the point
of abort. Reporting and completing the claimed tasks is then performed
from a scheduled `finishAbort()` callback.
This split does not yet allow a promise rejected by an abort listener to
replace the abort reason: work remains blocked once the request has been
aborted, and tests assert that abort-time rejections still report the
original abort reason. It establishes the task boundary needed for a
follow-up change to selectively process rejected suspended work before
completing the remaining aborted tasks.
This is observable for streaming renders because abort cleanup may now
happen after already available output is read. A Suspense boundary that
was previously converted to client rendering before it could be
serialized may instead be emitted as pending first and receive its
client-render instruction when the scheduled abort completion runs.
The scheduled finish must also preserve abort-during-render behavior in
renderers whose scheduler executes synchronously. The request tracks its
currently executing task, and both abort phases leave that task alone so
it can unwind through its normal abort path rather than being completed
twice or reporting an internal control-flow value.
Stacked on #36583
Fizz previously identified a task that was rendering during an abort by
marking its blocked Segment as `RENDERING`. This does not work for
resumed replay tasks because they do not have a Segment, so an abort
during replay could eagerly abort the task before it unwound and then
report the internal `null` throw instead of the abort reason.
Track the task currently executing on the Request so aborting can leave
the in-flight task to unwind through its normal error path for both
render and replay tasks. Since this replaces the only purpose of the
Segment `RENDERING` status, remove that status and its associated
bookkeeping.
When resumed work unwinds after aborting, use the request's abort reason
in the replay catch paths so aborting while replaying a prerendered tree
or while rendering a resumed segment reports the meaningful abort reason
instead of the internal control-flow value.
Previously Fizz represented an active abort using the `ABORTING` request
status. This is ambiguous because aborting a task can synchronously
fatal the request, transitioning it to `CLOSING` or `CLOSED` while
another task is still unwinding from the same abort. Once that happened,
the in-flight task no longer observed that the request was aborted and
could fail to report its abort error.
This change removes the `ABORTING` status and instead tracks whether the
request was aborted independently on the Request. The existing
`fatalError` field continues to store the abort reason. As a result,
tasks that were rendering when an abort occurred continue to observe the
abort even if another aborted task has already fataled the request,
allowing all relevant unfinished task errors to be reported. DEV stalled
replays temporarily mask the aborted state so they can continue to
reconstruct suspended call sites as before.
This also establishes explicit abort state on the Request for follow-up
work that delays abort completion and allows rejected suspended work to
provide more specific abort errors.
There are parts of Fizz that need to schedule work regardless of whether
the primary rendering pathway is drive externally through performWork.
Historically scheduleWork and later s cheduleMicrotask were noops in
this bundle but it makes it hard to reason about the code because you
cannot be assured that calling scheduleWork will actually result in the
function
ever executing. Now we model this explicitly through config. For builds
that drive work through external calls to performaWork we simply omit
any work scheduling in startWork or pi
ngTask. Now that this is modeled explicitly we can implement
scheduleMicrotask and scheduleWork to actually provide a guarantee that
the callbacks will get invoked.
For now I've implemented these two for the fb build as synchronous
however it is likely that queueMicrotask and setTimeout or similar are
preferred.
Stacked on #36574
Normally, a fatal error transitions the request to CLOSED or CLOSING,
which prevents later aborted root tasks from reporting their errors.
Errors inside Suspense boundaries can still be reported, but other
pending root tasks are hidden once the first one fatally errors.
That behavior is useful for a normal fatal render error, where
subsequent work does not need to be processed. During an abort, however,
the abort reason is already the source of failure for every unfinished
task. Treating the first root task visited during abort cleanup as the
only observable fatal error privileges arbitrary task ordering and hides
useful information about the unfinished render.
Continue logging errors for pending tasks aborted after the request has
already fatally errored, while still only failing the shell once.
Aborting is a gate you can only pass through once. A request that is
already aborting, already completed, or already fataled cannot be
aborted a second time. Previously this was generally functionally true
but you could contrive sequences where an onError would fire after a
render fataled. This change makes it more explicit that this is not
semantically correct by bailing out of abort if the request is in a
status that cannot be aborted.
The Flight Server emits Text and TypedArray rows as two chunks: a header
that gives the row's id, type, and content length, followed by the
content itself. These two chunks were pushed into
`completedRegularChunks` (and `completedDebugChunks` in DEV) as separate
items, so when the destination signaled backpressure between them, the
flush would write the header and then break out before reaching the
content. The content chunk was left stranded at the head of the queue.
Async work running while the destination was paused appended new rows to
`completedImportChunks` / `completedHintChunks`, and the next drain
flushed those queues first — splicing the newly-arrived bytes into the
position the Flight Client expects to read as the original row's
content. From there the Flight Client read rows from the wrong byte
offsets and the model failed to deserialize.
This only surfaced on the Node stream path.
`createFakeWritableFromReadableStreamController`, used by
`renderToReadableStream`, always returns `true` from `write()`, so the
flush loop never saw backpressure.
The fix pushes a `NEXT_TWO_CHUNKS_ARE_ATOMIC` sentinel ahead of each
`headerChunk` / `contentChunk` pair in `completedRegularChunks` and
`completedDebugChunks`. The flush loops detect the sentinel and write
the two chunks that follow it together before re-checking backpressure,
so backpressure can still break between rows but never within one.
### Alternatives considered
- **Pushing the pair as a `[headerChunk, contentChunk]` tuple.** Simpler
but allocates an array per row. The required `isArray` branch in the
flush hot path is likely comparable with the symbol check. This also
violates the opaque `Chunk` type boundary.
- **Concatenating header and content into one chunk.** Bad for memory —
typed-array content can be large.
- **Storing atomic groups in a separate queue.** Conceptually wrong and
risks breaking reference-ordering assumptions between rows.
- **Ignoring backpressure until the regulars queue is empty.** Defeats
the point of backpressure.
- **Wrapping the tuple behind a host-config API** (`writeAdjacentChunks`
/ `isAdjacentChunks` / `chunksToAdjacentChunks` /
`getAdjacentChunksLength`). Keeps the implementation opaque but adds
four exports per host config. Also has the tuple overhead.
- **Begin/end sentinels for variable-length atomic groups.** Not needed
— only Text and TypedArray rows use this pattern, and both are pairs.
When a rejected `Promise` is instrumented in userspace while setting the
rejection reason in the wrong field (e.g. `error` instead of `reason`),
React will throw undefined (because `usable.reason` doesn't exist). This
makes it incredibly hard to find the actual rejection reason.
React is now throwing a generic error if we couldn't find the rejection
reason. That will produce a callstack pointing into the problematic
Promise from where you can hopefully extract the real rejection reason
(alongside fixing the bad instrumentation).
We're doing this in prod since this is unlikely to surface in dev. We're
only doing runtime type-checking for the rejected case. That should be
hit rarely and therefore hopefully have negligible runtime impact.
`ensureSuspendableThenableStateDEV` patches `then` in fulfilled
thenables to avoid triggering a custom thenable's `then` in an
unexpected state. However, we weren't doing the same for rejected
thenables.
This affected `ReactPromise`, the type used for thenables passed from
server to client. if a `ReactPromise` passed to `use` was pending during
the render but became rejected between the abort and
`pushSuspendedCallSiteOnComponentStack`, then `ReactPromise#then` would
crash. (see the added test for a reprouction)
This is because we were putting the ReactPromise into an invalid state:
a `PendingChunk` expects to have a `value: null | Array<...>`, but we
were deleting `value` altogether, and tgus hitting `TypeError: can't
access property "push" of undefined` here:
https://github.com/facebook/react/blob/32abdbca5a984c9d5e9940d3727fc420e2b118f7/packages/react-client/src/ReactFlightClient.js#L309
Bypassing the suspended thenable's `then` avoids this crash.
<!--
Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.
Before submitting a pull request, please make sure the following is
done:
1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
10. If you haven't already, complete the CLA.
Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->
## Summary
<!--
Explain the **motivation** for making this change. What existing problem
does the pull request solve?
-->
- Adds a new react-flight-server-fb package providing RSC Flight
bindings for Meta's internal bundler stack
- Unlike webpack/turbopack integrations, this uses no manifest. Module
metadata is self-contained in ClientReference objects and sent over the
wire as-is
- Registers dom-browser-fb and dom-node-fb host configs for Rollup
builds targeting FB_WWW_DEV and FB_WWW_PROD
Key design differences from other bundler
- No build-time manifest
- Module IDs use Haste module names (e.g. `"MyComponent"`), with named
exports encoded as `"Module#export"`, rather than file paths resolved
through a manifest
- Client-side loading uses `Bootloader.handlePayload()` +
`JSResource().load()`
- `resolveClientReferenceMetadata` and `resolveClientReference` are
pass-throughs
## How did you test this change?
<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
If you leave this empty, your PR will very likely be closed.
-->
E2E integration test is set up on Meta's internal system.
The prior fix for finishedTask reentrancy solved an observed failure.
This change adds a bit of defensive bookeeping to protect against other
theoretical reentrant task finishing that might fail in simlar ways but
where we don't have a clear demonstration of the bug.
It is possible for the fallback tasks from a Suspense boundary to
trigger an early `completeAll` call which is later repeated due to
`finishedTask` reentrancy. For node.js in particular this might be
problematic since we invoke a callback on each `completeAll` call but in
general it just isn't the right semantics since the call is running
slightly earlier than the completion of the last `finishedTask`
invocation. This change ensures that any reentrant `finishedTask` calls
(due to soft aborting fallback tasks) omit the `completeAll` call by
temporarily incrementing the total pending tasks.
<!--
Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.
Before submitting a pull request, please make sure the following is
done:
1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
10. If you haven't already, complete the CLA.
Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->
## Summary
<!--
Explain the **motivation** for making this change. What existing problem
does the pull request solve?
-->
I just fixed typos as followings.
- `succesful` → `successful`
- `becuase` → `because`
- `enought` → `enough`
- `defualt` → `default`
## How did you test this change?
<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
If you leave this empty, your PR will very likely be closed.
-->
This PR only includes test case description, dummy strings for test, and
comments updates, so it has no impact on runtime behavior.
Therefore, I manually reviewed changed texts to ensure correctness.
When `requireModule` triggers a reentrant `readChunk` on the same module
chunk, the reentrant call can fail and set `chunk.reason` to an error.
After the outer `requireModule` succeeds, the chunk transitions to
initialized but retains the stale error as `reason`.
When the Flight response stream later closes, it iterates all chunks and
expects `reason` on initialized chunks to be a `FlightStreamController`.
Since the stale `reason` is an `Error` object instead, calling
`chunk.reason.error()` crashes with `TypeError: chunk.reason.error is
not a function`.
The reentrancy can occur when module evaluation synchronously triggers
`readChunk` on the same chunk — for example, when code called during
evaluation tries to resolve the client reference for the module that is
currently being initialized. In Fizz SSR, `captureOwnerStack()` can
trigger this because it constructs component stacks that resolve lazy
client references via `readChunk`. The reentrant `requireModule` call
returns the module's namespace object, but since the module is still
being evaluated, accessing the export binding throws a TDZ (Temporal
Dead Zone) `ReferenceError`. This sets the chunk to the errored state,
and the `ReferenceError` becomes the stale `chunk.reason` after the
outer call succeeds.
This scenario is triggered in Next.js when a client module calls an
instrumented API like `Math.random()` in module scope, which
synchronously invokes `captureOwnerStack()`.
When flushing the shell, stylesheets with precedence are emitted in the
`<head>` which blocks paint regardless. Outlining a boundary solely
because it has suspensey CSS provides no benefit during the shell flush
and causes a higher-level fallback to be shown unnecessarily (e.g.
"Middle Fallback" instead of "Inner Fallback").
This change passes a flushingInShell flag to hasSuspenseyContent so the
host config can skip stylesheet-only suspensey content when flushing the
shell. Suspensey images (used for ViewTransition animation reveals)
still trigger outlining during the shell since their motivation is
different.
When flushing streamed completions the behavior is unchanged — suspensey
CSS still causes outlining so the parent content can display sooner
while the stylesheet loads.
When the Flight Client resolves chunk references during model parsing,
it calls `transferReferencedDebugInfo` to propagate debug info entries
from referenced chunks to the parent chunk. Debug info on chunks is
later moved to their resolved values, where it is used by React DevTools
to show performance tracks and what a component was suspended by.
Debug chunks themselves (specifically `ReactComponentInfo`,
`ReactAsyncInfo`, `ReactIOInfo`, and their outlined references) are
metadata that is never rendered. They don't need debug info attached to
them. Without this fix, debug info entries accumulate on outlined debug
chunks via their references to other debug chunks (e.g. owner chains and
props deduplication paths). Since each outlined chunk's accumulated
entries are copied to every chunk that references it, this creates
exponential growth in deep component trees, which can cause the dev
server to hang and run out of memory.
This generalizes the existing skip of `transferReferencedDebugInfo` for
Element owner/stack references (which already recognizes that references
to debug chunks don't need debug info transferred) to all references
resolved during debug info resolution. It adds an
`isInitializingDebugInfo` flag set in `initializeDebugChunk` and
`resolveIOInfo`, which propagates through all nested
`initializeModelChunk` calls within the same synchronous stack. For the
async path, `waitForReference` captures the flag at call time into
`InitializationReference.isDebug`, so deferred fulfillments also skip
the transfer.
Database libraries like Gel/EdgeDB can create very long linear chains of
async sequences through temporal async sequencing in connection pools.
The recursive traversal of `node.previous` chains in `visitAsyncNode`
causes stack overflow on these deep chains.
The fix converts the `previous` chain traversal from recursive to
iterative. We collect the chain into an array, then process from deepest
to shallowest.
The `awaited` traversal remains recursive since its depth is bounded by
promise dependency depth, not by the number of event loop turns. Each
`awaited` branch still benefits from the iterative `previous` handling
within its own traversal.
I've verified that this fixes the
[repro](https://github.com/jere-co/next-debug) provided in #35246.
closes#35246
Allows Server Components to import Context from a `"use client'` module
and render its Provider.
Only tricky part was that I needed to add `REACT_CONTEXT_TYPE` handling
in mountLazyComponent so lazy-resolved Context types can be rendered.
Previously only functions, REACT_FORWARD_REF_TYPE, and REACT_MEMO_TYPE
were handled.
Tested in the Flight fixture.
ty bb claude
Closes https://github.com/facebook/react/issues/35340
---------
Co-authored-by: Sophie Alpert <[email protected]>
Currently, IO that finished before the request started is not considered
IO:
https://github.com/facebook/react/blob/da33fa5028edd00cf9697b153efe2b3f5e3f643d/packages/react-server/src/ReactFlightServer.js#L5338-L5343
This leads to loss of debug info when a flight stream is deserialized
and serialized again.
We can solve this by allowing "when the the request started" to be set
to a point in the past, when the original stream started by doing
```js
const startTime = performance.now() + performance.timeOrigin
// ... stuff happens and time passes...
ReactServer.renderToReadableStream(..., { startTime })
```
This fixes security vulnerabilities in Server Functions.
---------
Co-authored-by: Sebastian Markbåge <[email protected]>
Co-authored-by: Josh Story <[email protected]>
Co-authored-by: Janka Uryga <[email protected]>
Co-authored-by: Sebastian Sebbie Silbermann <[email protected]>
When `renderModelDestructive` unwraps a lazy element and subsequently
calls `renderModelDestructive` again with the resolved model, we should
preserve the parent connection so that cyclic references can be
serialized properly. This can occur in an advanced scenario where the
result from the Flight Client is serialized again with the Flight
Server, e.g. for slicing a precomputed payload into multiple parts.
Note: The added test only fails when run with `--prod`. In dev mode, the
component info outlining prevents the issue from occurring.
Requires full error message in assert helpers.
Some of the error messages we asset on add a native javascript stack
trace, which would be a pain to add to the messages and maintain. This
PR allows you to just add `\n in <stack>` placeholder to the error
message to denote a native stack trace is present in the message.
---
Note: i vibe coded this so it was a pain to backtrack this to break this
into a stack, I tried and gave up, sorry.
## Summary
This PR improves cyclic thenable detection in
`ReactFlightReplyServer.js`. Fixes#35368.
The previous fix only detected direct self-references (`inspectedValue
=== chunk`) and relied on the `cycleProtection` counter to eventually
bail out of longer cycles. This change keeps the existing
MAX_THENABLE_CYCLE_DEPTH ($1000$) `cycleProtection` cap as a hard
guardrail and adds a visited set so that we can detect self-cycles and
multi-node cycles as soon as any `ReactPromise` is revisited and while
still bounding the amount of work we do for deep acyclic chains via
`cycleProtection`.
## How did you test this change?
- Ran the existing test suite for the server renderer:
```bash
yarn test react-server
yarn test --prod react-server
yarn flow dom-node
yarn linc
```
---------
Co-authored-by: Hendrik Liebau <[email protected]>
Server Functions can be stringified (sometimes implicitly) when passed
as data. This adds an override to hide the source code in that case -
just in case someone puts sensitive information in there.
Note that this still preserves the `name` field but this is also
available on the export but in practice is likely minified anyway.
There's nothing else on these referenes we'd consider unsafe unless you
explicitly expose expandos which are part of the `"use server"` export.
This adds a safety check to ensure you don't encode cyclic Promises.
This isn't a parser bug per se. Promises do have a safety mechanism that
avoids them infinite looping. However, since we use custom Thenables,
what can happen is that every time a native Promise awaits it, another
Promise wrapper is created around the Thenable which foils the
ECMAScript Promise cycle detection which can lead to an infinite loop.
This also ensures that embedded `ReadableStream` and `AsyncIterable`
streams are properly closed if the source stream closes early both on
the Server and Client. This doesn't cause an infinite loop but just to
make sure resource clean up can proceed properly.
We're also adding some more explicit clear errors for invalid payloads
since we no longer need to obfuscate the original issue.
AFAIK this is not needed to prevent any exploit but we don't really need
this. We allow functions on pretty much any other object anyway but
never on the "then" property since those would be serialized as Promises
by the client anyway.
FlightReplyServer are for client->server and ReactFlightClient is for
server->client. They're not 100% symmetrical.
We did a number of refactors to ReactFlightClient in PRs like #29823 and
#33664 to change the structure of the resolution. This PR brings those
changes to synchronize the two approaches. Which addresses deep
resolution of cycles and deferred error handling.
This also fixes a critical security vulnerability.
When dealing with optimistic state, a common problem is not knowing the
id of the thing we're waiting on. Items in lists need keys (and single
items should often have keys too to reset their state). As a result you
have to generate fake keys. It's a pain to manage those and when the
real item comes in, you often end up rendering that with a different
`key` which resets the state of the component tree. That in turns works
against the grain of React and a lot of negatives fall out of it.
This adds a special `optimisticKey` symbol that can be used in place of
a `string` key.
```js
import {optimisticKey} from 'react';
...
const [optimisticItems, setOptimisticItems] = useOptimistic([]);
const children = savedItems.concat(
optimisticItems.map(item =>
<Item key={optimisticKey} item={item} />
)
);
return <div>{children}</div>;
```
The semantics of this `optimisticKey` is that the assumption is that the
newly saved item will be rendered in the same slot as the previous
optimistic items. State is transferred into whatever real key ends up in
the same slot.
This might lead to some incorrect transferring of state in some cases
where things don't end up lining up - but it's worth it for simplicity
in many cases since dealing with true matching of optimistic state is
often very complex for something that only lasts a blink of an eye.
If a new item matches a `key` elsewhere in the set, then that's favored
over reconciling against the old slot.
One quirk with the current algorithm is if the `savedItems` has items
removed, then the slots won't line up by index anymore and will be
skewed. We might be able to add something where the optimistic set is
always reconciled against the end. However, it's probably better to just
assume that the set will line up perfectly and otherwise it's just best
effort that can lead to weird artifacts.
An `optimisticKey` will match itself for updates to the same slot, but
it will not match any existing slot that is not an `optimisticKey`. So
it's not an `any`, which I originally called it, because it doesn't
match existing real keys against new optimistic keys. Only one
direction.
This PR fixes a critical bug where `ReadableStream({type: 'bytes'})`
instances passed through React Server Components (RSC) would stall after
reading only the first chunk or the first few chunks in the client. This
issue was masked by using `web-streams-polyfill` in tests, but manifests
with native Web Streams implementations.
The root cause is that when a chunk is enqueued to a
`ReadableByteStreamController`, the spec requires the underlying
ArrayBuffer to be synchronously transferred/detached. In the React
Flight Client's chunk parsing, embedded byte stream chunks are created
as views into the incoming RSC stream chunk buffer using `new
Uint8Array(chunk.buffer, offset, length)`. When embedded byte stream
chunks are enqueued, they can detach the shared buffer, leaving the RSC
stream parsing in a broken state.
The fix is to copy embedded byte stream chunks before enqueueing them,
preventing buffer detachment from affecting subsequent parsing. To not
affect performance too much, we use a zero-copy optimization: when a
chunk ends exactly at the end of the RSC stream chunk, or when the row
spans into the next RSC chunk, no further parsing will access that
buffer, so we can safely enqueue the view directly without copying.
We now also enqueue embedded byte stream chunks immediately as they are
parsed, without waiting for the full row to complete.
To simplify the logic in the client, we introduce a new `'b'` protocol
tag specifically for byte stream chunks. The server now emits `'b'`
instead of `'o'` for `Uint8Array` chunks from byte streams (detected via
`supportsBYOB`). This allows the client to recognize byte stream chunks
without needing to track stream IDs.
Tests now use the proper Jest environment with native Web Streams
instead of polyfills, exposing and validating the fix for this issue.
I need to regain a field because the SuspenseBoundary type is already at
16 fields in prod, after which it deopts v8.
There are two fields that are only used in prerender to track postpones.
These are ripe to be split into an optional object so that they only
take up one field when they're not used.
We've long had the CPU suspense feature behind a flag under the terrible
API `unstable_expectedLoadTime={arbitraryNumber}`. We've known for a
long time we want it to just be `defer={true}` (or just `<Suspense
defer>` in the short hand syntax). So this adds the new name and warns
for the old name.
For only the new name, I also implemented SSR semantics in Fizz. It has
two effects here.
1) It renders the fallback before the content (similar to prerender)
allowing siblings to complete quicker.
2) It always outlines the result. When streaming this should really
happen naturally but if you defer a prerendered content it also implies
that it's expensive and should be outlined. It gives you a opt-in to
outlining similar to suspensey images and css but let you control it
manually.