Statically prerender metadata image routes under Cache Components#94957
Open
unstubbable wants to merge 5 commits into
Open
Statically prerender metadata image routes under Cache Components#94957unstubbable wants to merge 5 commits into
unstubbable wants to merge 5 commits into
Conversation
Contributor
Stats cancelledCommit: fd6323d |
Contributor
Tests PassedCommit: fd6323d |
lubieowoce
reviewed
Jun 19, 2026
lubieowoce
reviewed
Jun 19, 2026
9b608bb to
110b8b3
Compare
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.
110b8b3 to
fd6323d
Compare
lubieowoce
reviewed
Jun 19, 2026
Comment on lines
+49
to
+50
| ): ReadableStream { | ||
| return new ReadableStream({ |
Member
There was a problem hiding this comment.
Suggested change
| ): ReadableStream { | |
| return new ReadableStream({ | |
| ): ReadableStream<Uint8Array> { | |
| return new ReadableStream<Uint8Array>({ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Under Cache Components, metadata image routes such as
opengraph-imageandiconthat return anImageResponsewere always rendered on demand (ƒ) rather than prerendered.ImageResponsedefers 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
ImageResponsearguments with React Flight'sprerenderToNodeStreaminside 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 ascookies()or an uncachedfetch, 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 oruse cachefinishes serializing and is rendered to an image. This mirrorsencryptActionBoundArgs, 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.lazythat satori can't walk, those references are resolved into plain elements first; this lets an async server component, including one that usesuse cache, be passed as theImageResponseelement. Rasterization runs outside the prerender work-unit store: inside a Cache Components prerender an uncachedfetch, 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
ArrayBufferin a new in-memoryimageResponsesstore 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-responsemodule that is loaded only for Cache Components builds, gated behindprocess.env.__NEXT_CACHE_COMPONENTSso therequireand its React Flight dependencies are eliminated as dead code otherwise; apps without Cache Components keepImageResponse's original streaming behavior unchanged.