From 5ef677bd326cdcda8dcce3956baf805f3be78c54 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 15 Jun 2026 18:35:26 -0700 Subject: [PATCH] fix(metadata): render redirect meta-refresh from resolved URL, not re-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 #1977. --- .../vinext/src/server/app-page-execution.ts | 13 ++-- .../vinext/src/server/app-ssr-error-meta.ts | 37 +++++++++--- tests/app-page-execution.test.ts | 59 +++++++++++++++++++ tests/app-ssr-error-meta.test.ts | 30 ++++++++++ 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 3adfee23ab..d7656c5bd5 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -10,7 +10,7 @@ import { VINEXT_RSC_REDIRECT_HEADER, VINEXT_RSC_REDIRECT_TYPE_HEADER } from "./h import { applyEdgeRuntimeHeader } from "./app-page-response.js"; import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; import { parseNextHttpErrorDigest, parseNextRedirectDigest } from "./next-error-digest.js"; -import { renderSsrErrorMetaTags } from "./app-ssr-error-meta.js"; +import { renderRedirectRefreshMetaTag } from "./app-ssr-error-meta.js"; import { isPromiseLike } from "../utils/promise.js"; import { formatNextRedirectDigest } from "./app-rsc-redirect-flight.js"; import { runWithConnectionProbe } from "vinext/shims/headers"; @@ -281,7 +281,8 @@ function sameOriginPathOrAbsolute(location: string, requestUrl: string): string } function buildMetadataRedirectHtmlResponse(options: { - digest: string; + url: string; + statusCode: number; getAndClearPendingCookies?: () => string[]; isEdgeRuntime?: boolean; middlewareContext?: { headers: Headers | null }; @@ -297,7 +298,10 @@ function buildMetadataRedirectHtmlResponse(options: { headers.append("Set-Cookie", cookie); } - const errorMetaTags = renderSsrErrorMetaTags([{ digest: options.digest }]); + // Render the refresh tag straight from the already-resolved redirect URL + // instead of round-tripping through a NEXT_REDIRECT digest. See + // renderRedirectRefreshMetaTag. + const errorMetaTags = renderRedirectRefreshMetaTag(options.url, options.statusCode); // Intentional divergence from Next.js: Next.js inserts the redirect meta tag // into the otherwise fully-rendered streamed document, whereas we emit a // minimal stub (refresh meta tag, empty body). For a redirect this is @@ -333,7 +337,8 @@ export async function buildAppPageSpecialErrorResponse( options.serveStreamingMetadata !== false ) { return buildMetadataRedirectHtmlResponse({ - digest, + url: digestUrl, + statusCode: options.specialError.statusCode, getAndClearPendingCookies: options.getAndClearPendingCookies, isEdgeRuntime: options.isEdgeRuntime, middlewareContext: options.middlewareContext, diff --git a/packages/vinext/src/server/app-ssr-error-meta.ts b/packages/vinext/src/server/app-ssr-error-meta.ts index 779793ae19..8231cb67b9 100644 --- a/packages/vinext/src/server/app-ssr-error-meta.ts +++ b/packages/vinext/src/server/app-ssr-error-meta.ts @@ -38,6 +38,32 @@ function prefixRedirectLocation(location: string, basePath?: string): string { return addBasePathToPathname(pathname, basePath) + location.slice(pathnameEnd); } +function buildRedirectRefreshMeta(location: string, status: number): string { + const delay = status === PERMANENT_REDIRECT_STATUS ? 0 : 1; + return ( + '' + ); +} + +/** + * Renders the redirect refresh meta tag directly from an already-resolved + * redirect URL, bypassing digest re-parsing. The metadata document-redirect + * path (`buildMetadataRedirectHtmlResponse`) resolves the target up front — + * basePath applied, percent-encoding intact — so the URL is ready to emit as-is. + * + * Emitting the resolved URL directly mirrors Next.js's + * `make-get-server-inserted-html`, which renders the raw + * `getURLFromRedirectError(error)` URL verbatim. That also keeps this path + * independent of digest encoding differences (raw canonical vs vinext-encoded). + */ +export function renderRedirectRefreshMetaTag(url: string, status: number): string { + return buildRedirectRefreshMeta(url, status); +} + function renderSsrErrorMetaTag(error: unknown, options: SsrErrorMetaRenderOptions): string { const digest = getNextErrorDigest(error); if (!digest) return ""; @@ -59,14 +85,9 @@ function renderSsrErrorMetaTag(error: unknown, options: SsrErrorMetaRenderOption const redirect = parseNextRedirectDigest(digest); if (!redirect) return ""; - const delay = redirect.status === PERMANENT_REDIRECT_STATUS ? 0 : 1; - const location = prefixRedirectLocation(redirect.url, options.basePath); - return ( - '' + return buildRedirectRefreshMeta( + prefixRedirectLocation(redirect.url, options.basePath), + redirect.status, ); } diff --git a/tests/app-page-execution.test.ts b/tests/app-page-execution.test.ts index d28427dbe6..e0c0407242 100644 --- a/tests/app-page-execution.test.ts +++ b/tests/app-page-execution.test.ts @@ -608,6 +608,65 @@ describe("app page execution helpers", () => { ); }); + // Regression: #1977 — a generateMetadata() redirect() to a target with + // percent-encoded characters or a ';' in the query must reach the browser + // intact in the streaming-metadata meta-refresh tag. Previously the verbatim + // NEXT_REDIRECT digest was re-parsed by parseNextRedirectDigest (parts[2] + + // decodeURIComponent), truncating at the first ';' and double-decoding '%25'. + // The HTML path now renders straight from the already-resolved URL. + async function renderMetadataRedirectHtml( + location: string, + statusCode = 307, + options: { basePath?: string } = {}, + ): Promise { + const response = await buildAppPageSpecialErrorResponse({ + buildRscRedirectFlightStream: vi.fn(), + clearRequestContext: vi.fn(), + isRscRequest: false, + request: new Request("https://example.com/start"), + ...(options.basePath ? { basePath: options.basePath } : {}), + specialError: { + kind: "redirect", + location, + statusCode, + fromMetadata: true, + }, + }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + return response.text(); + } + + it("preserves percent-encoded query characters in the metadata redirect meta-refresh URL (#1977)", async () => { + expect(await renderMetadataRedirectHtml("/search?q=50%25off")).toContain( + '', + ); + }); + + it("preserves a semicolon in the metadata redirect meta-refresh URL without truncating (#1977)", async () => { + expect(await renderMetadataRedirectHtml("/a?b=1;c=2")).toContain( + '', + ); + }); + + it("uses a 0s refresh delay for permanent (308) metadata redirects", async () => { + expect(await renderMetadataRedirectHtml("/perm", 308)).toContain( + '', + ); + }); + + it("applies the configured basePath exactly once to metadata redirect meta-refresh URLs", async () => { + expect(await renderMetadataRedirectHtml("/about?x=1", 307, { basePath: "/docs" })).toContain( + '', + ); + }); + + it("keeps cross-origin metadata redirect targets absolute in the meta-refresh URL", async () => { + expect(await renderMetadataRedirectHtml("https://other.example.org/foo?x=1")).toContain( + '', + ); + }); + it("uses an HTTP redirect for metadata-originated redirects when metadata streaming is disabled", async () => { // Ported from Next.js: // test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts diff --git a/tests/app-ssr-error-meta.test.ts b/tests/app-ssr-error-meta.test.ts index 7dd1e11fd0..217257a958 100644 --- a/tests/app-ssr-error-meta.test.ts +++ b/tests/app-ssr-error-meta.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { createSsrErrorMetaRenderer, + renderRedirectRefreshMetaTag, renderSsrErrorMetaTags, } from "../packages/vinext/src/server/app-ssr-error-meta.js"; @@ -92,4 +93,33 @@ describe("App SSR error meta tags", () => { ); expect(renderer.flush()).toBe(""); }); + + describe("renderRedirectRefreshMetaTag (resolved-URL path, #1977)", () => { + // Unlike the digest path, this entry point takes an already-resolved URL and + // must NOT decode or split it — the metadata document-redirect path holds the + // final URL and only needs HTML-attribute escaping. + it("emits a percent-encoded URL verbatim without double-decoding", () => { + expect(renderRedirectRefreshMetaTag("/search?q=50%25off", 307)).toBe( + '', + ); + }); + + it("preserves a semicolon in the URL without truncating", () => { + expect(renderRedirectRefreshMetaTag("/a?b=1;c=2", 307)).toBe( + '', + ); + }); + + it("uses a 0s delay for permanent (308) redirects", () => { + expect(renderRedirectRefreshMetaTag("/perm", 308)).toBe( + '', + ); + }); + + it("HTML-escapes the URL for attribute context", () => { + expect(renderRedirectRefreshMetaTag('/x?a=1&b=2&q="', 307)).toBe( + '', + ); + }); + }); });