Skip to content

fix(metadata): render redirect meta-refresh from resolved URL, not re-parsed digest#2057

Open
Divkix wants to merge 1 commit into
cloudflare:mainfrom
Divkix:fix/issue-1977-metadata-digest
Open

fix(metadata): render redirect meta-refresh from resolved URL, not re-parsed digest#2057
Divkix wants to merge 1 commit into
cloudflare:mainfrom
Divkix:fix/issue-1977-metadata-digest

Conversation

@Divkix

@Divkix Divkix commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Problem

When generateMetadata() calls redirect() to a target containing percent-encoded characters or a ; in the query (document request, streaming-capable UA), the emitted meta-refresh URL is corrupted:

  • /search?q=50%25off → double-decoded to /search?q=50%off
  • /a?b=1;c=2 → truncated at the first ; to /a?b=1

The browser then navigates to a wrong/malformed URL. Next.js handles both correctly — this is a vinext-only defect. Fixes #1977.

Root cause

buildAppPageSpecialErrorResponse already resolves the redirect target into digestUrl (a correct, fully-resolved, percent-encoded URL — basePath applied), then builds a verbatim NEXT_REDIRECT;replace;<url>;<status>; digest. For the streaming-metadata document path it fed that digest into renderSsrErrorMetaTagsparseNextRedirectDigest, which was designed for the encoded digests the redirect()/permanentRedirect() shims emit: it splits on ; and keeps only parts[2] (truncating any ; in the URL) and decodeURIComponents the result (double-decoding %25%). Feeding it a verbatim digest corrupts the URL.

The bug is isolated to this path — the html-limited-bot 307 path already emits the URL correctly in its Location header.

Fix

The metadata document-redirect path already holds the resolved URL, so render the refresh tag directly from it via a new renderRedirectRefreshMetaTag(url, status) helper, skipping the lossy digest round-trip. This mirrors Next.js's make-get-server-inserted-html, which emits the raw getURLFromRedirectError(error) URL verbatim (slice-rejoin, no decode).

Deliberately left unchanged (each a separate, correct contract):

  • parseNextRedirectDigest — still correct for its 3 encoded-digest callers (resolveAppPageSpecialError, server-action & route-handler policy) and the streaming-render path (createSsrErrorMetaRenderer), which all consume encoded shim digests.
  • formatNextRedirectDigest / the RSC-flight digest — contractually verbatim, consumed by the client decodeRedirectError and asserted by existing tests.

Changes

  • app-ssr-error-meta.ts: extract buildRedirectRefreshMeta(location, status); add exported renderRedirectRefreshMetaTag(url, status); renderSsrErrorMetaTag (digest path) keeps its existing decode behavior.
  • app-page-execution.ts: buildMetadataRedirectHtmlResponse now takes the resolved { url, statusCode } and renders directly; the verbatim digest is still computed for the RSC-flight path.

Test plan

  • redirect('/search?q=50%25off') and redirect('/a?b=1;c=2') from generateMetadata() now produce the exact, intact URL in the meta-refresh tag.
  • New cases in tests/app-page-execution.test.ts (percent-encoding, ;, 308 delay, basePath applied once, cross-origin) and direct-render unit tests in tests/app-ssr-error-meta.test.ts (no decode, no truncation, HTML-escaping). The two core cases fail before the change and pass after.
  • Full unit suite green; parseNextRedirectDigest / flight-digest tests unaffected.

@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2057
npm i https://pkg.pr.new/create-vinext-app@2057
npm i https://pkg.pr.new/vinext@2057

commit: 5ef677b

@Divkix Divkix closed this Jun 16, 2026
@Divkix Divkix reopened this Jun 16, 2026
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: LGTM

Clean, well-isolated fix with a correct root-cause diagnosis. The metadata document-redirect path already holds a fully-resolved digestUrl (basePath applied via applyAppPageRedirectBasePath, percent-encoding preserved through the new URL() round-trip), so rendering the meta-refresh tag directly from it via renderRedirectRefreshMetaTag avoids the lossy parseNextRedirectDigest round-trip (; truncation + decodeURIComponent double-decode). Verified the new URL() round-trip preserves %25 and ; for the asserted cases.

