From b84d25f4391f82cd8ebdcb97b1a89cdd6b2c14f5 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:56:23 +1100 Subject: [PATCH 1/3] Add production coverage for global-error compat --- tests/nextjs-compat/TRACKING.md | 36 ++++++------ tests/nextjs-compat/global-error.test.ts | 70 +++++++++++++++++++++++- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/tests/nextjs-compat/TRACKING.md b/tests/nextjs-compat/TRACKING.md index eb82cf099d..2f732ac498 100644 --- a/tests/nextjs-compat/TRACKING.md +++ b/tests/nextjs-compat/TRACKING.md @@ -75,27 +75,27 @@ Ported from: https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir **Local**: `tests/nextjs-compat/global-error.test.ts` **Fixtures**: `fixtures/app-basic/app/nextjs-compat/global-error-{rsc,ssr}/`, `metadata-error-{with,without}-boundary/` -| # | Next.js Test | Vinext Status | Notes | -| --- | --------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | error-server-test: server component throw caught by error.tsx | **SKIP** | Vinext returns 500 instead of rendering error.tsx boundary (200). RSC error propagates to HTTP handler instead of being caught at segment level. Fix: `packages/vinext/src/entries/app-rsc-entry.ts` — SSR layer needs to handle RSC error chunks by rendering error boundary. | -| 2 | error-nested-test: nested error caught by inner error.tsx | **SKIP** | Same root cause as #1. | -| 3 | Server component throw without local error.tsx returns a response | PASS | Returns a response (500) — server doesn't crash. Next.js would render global-error.tsx with 200. | -| 4 | Client component SSR throw without local error.tsx returns a response | PASS | Same — returns response, server stays up. | -| 5 | generateMetadata() error caught by local error.tsx boundary | **SKIP** | Vinext shows Vite dev error overlay instead of rendering co-located error.tsx. Fix: `packages/vinext/src/shims/metadata.tsx` (resolveModuleMetadata ~line 135) — wrap generateMetadata() in try/catch, render error boundary if sibling error.tsx exists. | -| 6 | generateMetadata() error without local boundary returns a response | PASS | Returns a response (Vite overlay HTML), server stays up. | -| 7 | Client-side error trigger via button click -> global-error renders | N/A | Requires Playwright — client component state change triggers throw | -| 8 | Nested client error auto-thrown via useEffect -> global-error | N/A | Requires Playwright | -| 9 | Dev-only Redbox display verification | N/A | Tests Next.js-specific dev overlay format, not applicable | -| 10 | Client-side notFound() trigger from button (root) | N/A | Requires Playwright | -| 11 | Client-side notFound() trigger from button (nested) | N/A | Requires Playwright | - -**Result: 3/6 pass (HTTP-level), 3 skip (vinext issues), 5 N/A (browser-only, dev overlay)** +| # | Next.js Test | Vinext Status | Notes | +| --- | --------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------- | +| 1 | error-server-test: server component throw caught by error.tsx | PASS | Verified in dev + production preview: vinext renders the local `error.tsx` boundary with HTTP 200. | +| 2 | error-nested-test: nested error caught by inner error.tsx | PASS | Verified in dev + production preview: vinext resolves to the nearest matching `error.tsx`, not the parent boundary. | +| 3 | Server component throw without local error.tsx returns a response | PASS | Verified in dev: vinext renders `global-error.tsx` with HTTP 200 when no local boundary exists. | +| 4 | Client component SSR throw without local error.tsx returns a response | PASS | Verified in dev: vinext renders `global-error.tsx` with HTTP 200 for SSR-time client component throws. | +| 5 | generateMetadata() error caught by local error.tsx boundary | PASS | Verified in dev + production preview: vinext renders the co-located `error.tsx` boundary with HTTP 200. | +| 6 | generateMetadata() error without local boundary returns a response | PASS | Verified in dev + production preview: vinext escalates to `global-error.tsx` with HTTP 200 when no local boundary exists. | +| 7 | Client-side error trigger via button click -> global-error renders | N/A | Requires Playwright — client component state change triggers throw | +| 8 | Nested client error auto-thrown via useEffect -> global-error | N/A | Requires Playwright | +| 9 | Dev-only Redbox display verification | N/A | Tests Next.js-specific dev overlay format, not applicable | +| 10 | Client-side notFound() trigger from button (root) | N/A | Requires Playwright | +| 11 | Client-side notFound() trigger from button (nested) | N/A | Requires Playwright | + +**Result: 6/6 pass (HTTP-level), 0 skip, 5 N/A (browser-only, dev overlay)** ### Findings -- **Server component errors return 500**: When a server component throws during RSC rendering, vinext returns HTTP 500 instead of catching the error and rendering the nearest error.tsx boundary with a 200. The RSC stream correctly encodes the error, but the SSR layer doesn't handle error chunks by activating React error boundaries. -- **generateMetadata() errors bypass error.tsx**: When `generateMetadata()` throws, vinext's metadata resolution lets the error propagate to the top-level handler, triggering Vite's dev error overlay instead of rendering the co-located error.tsx boundary. -- **Server stays up**: Despite errors, the dev server doesn't crash — all error paths return some HTTP response. +- **Tracked skips were stale**: Current vinext already matches Next.js for the HTTP-level cases in this chunk. The test file was green in dev, and production preview matches the same 200-status boundary behavior. +- **Server component and `generateMetadata()` errors use separate paths**: Server/render errors are rerouted through `renderErrorBoundaryPage()` from the RSC/SSR pipeline in `packages/vinext/src/entries/app-rsc-entry.ts`, while page-level `generateMetadata()` errors surface earlier from `buildPageElement()` and are routed to the same boundary renderer by a different catch path. +- **Remaining gaps are browser-only**: The unported cases are still the client-triggered `global-error` interactions and Next.js-specific dev redbox assertions, not server-side HTTP semantics. --- diff --git a/tests/nextjs-compat/global-error.test.ts b/tests/nextjs-compat/global-error.test.ts index 1b90716274..06d3314e7d 100644 --- a/tests/nextjs-compat/global-error.test.ts +++ b/tests/nextjs-compat/global-error.test.ts @@ -23,8 +23,11 @@ * - fixtures/app-basic/app/nextjs-compat/metadata-error-{with,without}-boundary/ (new) */ +import fs from "node:fs"; +import path from "node:path"; import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; -import type { ViteDevServer } from "vite-plus"; +import { createBuilder, preview, type ViteDevServer } from "vite-plus"; +import vinext from "../../packages/vinext/src/index.js"; import { APP_FIXTURE_DIR, startFixtureServer, fetchHtml } from "../helpers.js"; describe("Next.js compat: global-error", () => { @@ -171,3 +174,68 @@ describe("Next.js compat: global-error", () => { // Vinext uses Vite's error overlay which has different formatting. // N/A for compat suite. }); + +describe("Next.js compat: global-error (production preview)", () => { + const outDir = path.resolve(APP_FIXTURE_DIR, "dist"); + let previewServer: Awaited>; + let baseUrl: string; + + beforeAll(async () => { + const builder = await createBuilder({ + root: APP_FIXTURE_DIR, + configFile: false, + plugins: [vinext({ appDir: APP_FIXTURE_DIR })], + logLevel: "silent", + }); + await builder.buildApp(); + + previewServer = await preview({ + root: APP_FIXTURE_DIR, + configFile: false, + plugins: [vinext({ appDir: APP_FIXTURE_DIR })], + preview: { port: 0 }, + logLevel: "silent", + }); + + const addr = previewServer.httpServer.address(); + if (!addr || typeof addr !== "object") { + throw new Error("Preview server did not expose a port"); + } + baseUrl = `http://localhost:${addr.port}`; + }, 60_000); + + afterAll(() => { + previewServer?.httpServer.close(); + fs.rmSync(outDir, { recursive: true, force: true }); + }); + + it("server component throw with local error.tsx renders that boundary with 200", async () => { + const { res, html } = await fetchHtml(baseUrl, "/error-server-test"); + expect(res.status).toBe(200); + expect(html).toContain("Server Error Caught"); + expect(html).not.toContain("global-error"); + }); + + it("nested server component throw resolves to the nearest error.tsx boundary", async () => { + const { res, html } = await fetchHtml(baseUrl, "/error-nested-test/child"); + expect(res.status).toBe(200); + expect(html).toContain("inner-error-boundary"); + expect(html).not.toContain("outer-error-boundary"); + }); + + it("generateMetadata() errors render the co-located error.tsx boundary with 200", async () => { + const { res, html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-error-with-boundary"); + expect(res.status).toBe(200); + expect(html).toContain("Local error boundary"); + expect(html).not.toContain("global-error"); + }); + + it("generateMetadata() errors without a local boundary escalate to global-error with 200", async () => { + const { res, html } = await fetchHtml( + baseUrl, + "/nextjs-compat/metadata-error-without-boundary", + ); + expect(res.status).toBe(200); + expect(html).toContain("global-error"); + }); +}); From efefba90218fa6c1e05d7daaaa7c4c3d0bba0a79 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:55:45 +1100 Subject: [PATCH 2/3] Address global-error review feedback --- tests/nextjs-compat/TRACKING.md | 6 +++--- tests/nextjs-compat/global-error.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/nextjs-compat/TRACKING.md b/tests/nextjs-compat/TRACKING.md index 2f732ac498..675715c2d2 100644 --- a/tests/nextjs-compat/TRACKING.md +++ b/tests/nextjs-compat/TRACKING.md @@ -64,7 +64,7 @@ Ported from: https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir | ---------------- | ----- | ---- | ---- | --- | ---- | ------ | | 1. app-rendering | 8 | 6 | 2 | 0 | 0 | Done | | 2. not-found | 17 | 12 | 0 | 5 | 0 | Done | -| 3. global-error | 11 | 3 | 3 | 5 | 0 | Done | +| 3. global-error | 11 | 6 | 0 | 5 | 0 | Done | | 4. dynamic | 17 | 8 | 0 | 9 | 0 | Done | --- @@ -355,7 +355,7 @@ Three Playwright spec files cover client-side behaviors that cannot be tested vi | ------------------------ | -------- | ------- | ----- | -------- | ----- | ------------- | | 1. app-rendering | 8 | 6 | 2 | 0 | 0 | Done | | 2. not-found | 17 | 12 | 0 | 5 | 0 | Done | -| 3. global-error | 11 | 3 | 3 | 5 | 0 | Done | +| 3. global-error | 11 | 6 | 0 | 5 | 0 | Done | | 4. dynamic | 17 | 8 | 0 | 9 | 0 | Done | | 5. app-routes | 37 | 23 | 0 | 14 | 0 | Done | | 6. metadata | 45 | 30 | 0 | 15 | 0 | Done | @@ -371,7 +371,7 @@ Three Playwright spec files cover client-side behaviors that cannot be tested vi | 21. prefetch | 4 | 4 | 0 | 0 | 0 | Done | | 22. metadata-suspense | 3 | 2 | 1 | 0 | 0 | Done | | P5. shim/core unit tests | 230 | 230 | 0 | 0 | 0 | Done | -| **Total** | **555+** | **362** | **6** | **188+** | **0** | | +| **Total** | **555+** | **365** | **3** | **188+** | **0** | | ### Playwright Browser Tests diff --git a/tests/nextjs-compat/global-error.test.ts b/tests/nextjs-compat/global-error.test.ts index 06d3314e7d..05deaeaae7 100644 --- a/tests/nextjs-compat/global-error.test.ts +++ b/tests/nextjs-compat/global-error.test.ts @@ -209,6 +209,22 @@ describe("Next.js compat: global-error (production preview)", () => { fs.rmSync(outDir, { recursive: true, force: true }); }); + it("server component throw without local error.tsx renders global-error with 200", async () => { + const { res, html } = await fetchHtml(baseUrl, "/nextjs-compat/global-error-rsc"); + expect(res.status).toBe(200); + expect(html).toContain("global-error"); + expect(html).toContain("The specific message is omitted in production builds"); + expect(html).not.toContain("server page error"); + }); + + it("client component SSR throw without local error.tsx renders global-error with 200", async () => { + const { res, html } = await fetchHtml(baseUrl, "/nextjs-compat/global-error-ssr"); + expect(res.status).toBe(200); + expect(html).toContain("global-error"); + expect(html).toContain("The specific message is omitted in production builds"); + expect(html).not.toContain("client page error"); + }); + it("server component throw with local error.tsx renders that boundary with 200", async () => { const { res, html } = await fetchHtml(baseUrl, "/error-server-test"); expect(res.status).toBe(200); From a49bd19e30043442c500a14440389b4a302a6d09 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:28:13 +1100 Subject: [PATCH 3/3] docs: update tracker notes for tests #3 and #4 to reflect production preview coverage --- tests/nextjs-compat/TRACKING.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/nextjs-compat/TRACKING.md b/tests/nextjs-compat/TRACKING.md index 675715c2d2..651129056d 100644 --- a/tests/nextjs-compat/TRACKING.md +++ b/tests/nextjs-compat/TRACKING.md @@ -75,19 +75,19 @@ Ported from: https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir **Local**: `tests/nextjs-compat/global-error.test.ts` **Fixtures**: `fixtures/app-basic/app/nextjs-compat/global-error-{rsc,ssr}/`, `metadata-error-{with,without}-boundary/` -| # | Next.js Test | Vinext Status | Notes | -| --- | --------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------- | -| 1 | error-server-test: server component throw caught by error.tsx | PASS | Verified in dev + production preview: vinext renders the local `error.tsx` boundary with HTTP 200. | -| 2 | error-nested-test: nested error caught by inner error.tsx | PASS | Verified in dev + production preview: vinext resolves to the nearest matching `error.tsx`, not the parent boundary. | -| 3 | Server component throw without local error.tsx returns a response | PASS | Verified in dev: vinext renders `global-error.tsx` with HTTP 200 when no local boundary exists. | -| 4 | Client component SSR throw without local error.tsx returns a response | PASS | Verified in dev: vinext renders `global-error.tsx` with HTTP 200 for SSR-time client component throws. | -| 5 | generateMetadata() error caught by local error.tsx boundary | PASS | Verified in dev + production preview: vinext renders the co-located `error.tsx` boundary with HTTP 200. | -| 6 | generateMetadata() error without local boundary returns a response | PASS | Verified in dev + production preview: vinext escalates to `global-error.tsx` with HTTP 200 when no local boundary exists. | -| 7 | Client-side error trigger via button click -> global-error renders | N/A | Requires Playwright — client component state change triggers throw | -| 8 | Nested client error auto-thrown via useEffect -> global-error | N/A | Requires Playwright | -| 9 | Dev-only Redbox display verification | N/A | Tests Next.js-specific dev overlay format, not applicable | -| 10 | Client-side notFound() trigger from button (root) | N/A | Requires Playwright | -| 11 | Client-side notFound() trigger from button (nested) | N/A | Requires Playwright | +| # | Next.js Test | Vinext Status | Notes | +| --- | --------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------- | +| 1 | error-server-test: server component throw caught by error.tsx | PASS | Verified in dev + production preview: vinext renders the local `error.tsx` boundary with HTTP 200. | +| 2 | error-nested-test: nested error caught by inner error.tsx | PASS | Verified in dev + production preview: vinext resolves to the nearest matching `error.tsx`, not the parent boundary. | +| 3 | Server component throw without local error.tsx returns a response | PASS | Verified in dev + production preview: vinext renders `global-error.tsx` with HTTP 200 when no local boundary exists. | +| 4 | Client component SSR throw without local error.tsx returns a response | PASS | Verified in dev + production preview: vinext renders `global-error.tsx` with HTTP 200 for SSR-time client component throws. | +| 5 | generateMetadata() error caught by local error.tsx boundary | PASS | Verified in dev + production preview: vinext renders the co-located `error.tsx` boundary with HTTP 200. | +| 6 | generateMetadata() error without local boundary returns a response | PASS | Verified in dev + production preview: vinext escalates to `global-error.tsx` with HTTP 200 when no local boundary exists. | +| 7 | Client-side error trigger via button click -> global-error renders | N/A | Requires Playwright — client component state change triggers throw | +| 8 | Nested client error auto-thrown via useEffect -> global-error | N/A | Requires Playwright | +| 9 | Dev-only Redbox display verification | N/A | Tests Next.js-specific dev overlay format, not applicable | +| 10 | Client-side notFound() trigger from button (root) | N/A | Requires Playwright | +| 11 | Client-side notFound() trigger from button (nested) | N/A | Requires Playwright | **Result: 6/6 pass (HTTP-level), 0 skip, 5 N/A (browser-only, dev overlay)**