From 0e98e2e0f1b85c5336db35acd59a925513fc84f9 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 28 May 2026 14:37:56 +0100 Subject: [PATCH] fix(app-router): drop space before /> in streamed error meta tags (#1491) When notFound() or redirect() is called from inside a Suspense boundary the error surfaces after the shell has already streamed, so the not-found / redirect intent is communicated to the client via inline tags injected into the head section of the stream. vinext was emitting these as `` (with a space before the self-closing slash), but Next.js serializes them via React's HTML renderer, which produces `` without the space. The Next.js navigation tests assert on that exact substring, so this mismatch caused ~5 deploy-suite failures. Drop the extra space so the streamed meta tags match Next.js byte-for-byte, and add ported regression tests against the suspense fixtures. Ref: packages/next/src/server/app-render/make-get-server-inserted-html.tsx Ref: test/e2e/app-dir/navigation/navigation.test.ts (SEO describe block) --- .../vinext/src/server/app-ssr-error-meta.ts | 11 +++++-- tests/app-ssr-error-meta.test.ts | 24 +++++++-------- tests/nextjs-compat/navigation.test.ts | 29 +++++++++++++++++-- tests/rsc-streaming.test.ts | 2 +- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/vinext/src/server/app-ssr-error-meta.ts b/packages/vinext/src/server/app-ssr-error-meta.ts index 8b32a04b0..779793ae1 100644 --- a/packages/vinext/src/server/app-ssr-error-meta.ts +++ b/packages/vinext/src/server/app-ssr-error-meta.ts @@ -44,9 +44,14 @@ function renderSsrErrorMetaTag(error: unknown, options: SsrErrorMetaRenderOption const httpError = parseNextHttpErrorDigest(digest); if (httpError) { - let html = ''; + // 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. `''`). + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/make-get-server-inserted-html.tsx + let html = ''; if ((options.nodeEnv ?? process.env.NODE_ENV) === "development") { - html += ''; + html += ''; } return html; } @@ -61,7 +66,7 @@ function renderSsrErrorMetaTag(error: unknown, options: SsrErrorMetaRenderOption delay + ";url=" + escapeHtmlAttr(location) + - '" />' + '"/>' ); } diff --git a/tests/app-ssr-error-meta.test.ts b/tests/app-ssr-error-meta.test.ts index fd71aa4a2..7dd1e11fd 100644 --- a/tests/app-ssr-error-meta.test.ts +++ b/tests/app-ssr-error-meta.test.ts @@ -11,11 +11,11 @@ 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( - '', + '', ); expect(renderSsrErrorMetaTags([digestError("NEXT_HTTP_ERROR_FALLBACK;403")])).toBe( - '', + '', ); }); @@ -23,17 +23,17 @@ describe("App SSR error meta tags", () => { expect( renderSsrErrorMetaTags([digestError("NEXT_NOT_FOUND")], { nodeEnv: "development" }), ).toBe( - '' + '', + '' + '', ); }); it("renders refresh meta tags for streamed temporary and permanent redirects", () => { expect(renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/target;307")])).toBe( - '', + '', ); expect(renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/target;308")])).toBe( - '', + '', ); }); @@ -43,7 +43,7 @@ describe("App SSR error meta tags", () => { basePath: "/docs", }), ).toBe( - '', + '', ); expect( @@ -51,7 +51,7 @@ describe("App SSR error meta tags", () => { basePath: "/docs", }), ).toBe( - '', + '', ); expect( @@ -59,14 +59,14 @@ describe("App SSR error meta tags", () => { basePath: "/docs", }), ).toBe( - '', + '', ); expect( renderSsrErrorMetaTags([digestError("NEXT_REDIRECT;replace;/docs%23top;307")], { basePath: "/docs", }), - ).toBe(''); + ).toBe(''); }); it("escapes redirect meta URLs before inserting them into HTML", () => { @@ -75,7 +75,7 @@ describe("App SSR error meta tags", () => { digestError("NEXT_REDIRECT;replace;/target%3Fnext%3D%26%22%3Cscript%3E;307"), ]), ).toBe( - '', + '', ); }); @@ -83,12 +83,12 @@ describe("App SSR error meta tags", () => { const renderer = createSsrErrorMetaRenderer({ nodeEnv: "production" }); renderer.capture(digestError("NEXT_NOT_FOUND")); - expect(renderer.flush()).toBe(''); + expect(renderer.flush()).toBe(''); expect(renderer.flush()).toBe(""); renderer.capture(digestError("NEXT_REDIRECT;replace;/target;307")); expect(renderer.flush()).toBe( - '', + '', ); expect(renderer.flush()).toBe(""); }); diff --git a/tests/nextjs-compat/navigation.test.ts b/tests/nextjs-compat/navigation.test.ts index 69f2ceb41..00d5c78f9 100644 --- a/tests/nextjs-compat/navigation.test.ts +++ b/tests/nextjs-compat/navigation.test.ts @@ -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 + // 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(''); + }); + + it("redirect() in Suspense streams refresh meta tag (exact substring)", async () => { + const { html } = await fetchHtml(baseUrl, "/suspense-redirect-test"); + expect(html).toContain( + '', + ); + }); + // ── Browser-only tests (documented, not ported) ────────────── // // The following tests ALL require Playwright and are N/A for HTTP-level testing: @@ -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 }); diff --git a/tests/rsc-streaming.test.ts b/tests/rsc-streaming.test.ts index d70ad5946..343183f76 100644 --- a/tests/rsc-streaming.test.ts +++ b/tests/rsc-streaming.test.ts @@ -451,7 +451,7 @@ describe("Tick-buffered RSC streaming (behavioral)", () => { const shellPos = output.indexOf("
shell
"); const redirectMetaPos = output.indexOf( - '', + '', ); const boundaryPos = output.indexOf("");