diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts
index 3adfee23a..d7656c5bd 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 779793ae1..8231cb67b 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 d28427dbe..e0c040724 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 7dd1e11fd..217257a95 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(
+ '',
+ );
+ });
+ });
});