diff --git a/e2e/fixtures/ssr-catch-error/package.json b/e2e/fixtures/ssr-catch-error/package.json
new file mode 100644
index 000000000..d60c1aa07
--- /dev/null
+++ b/e2e/fixtures/ssr-catch-error/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "ssr-catch-error",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "waku dev",
+ "build": "waku build",
+ "start": "waku start"
+ },
+ "dependencies": {
+ "react": "19.0.0-rc-3da26163a3-20240704",
+ "react-dom": "19.0.0-rc-3da26163a3-20240704",
+ "react-error-boundary": "4.0.13",
+ "react-server-dom-webpack": "19.0.0-rc-3da26163a3-20240704",
+ "waku": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/react": "18.3.3",
+ "@types/react-dom": "18.3.0",
+ "typescript": "5.5.3"
+ }
+}
diff --git a/e2e/fixtures/ssr-catch-error/src/components/client-layout.tsx b/e2e/fixtures/ssr-catch-error/src/components/client-layout.tsx
new file mode 100644
index 000000000..62383f38b
--- /dev/null
+++ b/e2e/fixtures/ssr-catch-error/src/components/client-layout.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { ErrorBoundary } from 'react-error-boundary';
+
+const FallbackComponent = ({ error }: { error: any }) => {
+ return (
+
+
Something went wrong:
+
{error.message}
+ {error.statusCode && (
+
{error.statusCode}
+ )}
+
+ );
+};
+
+export const ClientLayout = ({ children }: { children: ReactNode }) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/e2e/fixtures/ssr-catch-error/src/middleware/validator.ts b/e2e/fixtures/ssr-catch-error/src/middleware/validator.ts
new file mode 100644
index 000000000..aff19f49b
--- /dev/null
+++ b/e2e/fixtures/ssr-catch-error/src/middleware/validator.ts
@@ -0,0 +1,30 @@
+import type { Middleware } from 'waku/config';
+import wakuConfig from '../../waku.config.js';
+
+const { rscPath } = wakuConfig;
+
+const stringToStream = (str: string): ReadableStream => {
+ const encoder = new TextEncoder();
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(str));
+ controller.close();
+ },
+ });
+};
+
+const validateMiddleware: Middleware = () => {
+ return async (ctx, next) => {
+ if (
+ ctx.req.url.pathname === '/invalid' ||
+ ctx.req.url.pathname.startsWith(`/${rscPath}/invalid`)
+ ) {
+ ctx.res.status = 401;
+ ctx.res.body = stringToStream('Unauthorized');
+ return;
+ }
+ await next();
+ };
+};
+
+export default validateMiddleware;
diff --git a/e2e/fixtures/ssr-catch-error/src/pages/_layout.tsx b/e2e/fixtures/ssr-catch-error/src/pages/_layout.tsx
new file mode 100644
index 000000000..5bfcc5bff
--- /dev/null
+++ b/e2e/fixtures/ssr-catch-error/src/pages/_layout.tsx
@@ -0,0 +1,16 @@
+import type { ReactNode } from 'react';
+import { ClientLayout } from '../components/client-layout.js';
+
+export default async function RootLayout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return {children};
+}
+
+export const getConfig = async () => {
+ return {
+ render: 'static',
+ };
+};
diff --git a/e2e/fixtures/ssr-catch-error/src/pages/index.tsx b/e2e/fixtures/ssr-catch-error/src/pages/index.tsx
new file mode 100644
index 000000000..86a3b5050
--- /dev/null
+++ b/e2e/fixtures/ssr-catch-error/src/pages/index.tsx
@@ -0,0 +1,16 @@
+import { Link } from 'waku';
+
+export default async function HomePage() {
+ return (
+
+
Home Page
+
Invalid page
+
+ );
+}
+
+export const getConfig = async () => {
+ return {
+ render: 'static',
+ };
+};
diff --git a/e2e/fixtures/ssr-catch-error/src/pages/invalid.tsx b/e2e/fixtures/ssr-catch-error/src/pages/invalid.tsx
new file mode 100644
index 000000000..f1bda111c
--- /dev/null
+++ b/e2e/fixtures/ssr-catch-error/src/pages/invalid.tsx
@@ -0,0 +1,13 @@
+export default async function InvalidPage() {
+ return (
+
+ );
+}
+
+export const getConfig = async () => {
+ return {
+ render: 'dynamic',
+ };
+};
diff --git a/e2e/fixtures/ssr-catch-error/tsconfig.json b/e2e/fixtures/ssr-catch-error/tsconfig.json
new file mode 100644
index 000000000..4e334be01
--- /dev/null
+++ b/e2e/fixtures/ssr-catch-error/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "strict": true,
+ "target": "esnext",
+ "downlevelIteration": true,
+ "esModuleInterop": true,
+ "module": "nodenext",
+ "skipLibCheck": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "types": ["react/experimental"],
+ "jsx": "react-jsx",
+ "outDir": "./dist"
+ },
+ "include": ["./src", "./waku.config.ts"]
+}
diff --git a/e2e/fixtures/ssr-catch-error/waku.config.ts b/e2e/fixtures/ssr-catch-error/waku.config.ts
new file mode 100644
index 000000000..bbbe80d2f
--- /dev/null
+++ b/e2e/fixtures/ssr-catch-error/waku.config.ts
@@ -0,0 +1,22 @@
+const DO_NOT_BUNDLE = '';
+
+/** @type {import('waku/config').Config} */
+export default {
+ middleware: (cmd: 'dev' | 'start') => [
+ ...(cmd === 'dev'
+ ? [
+ import(
+ /* @vite-ignore */ DO_NOT_BUNDLE + 'waku/middleware/dev-server'
+ ),
+ ]
+ : []),
+ import('./src/middleware/validator.js'),
+ import('waku/middleware/rsc'),
+ import('waku/middleware/fallback'),
+ ],
+ /**
+ * Prefix for HTTP requests to indicate RSC requests.
+ * Defaults to "RSC".
+ */
+ rscPath: 'RSC', // Just for clarification in tests
+};
diff --git a/e2e/ssr-catch-error.spec.ts b/e2e/ssr-catch-error.spec.ts
new file mode 100644
index 000000000..a201e70af
--- /dev/null
+++ b/e2e/ssr-catch-error.spec.ts
@@ -0,0 +1,69 @@
+import { expect } from '@playwright/test';
+import { execSync, exec, ChildProcess } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import waitPort from 'wait-port';
+import { debugChildProcess, getFreePort, terminate, test } from './utils.js';
+import { rm } from 'node:fs/promises';
+
+const waku = fileURLToPath(
+ new URL('../packages/waku/dist/cli.js', import.meta.url),
+);
+
+const commands = [
+ {
+ command: 'dev',
+ },
+ {
+ build: 'build',
+ command: 'start',
+ },
+];
+
+const cwd = fileURLToPath(
+ new URL('./fixtures/ssr-catch-error', import.meta.url),
+);
+
+for (const { build, command } of commands) {
+ test.describe(`ssr-catch-error: ${command}`, () => {
+ let cp: ChildProcess;
+ let port: number;
+ test.beforeAll('remove cache', async () => {
+ await rm(`${cwd}/dist`, {
+ recursive: true,
+ force: true,
+ });
+ });
+
+ test.beforeAll(async () => {
+ if (build) {
+ execSync(`node ${waku} ${build}`, { cwd });
+ }
+ port = await getFreePort();
+ cp = exec(`node ${waku} ${command} --port ${port}`, { cwd });
+ debugChildProcess(cp, fileURLToPath(import.meta.url), [
+ /ExperimentalWarning: Custom ESM Loaders is an experimental feature and might change at any time/,
+ ]);
+ await waitPort({ port });
+ });
+
+ test.afterAll(async () => {
+ await terminate(cp.pid!);
+ });
+
+ test('access top page', async ({ page }) => {
+ await page.goto(`http://localhost:${port}/`);
+ await expect(page.getByText('Home Page')).toBeVisible();
+ });
+
+ test('access invalid page through client router', async ({ page }) => {
+ await page.goto(`http://localhost:${port}/`);
+ await page.getByText('Invalid page').click();
+ await expect(page.getByText('401')).toBeVisible();
+ });
+
+ test('access invalid page directly', async ({ page }) => {
+ await page.goto(`http://localhost:${port}/invalid`);
+ await expect(page.getByText('Unauthorized')).toBeVisible();
+ });
+ });
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fba88aa91..5eb141cb9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -281,6 +281,34 @@ importers:
specifier: 5.5.3
version: 5.5.3
+ e2e/fixtures/ssr-catch-error:
+ dependencies:
+ react:
+ specifier: 19.0.0-rc-3da26163a3-20240704
+ version: 19.0.0-rc-3da26163a3-20240704
+ react-dom:
+ specifier: 19.0.0-rc-3da26163a3-20240704
+ version: 19.0.0-rc-3da26163a3-20240704(react@19.0.0-rc-3da26163a3-20240704)
+ react-error-boundary:
+ specifier: 4.0.13
+ version: 4.0.13(react@19.0.0-rc-3da26163a3-20240704)
+ react-server-dom-webpack:
+ specifier: 19.0.0-rc-3da26163a3-20240704
+ version: 19.0.0-rc-3da26163a3-20240704(react-dom@19.0.0-rc-3da26163a3-20240704(react@19.0.0-rc-3da26163a3-20240704))(react@19.0.0-rc-3da26163a3-20240704)(webpack@5.92.1)
+ waku:
+ specifier: workspace:*
+ version: link:../../../packages/waku
+ devDependencies:
+ '@types/react':
+ specifier: 18.3.3
+ version: 18.3.3
+ '@types/react-dom':
+ specifier: 18.3.0
+ version: 18.3.0
+ typescript:
+ specifier: 5.5.3
+ version: 5.5.3
+
e2e/fixtures/ssr-context-provider:
dependencies:
react:
@@ -3993,6 +4021,11 @@ packages:
peerDependencies:
react: 19.0.0-rc-3da26163a3-20240704
+ react-error-boundary@4.0.13:
+ resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==}
+ peerDependencies:
+ react: 19.0.0-rc-3da26163a3-20240704
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -6452,7 +6485,7 @@ snapshots:
date-fns@2.30.0:
dependencies:
- '@babel/runtime': 7.23.9
+ '@babel/runtime': 7.24.7
debug@2.6.9:
dependencies:
@@ -8266,6 +8299,11 @@ snapshots:
react: 19.0.0-rc-3da26163a3-20240704
scheduler: 0.25.0-rc-3da26163a3-20240704
+ react-error-boundary@4.0.13(react@19.0.0-rc-3da26163a3-20240704):
+ dependencies:
+ '@babel/runtime': 7.24.7
+ react: 19.0.0-rc-3da26163a3-20240704
+
react-is@16.13.1: {}
react-is@18.3.1: {}
diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json
index 5a1d7896d..f48c53897 100644
--- a/tsconfig.e2e.json
+++ b/tsconfig.e2e.json
@@ -38,6 +38,9 @@
},
{
"path": "./e2e/fixtures/partial-build/tsconfig.json"
+ },
+ {
+ "path": "./e2e/fixtures/ssr-catch-error/tsconfig.json"
}
]
}