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 ( +
+

Invalid Page

+
+ ); +} + +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" } ] }