What I verified:

  • basePath not double-applied — the metadata path resolves basePath once (app-page-execution.ts:340) and renderRedirectRefreshMetaTag correctly does not call prefixRedirectLocation. The "applies basePath exactly once" test covers this.
  • Digest path contract preservedrenderSsrErrorMetaTag (the streaming-render path via createSsrErrorMetaRenderer in app-ssr-entry.ts:519) still decodes via parseNextRedirectDigest + prefixRedirectLocation, which is correct since it consumes encoded shim digests. The shared buildRedirectRefreshMeta helper keeps both paths in sync on delay/escaping.
  • 307 bot path unaffected — app-page-execution.ts:412-432 emits prefixedLocation directly in the Location header (no digest round-trip), matching the PR's claim that path was already correct.
  • Flight digest unchangedformatNextRedirectDigest / VINEXT_RSC_REDIRECT_HEADER still use the verbatim digestUrl.
  • Single caller of buildMetadataRedirectHtmlResponse, properly updated to the new { url, statusCode } signature.
  • vp test run on both affected files: 51 passed. vp check: clean (format, lint, types).

Test coverage is thorough (percent-encoding, ;, 308 delay, basePath-once, cross-origin, HTML-attr escaping) at both the integration (buildAppPageSpecialErrorResponse) and unit (renderRedirectRefreshMetaTag) levels.

No blocking issues.

* resolved URL directly mirrors Next.js's `make-get-server-inserted-html`, which
* renders the raw `getURLFromRedirectError(error)` URL verbatim.
*/
export function renderRedirectRefreshMetaTag(url: string, status: number): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional: renderRedirectRefreshMetaTag is now a thin one-line wrapper that just forwards to buildRedirectRefreshMeta. Since the only behavioral difference between the two paths is whether prefixRedirectLocation runs (and that's handled at the call sites), you could export buildRedirectRefreshMeta directly and drop the wrapper. The extensive doc comment is valuable, though — if you keep the wrapper purely as a documented public entry point, that's a reasonable call. Non-blocking.

@ask-bonk

ask-bonk Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Summary

I reviewed PR #2057 (fix for #1977 — metadata redirect meta-refresh URL corruption) and approved it.

The fix is correct and well-isolated. The bug was that buildMetadataRedirectHtmlResponse fed an already-resolved verbatim redirect digest into parseNextRedirectDigest, which was designed for encoded shim digests — it split on ; (truncating URLs with semicolons) and decodeURIComponent'd the result (double-decoding %25%). The fix renders the refresh tag directly from the resolved digestUrl via a new renderRedirectRefreshMetaTag helper.

What I verified:

  • The new URL() round-trip in applyAppPageRedirectBasePath + sameOriginPathOrAbsolute preserves %25 and ; correctly (confirmed by running it).
  • basePath is applied exactly once (not re-applied in the new path).
  • The digest path's existing decode behavior is correctly preserved for its other callers (streaming render via createSsrErrorMetaRenderer, which consumes encoded shim digests).
  • The 307 bot path and flight digest paths are unaffected, matching the PR's claims.
  • Both test files pass (51 tests) and vp check is clean.

One non-blocking note: renderRedirectRefreshMetaTag is now just a thin forwarding wrapper around buildRedirectRefreshMeta; it could be collapsed, though keeping it as a documented public entry point is reasonable.

github run

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 5ef677b against base 8d0a18d using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.6 KB 126.6 KB ⚫ -0.0%
Client entry size (gzip) vinext 120.6 KB 120.6 KB ⚫ -0.0%
Dev server cold start vinext 3.02 s 3.04 s ⚫ +0.6%
Production build time vinext 3.26 s 3.24 s ⚫ -0.6%
RSC entry closure size (gzip) vinext 98.6 KB 98.4 KB ⚫ -0.1%
Server bundle size (gzip) vinext 165.3 KB 165.1 KB ⚫ -0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

…-parsed digest

generateMetadata() redirect()s to URLs with percent-encoded characters or a ';'
in the query were corrupted in the streaming-metadata meta-refresh tag: the
verbatim NEXT_REDIRECT digest was re-parsed by parseNextRedirectDigest (parts[2]
+ decodeURIComponent), double-decoding %25 (e.g. /search?q=50%25off ->
/search?q=50%off) and truncating at the first ';' (e.g. /a?b=1;c=2 -> /a?b=1).

The metadata document-redirect path already holds the fully-resolved URL
(digestUrl), so render the refresh tag directly from it via the new
renderRedirectRefreshMetaTag() helper instead of round-tripping through a digest
that parseNextRedirectDigest mis-decodes. parseNextRedirectDigest and the
verbatim RSC-flight digest (formatNextRedirectDigest) are left unchanged, so the
shim/encoded-digest and flight paths keep their contracts. Matches Next.js's
make-get-server-inserted-html, which emits the raw redirect URL verbatim.

Fixes cloudflare#1977.
@Divkix Divkix force-pushed the fix/issue-1977-metadata-digest branch from 51577b2 to 5ef677b Compare July 13, 2026 03:49
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.

App Router: redirect() from generateMetadata corrupts the meta-refresh URL (digest double-decode + ; truncation)

2 participants