Skip to content
Merged
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
11 changes: 8 additions & 3 deletions packages/vinext/src/server/app-ssr-error-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,14 @@ function renderSsrErrorMetaTag(error: unknown, options: SsrErrorMetaRenderOption

const httpError = parseNextHttpErrorDigest(digest);
if (httpError) {
let html = '<meta name="robots" content="noindex" />';
// Output format matches Next.js's `make-get-server-inserted-html.tsx`,
// which serializes these meta tags via React's HTML renderer. React's
// void-element output uses no space before `/>`, and Next.js tests assert
// on that exact substring (e.g. `'<meta name="robots" content="noindex"/>'`).
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/make-get-server-inserted-html.tsx
let html = '<meta name="robots" content="noindex"/>';
if ((options.nodeEnv ?? process.env.NODE_ENV) === "development") {
html += '<meta name="next-error" content="not-found" />';
html += '<meta name="next-error" content="not-found"/>';
}
return html;
}
Expand All @@ -61,7 +66,7 @@ function renderSsrErrorMetaTag(error: unknown, options: SsrErrorMetaRenderOption
delay +
";url=" +
escapeHtmlAttr(location) +
'" />'
'"/>'
);
}

Expand Down
24 changes: 12 additions & 12 deletions tests/app-ssr-error-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,29 @@ function digestError(digest: string): Error & { digest: string } {
describe("App SSR error meta tags", () => {
it("renders noindex meta tags for streamed notFound and HTTP access fallback errors", () => {
expect(renderSsrErrorMetaTags([digestError("NEXT_NOT_FOUND")])).toBe(
'<meta name="robots" content="noindex" />',
'<meta name="robots" content="noindex"/>',
);

expect(renderSsrErrorMetaTags([digestError("NEXT_HTTP_ERROR_FALLBACK;403")])).toBe(
'<meta name="robots" content="noindex" />',
'<meta name="robots" content="noindex"/>',
);
});

it("renders development next-error metadata for streamed notFound errors", () => {
expect(
renderSsrErrorMetaTags([digestError("NEXT_NOT_FOUND")], { nodeEnv: "development" }),
).toBe(
'<meta name="robots" content="noindex" />' + '<meta name="next-error" content="not-found" />',
'<meta name="robots" content="noindex"/>' + '<meta name="next-error" content="not-found"/>',
);
});

it("renders refresh meta tags for streamed temporary and permanent redirects", () => {
expect(renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/target;307")])).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/target" />',
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/target"/>',
);

expect(renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/target;308")])).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="0;url=/target" />',
'<meta id="__next-page-redirect" http-equiv="refresh" content="0;url=/target"/>',
);
});

Expand All @@ -43,30 +43,30 @@ describe("App SSR error meta tags", () => {
basePath: "/docs",
}),
).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/docs/target?ok=1#done" />',
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/docs/target?ok=1#done"/>',
);

expect(
renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;https%3A%2F%2Fexample.com;307")], {
basePath: "/docs",
}),
).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=https://example.com" />',
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=https://example.com"/>',
);

expect(
renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/docs%3Ffrom%3Dcheckout;307")], {
basePath: "/docs",
}),
).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/docs?from=checkout" />',
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/docs?from=checkout"/>',
);

expect(
renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/docs%23top;307")], {
basePath: "/docs",
}),
).toBe('<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/docs#top" />');
).toBe('<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/docs#top"/>');
});

it("escapes redirect meta URLs before inserting them into HTML", () => {
Expand All @@ -75,20 +75,20 @@ describe("App SSR error meta tags", () => {
digestError("NEXT_REDIRECT;replace;/target%3Fnext%3D%26%22%3Cscript%3E;307"),
]),
).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/target?next=&amp;&quot;&lt;script&gt;" />',
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/target?next=&amp;&quot;&lt;script&gt;"/>',
);
});

it("flushes each captured SSR error meta tag once", () => {
const renderer = createSsrErrorMetaRenderer({ nodeEnv: "production" });

renderer.capture(digestError("NEXT_NOT_FOUND"));
expect(renderer.flush()).toBe('<meta name="robots" content="noindex" />');
expect(renderer.flush()).toBe('<meta name="robots" content="noindex"/>');
expect(renderer.flush()).toBe("");

renderer.capture(digestError("NEXT_REDIRECT;replace;/target;307"));
expect(renderer.flush()).toBe(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/target" />',
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/target"/>',
);
expect(renderer.flush()).toBe("");
});
Expand Down
29 changes: 26 additions & 3 deletions tests/nextjs-compat/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,32 @@ describe("Next.js compat: navigation", () => {
expect(html).toMatch(NOINDEX_META_TAG_RE);
});

// ── Streaming meta tags for not-found / redirect ─────────────
// Ported from Next.js: test/e2e/app-dir/navigation/navigation.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts#L732-L772
//
// When notFound() or redirect() is called from inside a Suspense
// boundary, the error surfaces AFTER the shell has already started
// streaming. The response can no longer return a 4xx/3xx status, so
// Next.js communicates the not-found / redirect intent via inline
// <meta> tags injected into the HTML stream. The exact substring is
// asserted in Next.js tests, so vinext must use the same format
// (React's void-element serialization — no space before `/>`).
//
// Source: packages/next/src/server/app-render/make-get-server-inserted-html.tsx

it("notFound() in Suspense streams noindex robots meta tag (exact substring)", async () => {
const { html } = await fetchHtml(baseUrl, "/suspense-notfound-test");
expect(html).toContain('<meta name="robots" content="noindex"/>');
});

it("redirect() in Suspense streams refresh meta tag (exact substring)", async () => {
const { html } = await fetchHtml(baseUrl, "/suspense-redirect-test");
expect(html).toContain(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/about"/>',
);
});

// ── Browser-only tests (documented, not ported) ──────────────
//
// The following tests ALL require Playwright and are N/A for HTTP-level testing:
Expand Down Expand Up @@ -140,7 +166,4 @@ describe("Next.js compat: navigation", () => {
//
// N/A: Metadata await promise during navigation
// Tests async metadata loading during client nav
//
// N/A: Redirect refresh meta tag
// Tests HTML meta refresh tag — would need streaming-specific fixture
});
2 changes: 1 addition & 1 deletion tests/rsc-streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ describe("Tick-buffered RSC streaming (behavioral)", () => {

const shellPos = output.indexOf("<main>shell</main>");
const redirectMetaPos = output.indexOf(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/redirect/result" />',
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/redirect/result"/>',
);
const boundaryPos = output.indexOf("<template>resolved boundary</template>");

Expand Down
Loading