cloudflare / cloudflare/vinext
App Router cacheComponents: skip metadata/viewport in error recovery RSC payload (defer to client hydration)
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Next.js Change
**Commit:** [`79f9e67`](https://github.com/vercel/next.js/commit/79f9e67e23039476e24c5942be0a2d4c23581435)
**PR:** [#94134 — Exclude static metadata and viewport from error recovery prerender when cache-components is enabled](https://github.com/vercel/next.js/pull/94134)
## What changed
`getErrorRSCPayload` in `packages/next/src/server/app-render/app-render.tsx` gains a new `shouldRenderMetadataAndViewport` parameter. When `false`, the function skips constructing the `Viewport` and `Metadata` components entirely and emits `null` slots for them in the error payload's `initialHead`:
```diff
async function getErrorRSCPayload(
tree: LoaderTree,
ctx: AppRenderContext,
ssrError: unknown,
- errorType: MetadataErrorType | 'redirect' | undefined
+ errorType: MetadataErrorType | 'redirect' | undefined,
+ shouldRenderMetadataAndViewport: boolean
)
```
```diff
- const { Viewport, Metadata } = createMetadataComponents({ ... })
+ let Viewport: ComponentType | null = null
+ let Metadata: ComponentType | null = null
+ if (shouldRenderMetadataAndViewport) {
+ const metadataComponents = createMetadataComponents({ ... })
+ Viewport = metadataComponents.Viewport
+ Metadata = metadataComponents.Metadata
+ }
```
```diff
- createElement(Viewport, null),
+ Viewport ? createElement(Viewport, null) : null,
...
- createElement(Metadata, null)
+ Metadata ? createElement(Metadata, null) : null
```
Two call sites of `getErrorRSCPayload` (the two error-recovery `renderToStream` paths) now pass `true` for **normal error rendering**. A third call site (the cache-components prerender error recovery path) passes `false` so that dynamic `generateMetadata` / `generateViewport` do not block producing a shell.
## Rationale (from PR description)
> These functions can be dynamic however in this mode we are trying to recover on the client so there is little point in attempting to resolve the http error recovery metadata since it might block us producing a shell. We now exclude these from the error recovery RSC payload since we will recover the metadata on hydration in the browser.
## Test coverage added
- `test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts` — new `describe('notFound() with dynamic metadata and viewport', ...)` block at `/not-found-dynamic-head/[slug]` asserts the prerendered `not-found.html` does **not** contain the dynamic title/description/themeColor markers from `not-found.tsx`, but after `next start` the browser DOM **does** contain them (recovered on hydration).
- New fixture `fixtures/http-access-fallback-prerender/app/not-found-dynamic-head/[slug]/` with `page.tsx` (`unstable_instant = false`, calls `notFound()`, has static `metadata` and async `generateViewport`) and `not-found.tsx` (async `generateMetadata` + `generateViewport`).
- `test/e2e/app-dir/parallel-routes-not-found/parallel-routes-not-found.test.ts` updated with a `retry` + DOM title assertion (`'404: This page could not be found.'`) and a TODO comment noting that SSR vs hydration-data title disagreement when cache-components is off is a sign of incoherent notFound title handling — flagged as a pre-existing parity gap.
## Impact on vinext
vinext's App Router execution pipeline in `packages/vinext/src/server/app-page-execution.ts` models an `http-access-fallback` outcome and runs an error-recovery path that constructs a fallback RSC payload. If that path currently invokes `createMetadataComponents` (or vinext's equivalent metadata/viewport rendering) inside the recovery payload, an `async generateMetadata` / `generateViewport` in a `not-found.tsx` can block shell emission for cache-components routes — diverging from the new upstream behavior.
Concrete things vinext should mirror:
1. **Gate metadata/viewport rendering in the error recovery payload** behind a `shouldRenderMetadataAndViewport` flag (or equivalent) and pass `false` for the cache-components prerender error recovery call site so the shell can be produced without awaiting dynamic metadata.
2. **Emit `null` head slots** (not skipped React elements that change keys) so the client hydration path still matches.
3. **Recover metadata on hydration**: client navigation/hydration should re-resolve dynamic `generateMetadata` / `generateViewport` from `not-found.tsx` and patch the DOM (`document.title`, `meta[name="description"]`, `meta[name="theme-color"]`).
4. **Preserve normal (non-cache-components) error rendering behavior**: the two normal error `renderToStream` call sites still pass `true`, so this is a cache-components-only behavior change.
5. Port the e2e test fixture and assertions, especially the post-hydration DOM check, so we don't regress.
## Related
- #1615 — App Router cacheComponents: revisit notFound recovery (broader resumable-error-page refactor this PR builds on)
- #764 — closed; tracked the now-reverted boundary-rerender approach
Contributor guide
Research direction
Start in packages/vinext/src/server/app-page-execution.ts and trace the http-access-fallback error-recovery path and its fallback RSC payload. Compare the metadata handling with packages/next/src/server/app-render/app-render.tsx, then run test/e2e/app-dir/cache-components-errors/cache-components-errors.http-access-fallback-prerender.test.ts. Done means dynamic metadata does not block the prerendered shell, hydration restores the head, and normal error rendering remains unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, react, typescript
- Domain
- backend, frontend, web-dev
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100