* fix(navigation): tolerate history state writes rejected by the browser
history.pushState/replaceState can throw a SecurityError when document.URL
still contains userinfo credentials (e.g. behind HTTP Basic auth Chromium
strips them from location.href but not from document.URL), in sandboxed or
opaque-origin documents, or when the per-origin call quota is exceeded.
The notify-suppressed history primitives only persist vinext navigation
metadata, so an unguarded call let a hydration-time replaceState failure
escalate to the global error boundary and blank the whole page. Wrap both
primitives in try/catch and log a warning so metadata-write failures degrade
gracefully.
Fixes#2614
* fix(navigation): avoid rewriting URL for history metadata
---------
Co-authored-by: James <james@eli.cx>
* fix(shims): align public API with vendored Next types
* fix(shims): preserve revalidation and runtime behavior
* test(shims): align cache revalidation expectations
* fix(ci): package types for deploy suite
* fix(app-router): hoist streamed metadata into <head> instead of body
Async generateMetadata was serialized via dangerouslySetInnerHTML into a
hidden <div> in the body, which React cannot hoist. For JS-capable clients
(browsers, Googlebot) the tags stayed in <body>, where Google ignores
rel=canonical, hreflang and robots.
Render the resolved metadata as real <title>/<meta>/<link> elements through
MetadataHead so React 19 hoists them into <head>, matching Next.js. Remove
the now-unused renderMetadataToHtml string serializer.
* Fix tests
* fix(app-router): harden metadata hoisting coverage
* fix(app-router): restore streamed metadata placement
* fix(app-router): preserve static metadata placement
---------
Co-authored-by: James <james@eli.cx>
Dynamic App SSR currently pipes React's edge stream as soon as the shell resolves. That first pull can flush a Suspense fallback even when the boundary resolves in the next React render task, producing fallback HTML that Next avoids.
The SSR entry now marks successful dynamic streams and awaits one React render task before wiring them through the HTML transform. waitForAllReady, PPR fallback-shell, and recovered shell-error paths keep their existing behavior.
Add a stream-boundary regression that proves immediate consumption leaks the fallback marker while delayed consumption emits only the resolved content.
* fix(app-router): handle redirects in route-miss fallbacks
* test(app-router): fix frozen-lockfile break in not-found-redirect fixture
The route-miss redirect fixture shipped a package.json declaring six
"latest" dependencies (@vitejs/plugin-react, typescript, @types/react*,
vinext, vite) with no matching pnpm-lock.yaml importer. CI runs
frozen-lockfile installs, so every job died at the shared setup step with
ERR_PNPM_OUTDATED_LOCKFILE, turning the whole suite red before a single
test ran.
The fixture never needs those dependencies: dev tests load it via
createServer({ configFile: false }) with the plugin built in code, and
the production test copies it through createIsolatedFixture, which
symlinks the workspace root node_modules. The only file the build
actually reads is package.json's "type": "module" (for ESM output), so
the deps, vite.config.ts, tsconfig.json, and next-env.d.ts were all dead
scaffolding.
Trim package.json to name/private/type, add the empty lockfile importer
so frozen install is satisfied, and delete the unused config files. Also
drop app/result/page.tsx, which .gitignore's bare "result" rule silently
excluded from the commit anyway; the tests only assert the redirect
Location header, never render /result.
* fix(app-router): encode async route-miss redirects in the RSC flight
A redirect() thrown by an async root layout (one that `await headers()`
before redirecting) during a route-miss not-found render was lost on RSC
navigations. renderAppPageBoundaryResponse returns the RSC stream without
consuming it, so the async redirect only surfaced through React's onError
once the stream was pulled — after the synchronous captured-special-error
check had already run and found nothing. The request fell through to a raw
404 flight instead of the 200 flight-encoded redirect the client router
expects. The document path was unaffected because createHtmlResponse
consumes the stream, surfacing the redirect before the same check.
Drain a tee'd copy of the RSC boundary stream to force the render to
settle before the capture check, then hand the buffered copy back as the
body. Boundary responses are small terminal documents, so buffering them
is an acceptable cost for correct redirect handling; the document path is
untouched. Mirrors app-page-render.ts's pre-flush special-error capture.
Also:
- Extract the duplicated redirect-flight encoding (digest format, throwing
error, stream builder) from app-page-dispatch and app-page-boundary-render
into app-rsc-redirect-flight.ts as a single owner, with focused unit tests.
- Replace the `new Error(...) as Error & { digest }` cast with a typed
RscRedirectFlightError subclass, and read the metadata-error marker via
Reflect.get instead of an `as Record<symbol, unknown>` cast.
- Document that renderBoundarySpecialErrorResponse deliberately omits
renderFallbackPage: redirects are fully handled, but a notFound()/
forbidden()/unauthorized() re-thrown inside boundary rendering has no
parent boundary and terminates with a plain status-text response. Pin
that terminal behavior with a test.
- Add route-miss RSC redirect coverage (dev + production) asserting the
200 flight response, text/x-component, and X-Vinext-Rsc-Redirect header.
* test(app-router): prove RSC redirect drain covers matched-route fallbacks
The RSC special-error drain in the HTTP-access fallback path was described
as route-miss-only, but it runs for every http-access fallback: a matched
route's notFound()/forbidden()/unauthorized() renders its boundary through
the same path, where a layout can also async-redirect. Narrowing the drain
to route misses would leave that matched-route case with the original
lazy-stream bug, so the broad behavior is intentional.
Add a matched-route fixture page (`/gated` calls notFound()) and dev +
production coverage: with the redirect trigger the matched-route not-found's
async layout redirect is encoded as a 200 flight; without it the response is
a normal 404 flight, proving the pre-response buffering neither drops nor
corrupts the matched-route payload. Broaden the drain comment to state the
behavior spans route-miss and matched-route fallbacks and why.
* fix(app-router): honor html-limited bots for fallback metadata redirects
A redirect() thrown from generateMetadata() while rendering an HTTP-access
fallback boundary always rode as a 200 streaming response (HTML meta-refresh
/ RSC flight), even for html-limited bots that Next.js serves a blocking 307.
renderBoundarySpecialErrorResponse never passed serveStreamingMetadata, so
buildAppPageSpecialErrorResponse defaulted `serveStreamingMetadata !== false`
to streaming. Matched-page dispatch already threads
shouldServeStreamingMetadata(userAgent, htmlLimitedBots); the fallback path
did not.
Thread htmlLimitedBots into the fallback renderer, compute the same
per-request decision there, and pass serveStreamingMetadata through the
boundary options into the special-error responder. Hoist the entry's
__htmlLimitedBots declaration above __createAppFallbackRenderer (it runs at
module init) to avoid a temporal-dead-zone reference. Cover both branches:
streaming document → 200 meta-refresh, Bingbot → 307 + Location.
Also fix the matched-route drain test so it actually exercises the new path.
The prior test redirected from the root layout, which fires during the
matched-route layout probe and is caught by the layout special-error path
before the fallback renders — so it passed without touching the boundary
drain. Redirect from the route's own not-found boundary instead (on its own
header, so the root layout renders normally and the fallback is reached),
which surfaces through the RSC drain in renderAppPageBoundaryElementResponse.
* fix(app-router): prefix basePath on global-not-found fallback redirects
The global-not-found branch of renderHttpAccessFallback did not pass
basePath/trailingSlash into renderAppPageHttpAccessFallback, while the normal
fallback branch did. renderBoundarySpecialErrorResponse forwards
options.basePath into buildAppPageSpecialErrorResponse, which prefixes
app-internal redirect Locations with the configured base path. So a redirect()
thrown from app/global-not-found.tsx or its generateMetadata() produced an
unprefixed Location under basePath, unlike every other fallback boundary.
Pass basePath and trailingSlash in the global-not-found branch too, matching
the sibling branch. Also refresh the stale renderBoundarySpecialErrorResponse
comment: it described a route-miss-only path and a "root not-found or error
boundary", but the responder now covers matched-route HTTP-access fallbacks
and no longer claims error-boundary handling.
* fix(app-router): preserve fallback redirect semantics
* fix(app-router): preserve push redirect history
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): preserve raw redirect digest URLs
Redirect digests can carry raw URLs, and raw URLs can contain semicolons. Treating semicolon-delimited URL content as digest structure can truncate or misclassify the redirect target.
Keep redirect digest parsing strict about the final status segment while preserving semicolons in the URL. The server parser stays self-contained so App Router startup does not import the navigation shim.
* fix: simplify malformed redirect digest fallback
* fix: simplify malformed redirect digest fallback
* test: cover malformed redirect digest tails consistently
* fix: restore unstable rethrow cause handling
* fix: guard decodeURIComponent throw and comment status allowlist
- Wrap decodeURIComponent in try/catch for malformed lone % in raw URLs
- Add clarifying comment that only canonical 303/307/308 are extracted
- Both address ask-bonk review feedback from PR #2487
* fix(app-router): preserve raw redirect digest URLs
* fix(app-router): support empty redirect destinations
* docs(app-router): explain redirect digest formats
* docs(app-router): clarify redirect digest parsing
* test(app-router): cover redirect digest re-emission
* docs(app-router): document redirect parser invariants
---------
Co-authored-by: James <james@eli.cx>