Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions packages/vinext/src/server/app-page-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 };
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 29 additions & 8 deletions packages/vinext/src/server/app-ssr-error-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
'<meta id="__next-page-redirect" http-equiv="refresh" content="' +
delay +
";url=" +
escapeHtmlAttr(location) +
'"/>'
);
}

/**
* 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 {

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.

return buildRedirectRefreshMeta(url, status);
}

function renderSsrErrorMetaTag(error: unknown, options: SsrErrorMetaRenderOptions): string {
const digest = getNextErrorDigest(error);
if (!digest) return "";
Expand All @@ -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 (
'<meta id="__next-page-redirect" http-equiv="refresh" content="' +
delay +
";url=" +
escapeHtmlAttr(location) +
'"/>'
return buildRedirectRefreshMeta(
prefixRedirectLocation(redirect.url, options.basePath),
redirect.status,
);
}

Expand Down
59 changes: 59 additions & 0 deletions tests/app-page-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/search?q=50%25off"/>',
);
});

it("preserves a semicolon in the metadata redirect meta-refresh URL without truncating (#1977)", async () => {
expect(await renderMetadataRedirectHtml("/a?b=1;c=2")).toContain(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/a?b=1;c=2"/>',
);
});

it("uses a 0s refresh delay for permanent (308) metadata redirects", async () => {
expect(await renderMetadataRedirectHtml("/perm", 308)).toContain(
'<meta id="__next-page-redirect" http-equiv="refresh" content="0;url=/perm"/>',
);
});

it("applies the configured basePath exactly once to metadata redirect meta-refresh URLs", async () => {
expect(await renderMetadataRedirectHtml("/about?x=1", 307, { basePath: "/docs" })).toContain(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/docs/about?x=1"/>',
);
});

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(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=https://other.example.org/foo?x=1"/>',
);
});

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
Expand Down
30 changes: 30 additions & 0 deletions tests/app-ssr-error-meta.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/search?q=50%25off"/>',
);
});

it("preserves a semicolon in the URL without truncating", () => {
expect(renderRedirectRefreshMetaTag("/a?b=1;c=2", 307)).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/a?b=1;c=2"/>',
);
});

it("uses a 0s delay for permanent (308) redirects", () => {
expect(renderRedirectRefreshMetaTag("/perm", 308)).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="0;url=/perm"/>',
);
});

it("HTML-escapes the URL for attribute context", () => {
expect(renderRedirectRefreshMetaTag('/x?a=1&b=2&q="<x>', 307)).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/x?a=1&amp;b=2&amp;q=&quot;&lt;x&gt;"/>',
);
});
});
});
Loading