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
42 changes: 21 additions & 21 deletions tests/nextjs-compat/TRACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---
Expand All @@ -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 + 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)**

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.

This line correctly shows 6/6 pass, 0 skip. But the two summary tables elsewhere in this file were not updated:

  • Line 67 ("early snapshot" summary): still says 3 pass, 3 skip
  • Line 358 ("Overall Summary"): still says 3 pass, 3 skip
  • Line 374 (total row): still says 6 skip — should be 3

Please update all three for consistency.


### 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.

---

Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand Down
86 changes: 85 additions & 1 deletion tests/nextjs-compat/global-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -171,3 +174,84 @@ 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<ReturnType<typeof preview>>;
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 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);
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");
});
});
Loading