Skip to content

Statically prerender metadata image routes under Cache Components#94957

Open
unstubbable wants to merge 5 commits into
canaryfrom
hl/cache-image-response
Open

Statically prerender metadata image routes under Cache Components#94957
unstubbable wants to merge 5 commits into
canaryfrom
hl/cache-image-response

Conversation

@unstubbable

@unstubbable unstubbable commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Under Cache Components, metadata image routes such as opengraph-image and icon that return an ImageResponse were always rendered on demand (ƒ) rather than prerendered. ImageResponse defers rasterizing its element tree into the response body stream, and the route-handler prerender unwraps that body within a single task; the rasterization never finishes within that budget, so the route was classified as dynamic. This change renders and caches the image during the prerender so these routes become static ().

During the prerender we serialize the ImageResponse arguments with React Flight's prerenderToNodeStream inside the prerender work-unit store, which runs the user's component tree once in the correct scope. This brings any user-space I/O inside that tree, such as cookies() or an uncached fetch, under the same Cache Components rules that already governed I/O elsewhere in the handler. The hanging-input abort signal bounds the serialization and decides static versus dynamic: if the tree is still waiting on dynamic input once the prerender's cache-sourced input is ready, the serialization can't complete and we return a hanging promise, so the final prerender's macrotask budget classifies the route as dynamic; a tree that resolves entirely from static data or use cache finishes serializing and is rendered to an image. This mirrors encryptActionBoundArgs, which serializes server action bound args with React Flight under the same hanging-input abort signal during a prerender.

The fully resolved element tree is then handed to satori. Because React Flight encodes an async Server Component's output as a React.lazy that satori can't walk, those references are resolved into plain elements first; this lets an async server component, including one that uses use cache, be passed as the ImageResponse element. Rasterization runs outside the prerender work-unit store: inside a Cache Components prerender an uncached fetch, such as the renderer loading a font, is turned into a hanging promise (Cache Components skips I/O that would not be cached anyway), so running satori with no store lets those framework fetches resolve normally. Crucially, because satori only walks the already-resolved tree, no user component runs in that storeless scope, so uncached user-space I/O can't be wrongly allowed there and let a route that should be dynamic render as static.

The rendered image is stored as an ArrayBuffer in a new in-memory imageResponses store on the Resume Data Cache, keyed by a base64 encoding of its serialized arguments, and the cache signal is held open until it is stored so the prospective prerender waits for it. The final prerender retrieves the array buffer from memory within microtasks. The store is in-memory only and is never serialized, so the image array buffers never enter the resume data that ships with the prerender.

The caching path lives in a separate cache-image-response module that is loaded only for Cache Components builds, gated behind process.env.__NEXT_CACHE_COMPONENTS so the require and its React Flight dependencies are eliminated as dead code otherwise; apps without Cache Components keep ImageResponse's original streaming behavior unchanged.

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Stats cancelled

Commit: fd6323d
View workflow run

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: fd6323d

Comment thread packages/next/src/server/og/cache-image-response.ts Outdated
@unstubbable unstubbable marked this pull request as ready for review June 18, 2026 23:30
@unstubbable unstubbable requested a review from lubieowoce June 18, 2026 23:30
Comment thread packages/next/src/server/og/cache-image-response.ts Outdated
Comment thread packages/next/src/server/og/cache-image-response.ts Outdated
@unstubbable unstubbable force-pushed the hl/cache-image-response branch from 9b608bb to 110b8b3 Compare June 19, 2026 18:56
unstubbable and others added 5 commits June 19, 2026 21:41
Under Cache Components, metadata image routes such as `opengraph-image`
and `icon` that return an `ImageResponse` were always rendered on demand
(`ƒ`) rather than prerendered. `ImageResponse` defers its JSX-to-image
rasterization into the response body stream, and the route-handler
prerender unwraps that body within a single task; the rasterization
never finishes within that budget, so the route was classified as
dynamic. This change caches the rendered image during the prerender so
these routes become static (`○`).

The rendered image is stored as an `ArrayBuffer` in a new in-memory
`imageResponses` store on the Resume Data Cache, keyed by a React Flight
serialization of the `ImageResponse` constructor arguments (the same
approach `encryptActionBoundArgs` uses for server action bound args).
The prospective prerender renders the image once and fills the store
while the cache signal is held open, and the final prerender retrieves
the bytes from memory within microtasks. The store is in-memory only and
is never serialized, so the image bytes do not bloat the resume data
that ships with the prerender.

The rasterization runs outside the prerender work-unit store. Inside a
Cache Components prerender an uncached `fetch`, such as the renderer
loading a font or a remote image, is turned into a hanging promise,
since Cache Components skips I/O that would not be cached anyway, which
would otherwise stall the render during the prospective pass. Running it
with no store lets those fetches resolve normally, and it keeps them out
of the serialized `cache` and `fetch` resume data stores that a cache
scope would route them through.

The cache boundary covers only the deterministic rasterization. The
handler that constructs the `ImageResponse` still re-runs on every
prerender pass, so any user-space I/O remains subject to the normal
Cache Components rules and must be wrapped in `use cache` to be
reproducible; if the inputs do not reproduce across the prospective and
final passes, the cache keys diverge, the final render re-renders, and
the route correctly falls back to dynamic. The caching path lives in a
separate `cache-image-response` module that is loaded only for Cache
Components builds, gated behind `process.env.__NEXT_CACHE_COMPONENTS` so
the `require` and its React Flight dependency are eliminated as dead
code otherwise; apps without Cache Components keep `ImageResponse`'s
original streaming behavior unchanged.
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
The earlier commits rasterized the `ImageResponse` arguments directly
with satori, which ran the components passed to `ImageResponse` outside
the prerender work-unit store. User-space I/O elsewhere in the handler
already made the route dynamic through the normal route-handler rules,
but I/O inside that element tree was not subject to them. This commit
serializes the arguments with React Flight's `prerenderToNodeStream`
inside the store instead, so those components run once in the correct
scope. The hanging-input abort signal bounds that serialization and
decides static versus dynamic: if the element tree is still waiting on
dynamic input such as `cookies()` or an uncached `fetch`, the
serialization can't complete and we return a hanging promise so the
route is classified as dynamic; otherwise it is rasterized and cached.

satori then rasterizes only the fully resolved tree and never re-runs a
user component, so uncached user-space I/O can't slip past those rules
by running in its storeless scope. Because React Flight encodes an async
Server Component's output as a `React.lazy` that satori can't walk, we
resolve those references into plain elements first. Together this lets
an async server component, including one that uses `use cache`, be
passed as the `ImageResponse` element.
The `createHangingInputAbortSignal` overload added a few lines to
`dynamic-rendering.ts`, shifting the line numbers in the throw-site code
frames that `cache-components-client-hook-abort-reasons` captures. This
regenerates the affected inline snapshots; only the cited line numbers
change.
@unstubbable unstubbable force-pushed the hl/cache-image-response branch from 110b8b3 to fd6323d Compare June 19, 2026 19:41
Comment on lines +49 to +50
): ReadableStream {
return new ReadableStream({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
): ReadableStream {
return new ReadableStream({
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants