diff --git a/integration/middleware-test.ts b/integration/middleware-test.ts index 1227f97ca0..7d9738e27b 100644 --- a/integration/middleware-test.ts +++ b/integration/middleware-test.ts @@ -10,6 +10,8 @@ import { } from "react-router"; import { + type AppFixture, + type Fixture, createAppFixture, createFixture, js, @@ -17,75 +19,8 @@ import { import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; import { reactRouterConfig } from "./helpers/vite.js"; -let ENTRY_SERVER_WITH_HANDLE_ERROR = js` -import { PassThrough } from "node:stream"; - -import type { AppLoadContext, EntryContext } from "react-router"; -import { createReadableStreamFromReadable } from "@react-router/node"; -import { ServerRouter } from "react-router"; -import type { RenderToPipeableStreamOptions } from "react-dom/server"; -import { renderToPipeableStream } from "react-dom/server"; - -export const streamTimeout = 5_000; - -export function handleError(error, { request }) { - if (!request.signal.aborted) { - let {pathname, search} = new URL(request.url); - console.error("handleError", request.method, pathname + search, error); - } -} - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - routerContext: EntryContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - - const { pipe, abort } = renderToPipeableStream( - , - { - onShellReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - }, - ); - - setTimeout(abort, streamTimeout + 1000); - }); -} - `; - test.describe("Middleware", () => { let originalConsoleError = console.error; - test.beforeEach(() => { console.error = () => {}; }); @@ -95,10 +30,14 @@ test.describe("Middleware", () => { }); test.describe("SPA Mode", () => { - test("calls clientMiddleware before/after loaders", async ({ page }) => { - let fixture = await createFixture({ + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ spaMode: true, files: { + // ...existing code... "react-router.config.ts": reactRouterConfig({ ssr: false, v8_middleware: true, @@ -106,20 +45,15 @@ test.describe("Middleware", () => { "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); + export default defineConfig({ build: { manifest: true, minify: false }, plugins: [reactRouter()] }); `, "app/context.ts": js` import { createContext } from 'react-router' export const orderContext = createContext([]); `, - "app/routes/_index.tsx": js` + "app/routes/loaders._index.tsx": js` import { Link } from 'react-router' import { orderContext } from '../context' - export const clientMiddleware = [ ({ context }) => { context.set(orderContext, [...context.get(orderContext), 'a']); @@ -128,23 +62,20 @@ test.describe("Middleware", () => { context.set(orderContext, [...context.get(orderContext), 'b']); }, ]; - export async function clientLoader({ request, context }) { return context.get(orderContext).join(','); } - export default function Component({ loaderData }) { return ( <>

Index: {loaderData}

- Go to about + Go to about ); } `, - "app/routes/about.tsx": js` + "app/routes/loaders.about.tsx": js` import { orderContext } from '../context' - export const clientMiddleware = [ ({ context }) => { context.set(orderContext, [...context.get(orderContext), 'c']); @@ -153,40 +84,286 @@ test.describe("Middleware", () => { context.set(orderContext, [...context.get(orderContext), 'd']); }, ]; - export async function clientLoader({ context }) { return context.get(orderContext).join(','); } - export default function Component({ loaderData }) { return

About: {loaderData}

; } `, + "app/routes/actions._index.tsx": js` + import { Form } from 'react-router' + import { orderContext } from '../context'; + export const clientMiddleware = [ + ({ request, context }) => { + context.set(orderContext, ['a']); + }, + ({ request, context }) => { + context.set(orderContext, [...context.get(orderContext), 'b']); + }, + ]; + export async function clientAction({ request, context }) { + return context.get(orderContext).join(','); + } + export async function clientLoader({ request, context }) { + return context.get(orderContext).join(','); + } + export default function Component({ loaderData, actionData }) { + return ( + <> +

Index: {loaderData} - {actionData || 'empty'}

+
+ + +
+ + ); + } + `, + "app/routes/redirect-down._index.tsx": js` + import { Link } from 'react-router' + export default function Component({ loaderData, actionData }) { + return Link; + } + `, + "app/routes/redirect-down.redirect.tsx": js` + import { Link, redirect } from 'react-router' + export const clientMiddleware = [ + ({ request, context }) => { throw redirect('/redirect-down/target'); } + ] + export default function Component() { + return

Redirect

+ } + `, + "app/routes/redirect-down.target.tsx": js` + export default function Component() { + return

Target

+ } + `, + "app/routes/redirect-up._index.tsx": js` + import { Link } from 'react-router'; + + export default function Component() { + return Link; + } + `, + "app/routes/redirect-up.redirect.tsx": js` + import { Link, redirect } from 'react-router'; + + export const clientMiddleware = [ + async ({ request, context }, next) => { + await next(); + throw redirect('/redirect-up/target'); + } + ]; + + export default function Component() { + return

Redirect

; + } + `, + "app/routes/redirect-up.target.tsx": js` + export default function Component() { + return

Target

; + } + `, + "app/routes/error-down._index.tsx": js` + import { Link } from 'react-router'; + + export default function Component() { + return Link; + } + `, + "app/routes/error-down.broken.tsx": js` + export const clientMiddleware = [ + async ({ request, context }, next) => { + throw new Error('broken!'); + } + ]; + + export default function Component() { + return

Should not see me

; + } + + export function ErrorBoundary({ error }) { + return

{error.message}

; + } + `, + "app/routes/error-up._index.tsx": js` + import { Link } from 'react-router'; + + export default function Component() { + return Link; + } + `, + "app/routes/error-up.broken.tsx": js` + export const clientMiddleware = [ + async ({ request, context }, next) => { + await next(); + throw new Error('broken!'); + } + ]; + + export function clientLoader() { + return "nope"; + } + + export default function Component() { + return

Should not see me

; + } + + export function ErrorBoundary({ loaderData, error }) { + return ( + <> +

{error.message}

+
{loaderData ?? 'empty'}
+ + ); + } + `, + "app/routes/middleware-chain._index.tsx": js` + import { Link } from 'react-router' + export default function Component({ loaderData }) { + return Link; + } + `, + "app/routes/middleware-chain.a.tsx": js` + import { Outlet } from 'react-router' + import { orderContext } from '../context'; + export const clientMiddleware = [ + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'a']); + }, + ]; + + export function clientLoader({ context }) { + return context.get(orderContext).join(','); + } + + export default function Component({ loaderData }) { + return <>

A: {loaderData}

; + } + `, + "app/routes/middleware-chain.a.b.tsx": js` + import { Outlet } from 'react-router' + import { orderContext } from '../context'; + export const clientMiddleware = [ + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'b']); + } + ]; + + export default function Component() { + return <>

B

; + } + `, + "app/routes/middleware-chain.a.b.c.tsx": js` + import { orderContext } from '../context'; + export function clientLoader({ context }) { + return context.get(orderContext).join(','); + } + + export default function Component({ loaderData }) { + return

C: {loaderData}

; + } + `, }, }); + appFixture = await createAppFixture(fixture); + }); - let appFixture = await createAppFixture(fixture); + test.afterAll(() => { + appFixture.close(); + }); + test("calls clientMiddleware before/after loaders", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); + await app.goto("/loaders"); await page.waitForSelector('[data-route]:has-text("Index")'); expect(await page.locator("[data-route]").textContent()).toBe( "Index: a,b", ); - (await page.$('a[href="/about"]'))?.click(); + (await page.$('a[href="/loaders/about"]'))?.click(); await page.waitForSelector('[data-route]:has-text("About")'); expect(await page.locator("[data-route]").textContent()).toBe( "About: c,d", ); + }); - appFixture.close(); + test("calls clientMiddleware before/after actions", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/actions"); + await page.waitForSelector('[data-route]:has-text("Index")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "Index: a,b - empty", + ); + + (await page.getByRole("button"))?.click(); + await new Promise((r) => setTimeout(r, 1000)); + await page.waitForSelector('[data-route]:has-text("- a,b")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "Index: a,b - a,b", + ); }); - test("calls clientMiddleware before/after loaders with split route modules", async ({ + test("handles redirects thrown on the way down", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect-down"); + await page.waitForSelector('a:has-text("Link")'); + + (await page.getByRole("link"))?.click(); + await page.waitForSelector('h1:has-text("Target")'); + }); + + test("handles redirects thrown on the way up", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect-up"); + await page.waitForSelector('a:has-text("Link")'); + + (await page.getByRole("link"))?.click(); + await page.waitForSelector('h1:has-text("Target")'); + }); + + test("handles errors thrown on the way down", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/error-down"); + await page.waitForSelector('a:has-text("Link")'); + + (await page.getByRole("link"))?.click(); + await page.waitForSelector("[data-error]"); + expect(await page.innerText("[data-error]")).toBe("broken!"); + }); + + test("handles errors thrown on the way up", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/error-up"); + await page.waitForSelector('a:has-text("Link")'); + + (await page.getByRole("link"))?.click(); + await page.waitForSelector("[data-error]"); + expect(await page.innerText("[data-error]")).toBe("broken!"); + expect(await page.innerText("pre")).toBe("empty"); + }); + + test("calls clientMiddleware for routes even without a loader", async ({ page, }) => { - let fixture = await createFixture({ + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/middleware-chain", true); + (await page.$('a[href="/middleware-chain/a/b/c"]'))?.click(); + await page.waitForSelector("h4"); + expect(await page.innerText("h2")).toBe("A: a,b"); + expect(await page.innerText("h3")).toBe("B"); + expect(await page.innerText("h4")).toBe("C: a,b"); + }); + }); + + test.describe("SPA Mode + Split Route Modules", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ spaMode: true, files: { "react-router.config.ts": reactRouterConfig({ @@ -207,7 +384,7 @@ test.describe("Middleware", () => { import { createContext } from 'react-router' export const orderContext = createContext([]); `, - "app/routes/_index.tsx": js` + "app/routes/loaders._index.tsx": js` import { Link } from 'react-router' import { orderContext } from '../context' @@ -228,12 +405,12 @@ test.describe("Middleware", () => { return ( <>

Index: {loaderData}

- Go to about + Go to about ); } `, - "app/routes/about.tsx": js` + "app/routes/loaders.about.tsx": js` import { orderContext } from '../context' export const clientMiddleware = [ @@ -256,66 +433,111 @@ test.describe("Middleware", () => { }, }); - let appFixture = await createAppFixture(fixture); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + test("calls clientMiddleware before/after loaders with split route modules", async ({ + page, + }) => { let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); + await app.goto("/loaders"); await page.waitForSelector('[data-route]:has-text("Index")'); expect(await page.locator("[data-route]").textContent()).toBe( "Index: a,b", ); - (await page.$('a[href="/about"]'))?.click(); + (await page.$('a[href="/loaders/about"]'))?.click(); await page.waitForSelector('[data-route]:has-text("About")'); expect(await page.locator("[data-route]").textContent()).toBe( "About: c,d", ); - - appFixture.close(); }); + }); - test("calls clientMiddleware before/after actions", async ({ page }) => { - let fixture = await createFixture({ - spaMode: true, + test.describe("Client Middleware", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ files: { "react-router.config.ts": reactRouterConfig({ - ssr: false, v8_middleware: true, }), "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true }, + build: { manifest: true, minify: false }, plugins: [reactRouter()], }); `, "app/context.ts": js` import { createContext } from 'react-router' export const orderContext = createContext([]); + export const countContext = createContext({ parent: 0, child: 0, index: 0 }); `, - "app/routes/_index.tsx": js` - import { Form } from 'react-router' - import { orderContext } from '../context'; - + "app/routes/loaders._index.tsx": js` + import { Link } from 'react-router' + import { orderContext } from '../context' export const clientMiddleware = [ - ({ request, context }) => { + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'a']); + }, + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'b']); + }, + ]; + export async function clientLoader({ request, context }) { + return context.get(orderContext).join(','); + } + export default function Component({ loaderData }) { + return ( + <> +

Index: {loaderData}

+ Go to about + + ); + } + `, + "app/routes/loaders.about.tsx": js` + import { orderContext } from '../context' + export const clientMiddleware = [ + ({ context }) => { + context.set(orderContext, ['c']); + }, + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'd']); + }, + ]; + export async function clientLoader({ context }) { + return context.get(orderContext).join(','); + } + export default function Component({ loaderData }) { + return

About: {loaderData}

; + } + `, + "app/routes/actions._index.tsx": js` + import { Form } from 'react-router' + import { orderContext } from '../context'; + export const clientMiddleware = [ + ({ context }) => { context.set(orderContext, ['a']); }, - ({ request, context }) => { + ({ context }) => { context.set(orderContext, [...context.get(orderContext), 'b']); }, ]; - export async function clientAction({ request, context }) { return context.get(orderContext).join(','); } - export async function clientLoader({ request, context }) { return context.get(orderContext).join(','); } - export default function Component({ loaderData, actionData }) { return ( <> @@ -325,90 +547,232 @@ test.describe("Middleware", () => { - ); + ); + } + `, + "app/routes/redirect-down._index.tsx": js` + import { Link } from 'react-router' + export default function Component() { + return Link; + } + `, + "app/routes/redirect-down.redirect.tsx": js` + import { Link, redirect } from 'react-router' + export const clientMiddleware = [ + ({ request, context }) => { throw redirect('/redirect-down/target'); } + ]; + export default function Component() { + return

Redirect

; + } + `, + "app/routes/redirect-down.target.tsx": js` + export default function Component() { + return

Target

; + } + `, + "app/routes/redirect-up._index.tsx": js` + import { Link } from 'react-router'; + export default function Component() { + return Link; + } + `, + "app/routes/redirect-up.redirect.tsx": js` + import { Link, redirect } from 'react-router'; + export const clientMiddleware = [ + async ({ request, context }, next) => { + await next(); + throw redirect('/redirect-up/target'); + } + ]; + export default function Component() { + return

Redirect

; + } + `, + "app/routes/redirect-up.target.tsx": js` + export default function Component() { + return

Target

; + } + `, + "app/routes/error-down._index.tsx": js` + import { Link } from 'react-router'; + export default function Component() { + return Link; + } + `, + "app/routes/error-down.broken.tsx": js` + export const clientMiddleware = [ + async ({ request, context }, next) => { + throw new Error('broken!'); + } + ]; + export default function Component() { + return

Should not see me

; + } + export function ErrorBoundary({ error }) { + return

{error.message}

; + } + `, + "app/routes/error-up._index.tsx": js` + import { Link } from 'react-router'; + export default function Component() { + return Link; + } + `, + "app/routes/error-up.broken.tsx": js` + export const clientMiddleware = [ + async ({ request, context }, next) => { + await next(); + throw new Error('broken!'); + } + ]; + export function clientLoader() { + return "nope"; + } + export default function Component() { + return

Should not see me

; + } + export function ErrorBoundary({ loaderData, error }) { + return ( + <> +

{error.message}

+
{loaderData ?? 'empty'}
+ + ); + } + `, + "app/routes/middleware-chain._index.tsx": js` + import { Link } from 'react-router' + export default function Component({ loaderData }) { + return Link; + } + `, + "app/routes/middleware-chain.a.tsx": js` + import { Outlet } from 'react-router' + import { orderContext } from '../context'; + export const clientMiddleware = [ + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'a']); + }, + ]; + export function clientLoader({ context }) { + return context.get(orderContext).join(','); + } + export default function Component({ loaderData }) { + return ( + <> +

A: {loaderData}

+ + + ); + } + `, + "app/routes/middleware-chain.a.b.tsx": js` + import { Outlet } from 'react-router' + import { orderContext } from '../context'; + export const clientMiddleware = [ + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'b']); + }, + ]; + export default function Component() { + return ( + <> +

B

+ + + ); + } + `, + "app/routes/middleware-chain.a.b.c.tsx": js` + import { orderContext } from '../context'; + export function clientLoader({ context }) { + return context.get(orderContext).join(','); + } + export default function Component({ loaderData }) { + return

C: {loaderData}

; } `, }, }); + appFixture = await createAppFixture(fixture); + }); - let appFixture = await createAppFixture(fixture); + test.afterAll(() => { + appFixture.close(); + }); + test("calls clientMiddleware before/after loaders", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); + await app.goto("/loaders"); await page.waitForSelector('[data-route]:has-text("Index")'); expect(await page.locator("[data-route]").textContent()).toBe( - "Index: a,b - empty", + "Index: a,b", ); - (await page.getByRole("button"))?.click(); - await new Promise((r) => setTimeout(r, 1000)); - await page.waitForSelector('[data-route]:has-text("- a,b")'); + (await page.$('a[href="/loaders/about"]'))?.click(); + await page.waitForSelector('[data-route]:has-text("About")'); expect(await page.locator("[data-route]").textContent()).toBe( - "Index: a,b - a,b", + "About: c,d", ); - - appFixture.close(); }); test("handles redirects thrown on the way down", async ({ page }) => { - let fixture = await createFixture({ - spaMode: true, - files: { - "react-router.config.ts": reactRouterConfig({ - ssr: false, - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect-down"); + await page.waitForSelector('a:has-text("Link")'); - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' + (await page.getByRole("link"))?.click(); + await page.waitForSelector('h1:has-text("Target")'); + }); - export default function Component({ loaderData, actionData }) { - return Link; - } - `, - "app/routes/redirect.tsx": js` - import { Link, redirect } from 'react-router' - export const clientMiddleware = [ - ({ request, context }) => { throw redirect('/target'); } - ] - export default function Component() { - return

Redirect

- } - `, - "app/routes/target.tsx": js` - export default function Component() { - return

Target

- } - `, - }, - }); + test("handles redirects thrown on the way up", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect-up"); + await page.waitForSelector('a:has-text("Link")'); - let appFixture = await createAppFixture(fixture); + (await page.getByRole("link"))?.click(); + await page.waitForSelector('h1:has-text("Target")'); + }); + test("handles errors thrown on the way down", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); + await app.goto("/error-down"); await page.waitForSelector('a:has-text("Link")'); (await page.getByRole("link"))?.click(); - await page.waitForSelector('h1:has-text("Target")'); + await page.waitForSelector("[data-error]"); + expect(await page.innerText("[data-error]")).toBe("broken!"); + }); - appFixture.close(); + test("handles errors thrown on the way up", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/error-up"); + await page.waitForSelector('a:has-text("Link")'); + + (await page.getByRole("link"))?.click(); + await page.waitForSelector("[data-error]"); + expect(await page.innerText("[data-error]")).toBe("broken!"); + expect(await page.innerText("pre")).toBe("empty"); }); - test("handles redirects thrown on the way up", async ({ page }) => { + test("calls clientMiddleware for routes even without a loader", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/middleware-chain"); + (await page.$('a[href="/middleware-chain/a/b/c"]'))?.click(); + await page.waitForSelector("h4"); + expect(await page.innerText("h2")).toBe("A: a,b"); + expect(await page.innerText("h3")).toBe("B"); + expect(await page.innerText("h4")).toBe("C: a,b"); + }); + + test("calls clientMiddleware once when multiple server requests happen", async ({ + page, + }) => { let fixture = await createFixture({ - spaMode: true, files: { "react-router.config.ts": reactRouterConfig({ - ssr: false, v8_middleware: true, }), "vite.config.ts": js` @@ -416,32 +780,67 @@ test.describe("Middleware", () => { import { reactRouter } from "@react-router/dev/vite"; export default defineConfig({ - build: { manifest: true }, + build: { manifest: true, minify: false }, plugins: [reactRouter()], }); `, + "app/context.ts": js` + import { createContext } from 'react-router' + export const countContext = createContext({ + parent: 0, + child: 0, + }); + `, "app/routes/_index.tsx": js` import { Link } from 'react-router' - - export default function Component({ loaderData, actionData }) { - return Link; + export default function Component({ loaderData }) { + return Go to /parent/child; } `, - "app/routes/redirect.tsx": js` - import { Link, redirect } from 'react-router' + "app/routes/parent.tsx": js` + import { countContext } from '../context'; + import { Outlet } from 'react-router'; + export function loader() { + return 'PARENT' + } export const clientMiddleware = [ - async ({ request, context }, next) => { - await next(); - throw redirect('/target'); + ({ context }) => { context.get(countContext).parent++ }, + ]; + + export async function clientLoader({ serverLoader, context }) { + return { + serverData: await serverLoader(), + context: context.get(countContext) } - ] - export default function Component() { - return

Redirect

+ } + + export default function Component({ loaderData }) { + return ( + <> +

{JSON.stringify(loaderData)}

+ + + ); } `, - "app/routes/target.tsx": js` - export default function Component() { - return

Target

+ "app/routes/parent.child.tsx": js` + import { countContext } from '../context'; + export function loader() { + return 'CHILD' + } + export const clientMiddleware = [ + ({ context }) => { context.get(countContext).child++ }, + ]; + + export async function clientLoader({ serverLoader, context }) { + return { + serverData: await serverLoader(), + context: context.get(countContext) + } + } + + export default function Component({ loaderData }) { + return

{JSON.stringify(loaderData)}

; } `, }, @@ -449,152 +848,53 @@ test.describe("Middleware", () => { let appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('a:has-text("Link")'); - - (await page.getByRole("link"))?.click(); - await page.waitForSelector('h1:has-text("Target")'); - - appFixture.close(); - }); - - test("handles errors thrown on the way down", async ({ page }) => { - let fixture = await createFixture( - { - spaMode: true, - files: { - "react-router.config.ts": reactRouterConfig({ - ssr: false, - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - - export default function Component() { - return Link; - } - `, - "app/routes/broken.tsx": js` - export const clientMiddleware = [ - async ({ request, context }, next) => { - throw new Error('broken!'); - } - ] - export default function Component() { - return

Should not see me

- } - export function ErrorBoundary({ error }) { - return

{error.message}

- } - `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); + let requests: string[] = []; + page.on("request", (request: PlaywrightRequest) => { + if (request.url().includes(".data")) { + requests.push(request.url()); + } + }); let app = new PlaywrightFixture(appFixture, page); await app.goto("/"); - await page.waitForSelector('a:has-text("Link")'); - - (await page.getByRole("link"))?.click(); - await page.waitForSelector("[data-error]"); - expect(await page.innerText("[data-error]")).toBe("broken!"); - - appFixture.close(); - }); - - test("handles errors thrown on the way up", async ({ page }) => { - let fixture = await createFixture( - { - spaMode: true, - files: { - "react-router.config.ts": reactRouterConfig({ - ssr: false, - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + (await page.$('a[href="/parent/child"]'))?.click(); + await page.waitForSelector("[data-child]"); - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' + // 2 separate server requests made + expect(requests.sort()).toEqual([ + expect.stringContaining("/parent/child.data?_routes=routes%2Fparent"), + expect.stringContaining( + "/parent/child.data?_routes=routes%2Fparent.child", + ), + ]); - export default function Component() { - return Link; - } - `, - "app/routes/broken.tsx": js` - export const clientMiddleware = [ - async ({ request, context }, next) => { - await next(); - throw new Error('broken!'); - } - ] - export function clientLoader() { - return "nope" - } - export default function Component() { - return

Should not see me

- } - export function ErrorBoundary({ loaderData, error }) { - return ( - <> -

{error.message}

-
{loaderData ?? 'empty'}
- - ); - } - `, - }, + // But client middlewares only ran once + let json = (await page.locator("[data-parent]").textContent()) as string; + expect(JSON.parse(json)).toEqual({ + serverData: "PARENT", + context: { + parent: 1, + child: 1, }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('a:has-text("Link")'); - - (await page.getByRole("link"))?.click(); - await page.waitForSelector("[data-error]"); - expect(await page.innerText("[data-error]")).toBe("broken!"); - expect(await page.innerText("pre")).toBe("empty"); + }); + json = (await page.locator("[data-child]").textContent()) as string; + expect(JSON.parse(json)).toEqual({ + serverData: "CHILD", + context: { + parent: 1, + child: 1, + }, + }); appFixture.close(); }); - test("calls clientMiddleware for routes even without a loader", async ({ + test("calls clientMiddleware once when multiple server requests happen and some routes opt out", async ({ page, }) => { let fixture = await createFixture({ - spaMode: true, files: { "react-router.config.ts": reactRouterConfig({ - ssr: false, v8_middleware: true, }), "vite.config.ts": js` @@ -608,77 +908,160 @@ test.describe("Middleware", () => { `, "app/context.ts": js` import { createContext } from 'react-router' - export const orderContext = createContext([]); + export const countContext = createContext({ + parent: 0, + child: 0, + index: 0, + }); `, "app/routes/_index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return Link; + return Go to /parent/child; } `, - "app/routes/a.tsx": js` - import { Outlet } from 'react-router' - import { orderContext } from '../context'; + "app/routes/parent.tsx": js` + import { Outlet } from 'react-router'; + import { countContext } from '../context'; + export function loader() { + return 'PARENT' + } export const clientMiddleware = [ - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'a']); - }, + ({ context }) => { context.get(countContext).parent++ }, ]; - - export function clientLoader({ context }) { - return context.get(orderContext).join(','); - } - export default function Component({ loaderData }) { - return <>

A: {loaderData}

; + return ( + <> +

{loaderData}

+ + + ); } - `, - "app/routes/a.b.tsx": js` - import { Outlet } from 'react-router' - import { orderContext } from '../context'; - export const clientMiddleware = [ - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'b']); - } - ]; - - export default function Component() { - return <>

B

; + export function shouldRevalidate() { + return false; } `, - "app/routes/a.b.c.tsx": js` - import { orderContext } from '../context'; - export function clientLoader({ context }) { - return context.get(orderContext).join(','); + "app/routes/parent.child.tsx": js` + import { Outlet } from 'react-router'; + import { countContext } from '../context'; + export function loader() { + return 'CHILD' } - + export const clientMiddleware = [ + ({ context }) => { context.get(countContext).child++ }, + ]; export default function Component({ loaderData }) { - return

C: {loaderData}

; + return ( + <> +

{loaderData}

+ + + ); } `, - }, - }); + "app/routes/parent.child._index.tsx": js` + import { Form } from 'react-router'; + import { countContext } from '../context'; + export function action() { + return 'INDEX ACTION' + } + export function loader() { + return 'INDEX' + } + export const clientMiddleware = [ + ({ context }) => { context.get(countContext).index++ }, + ]; + export async function clientLoader({ serverLoader, context }) { + return { + serverData: await serverLoader(), + context: context.get(countContext) + } + } + export default function Component({ loaderData, actionData }) { + return ( + <> +

{JSON.stringify(loaderData)}

+
+ +
+ {actionData ?

{JSON.stringify(actionData)}

: null} + + ); + } + `, + }, + }); let appFixture = await createAppFixture(fixture); + let requests: string[] = []; + page.on("request", (request: PlaywrightRequest) => { + if (request.method() === "GET" && request.url().includes(".data")) { + requests.push(request.url()); + } + }); + let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - (await page.$('a[href="/a/b/c"]'))?.click(); - await page.waitForSelector("h4"); - expect(await page.innerText("h2")).toBe("A: a,b"); - expect(await page.innerText("h3")).toBe("B"); - expect(await page.innerText("h4")).toBe("C: a,b"); + await app.goto("/"); + (await page.$('a[href="/parent/child"]'))?.click(); + await page.waitForSelector("[data-child]"); + expect(await page.locator("[data-parent]").textContent()).toBe("PARENT"); + expect(await page.locator("[data-child]").textContent()).toBe("CHILD"); + expect( + JSON.parse((await page.locator("[data-index]").textContent())!), + ).toEqual({ + serverData: "INDEX", + context: { + parent: 1, + child: 1, + index: 1, + }, + }); + + requests = []; // clear before form submission + (await page.$('button[type="submit"]'))?.click(); + await page.waitForSelector("[data-action]"); + + // 2 separate server requests made + expect(requests.sort()).toEqual([ + // This is the normal request but only included parent.child because parent opted out + expect.stringMatching( + /\/parent\/child\.data\?_routes=routes%2Fparent\.child$/, + ), + // index gets it's own due to clientLoader + expect.stringMatching( + /\/parent\/child\.data\?_routes=routes%2Fparent\.child\._index$/, + ), + ]); + + // But client middlewares only ran once for the action and once for the revalidation + expect(await page.locator("[data-parent]").textContent()).toBe("PARENT"); + expect(await page.locator("[data-child]").textContent()).toBe("CHILD"); + expect( + JSON.parse((await page.locator("[data-index]").textContent())!), + ).toEqual({ + serverData: "INDEX", + context: { + parent: 3, + child: 3, + index: 3, + }, + }); appFixture.close(); }); }); - test.describe("Client Middleware", () => { - test("calls clientMiddleware before/after loaders", async ({ page }) => { - let fixture = await createFixture({ + test.describe("Client Middleware + Split Route Modules", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ files: { "react-router.config.ts": reactRouterConfig({ v8_middleware: true, + splitRouteModules: true, }), "vite.config.ts": js` import { defineConfig } from "vite"; @@ -693,7 +1076,7 @@ test.describe("Middleware", () => { import { createContext } from 'react-router' export const orderContext = createContext([]); `, - "app/routes/_index.tsx": js` + "app/routes/loaders._index.tsx": js` import { Link } from 'react-router' import { orderContext } from "../context";; @@ -714,12 +1097,12 @@ test.describe("Middleware", () => { return ( <>

Index: {loaderData}

- Go to about + Go to about ); } `, - "app/routes/about.tsx": js` + "app/routes/loaders.about.tsx": js` import { orderContext } from "../context";; export const clientMiddleware = [ ({ context }) => { @@ -741,524 +1124,375 @@ test.describe("Middleware", () => { }, }); - let appFixture = await createAppFixture(fixture); + appFixture = await createAppFixture(fixture); + }); + test.afterAll(() => { + appFixture.close(); + }); + + test("calls clientMiddleware before/after loaders with split route modules", async ({ + page, + }) => { let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); + await app.goto("/loaders"); await page.waitForSelector('[data-route]:has-text("Index")'); expect(await page.locator("[data-route]").textContent()).toBe( "Index: a,b", ); - (await page.$('a[href="/about"]'))?.click(); + (await page.$('a[href="/loaders/about"]'))?.click(); await page.waitForSelector('[data-route]:has-text("About")'); expect(await page.locator("[data-route]").textContent()).toBe( "About: c,d", ); - - appFixture.close(); }); + }); - test("calls clientMiddleware before/after loaders with split route modules", async ({ - page, - }) => { - let fixture = await createFixture({ + test.describe("Server Middleware", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - splitRouteModules: true, - }), + "react-router.config.ts": reactRouterConfig({ v8_middleware: true }), "vite.config.ts": js` import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); + export default defineConfig({ build: { manifest: true, minify: false }, plugins: [reactRouter()] }); `, "app/context.ts": js` import { createContext } from 'react-router' export const orderContext = createContext([]); `, - "app/routes/_index.tsx": js` + "app/routes/loaders._index.tsx": js` import { Link } from 'react-router' - import { orderContext } from "../context";; - - export const clientMiddleware = [ - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'a']); - }, - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'b']); - }, + import { orderContext } from '../context' + export const middleware = [ + ({ context }) => { context.set(orderContext, [...context.get(orderContext), 'a']); }, + ({ context }) => { context.set(orderContext, [...context.get(orderContext), 'b']); }, ]; - - export async function clientLoader({ request, context }) { - return context.get(orderContext).join(','); - } - + export async function loader({ request, context }) { return context.get(orderContext).join(','); } export default function Component({ loaderData }) { - return ( - <> -

Index: {loaderData}

- Go to about - - ); + return (<>

Index: {loaderData}

Go to about); } `, - "app/routes/about.tsx": js` - import { orderContext } from "../context";; - export const clientMiddleware = [ - ({ context }) => { - context.set(orderContext, ['c']); // reset order from hydration - }, - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'd']); - }, + "app/routes/loaders.about.tsx": js` + import { orderContext } from '../context' + export const middleware = [ + ({ context }) => { context.set(orderContext, ['c']); }, + ({ context }) => { context.set(orderContext, [...context.get(orderContext), 'd']); }, ]; - - export async function clientLoader({ context }) { - return context.get(orderContext).join(','); - } - + export async function loader({ context }) { return context.get(orderContext).join(','); } export default function Component({ loaderData }) { return

About: {loaderData}

; } `, - }, - }); - - let appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('[data-route]:has-text("Index")'); - expect(await page.locator("[data-route]").textContent()).toBe( - "Index: a,b", - ); - - (await page.$('a[href="/about"]'))?.click(); - await page.waitForSelector('[data-route]:has-text("About")'); - expect(await page.locator("[data-route]").textContent()).toBe( - "About: c,d", - ); - - appFixture.close(); - }); - - test("calls clientMiddleware when no loaders exist", async ({ page }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' + "app/routes/no-loaders.parent.tsx": js` + import { Link, Outlet } from 'react-router' - export const clientMiddleware = [ - ({ context }) => { - console.log('running index middleware') + export const middleware = [ + ({ request }) => { + console.log('Running parent middleware', new URL(request.url).pathname) }, ]; export default function Component() { return ( <> -

Index

- Go to about +

Parent

+ Go to A + Go to B + ); } `, - "app/routes/about.tsx": js` - import { Link } from 'react-router' - export const clientMiddleware = [ - ({ context }) => { - console.log('running about middleware') + "app/routes/no-loaders.parent.a.tsx": js` + export const middleware = [ + ({ request }) => { + console.log('Running A middleware', new URL(request.url).pathname) }, ]; export default function Component() { - return ( - <> -

About

- Go to index - - ); + return

A

; } `, - }, - }); - - let appFixture = await createAppFixture(fixture); - - let logs: string[] = []; - page.on("console", (msg) => logs.push(msg.text())); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - (await page.$('a[href="/about"]'))?.click(); - await page.waitForSelector('[data-route]:has-text("About")'); - expect(logs).toEqual(["running about middleware"]); - logs.splice(0); - - (await page.$('a[href="/"]'))?.click(); - await page.waitForSelector('[data-route]:has-text("Index")'); - expect(logs).toEqual(["running index middleware"]); - - appFixture.close(); - }); - - test("calls clientMiddleware before/after actions", async ({ page }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + "app/routes/no-loaders.parent.b.tsx": js` + export const middleware = [ + ({ request }) => { + console.log('Running B middleware', new URL(request.url).pathname) + }, + ]; - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const orderContext = createContext([]); + export default function Component() { + return

B

; + } `, - "app/routes/_index.tsx": js` - import { Form } from 'react-router' - import { orderContext } from "../context";; - export const clientMiddleware = [ - ({ context }) => { - context.set(orderContext, ['a']); - }, - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'b']); - }, + "app/routes/actions._index.tsx": js` + import { Form } from 'react-router' + import { orderContext } from '../context'; + export const middleware = [ + ({ context }) => { context.set(orderContext, [...context.get(orderContext), 'a']); }, + ({ context }) => { context.set(orderContext, [...context.get(orderContext), 'b']); }, ]; - - export async function clientAction({ request, context }) { - return context.get(orderContext).join(','); - } - - export async function clientLoader({ request, context }) { - return context.get(orderContext).join(','); - } - + export async function action({ request, context }) { return context.get(orderContext).join(','); } + export async function loader({ request, context }) { return context.get(orderContext).join(','); } export default function Component({ loaderData, actionData }) { - return ( - <> -

Index: {loaderData} - {actionData || 'empty'}

-
- - -
- - ); + return (<>

Index: {loaderData} - {actionData || 'empty'}

); } `, - }, - }); - - let appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('[data-route]:has-text("Index")'); - expect(await page.locator("[data-route]").textContent()).toBe( - "Index: a,b - empty", - ); - - (await page.getByRole("button"))?.click(); - await new Promise((r) => setTimeout(r, 1000)); - await page.waitForSelector('[data-route]:has-text("- a,b")'); - expect(await page.locator("[data-route]").textContent()).toBe( - "Index: a,b - a,b", - ); - - appFixture.close(); - }); - - test("handles redirects thrown on the way down", async ({ page }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` + "app/routes/redirect-down._index.tsx": js` import { Link } from 'react-router' - - export default function Component({ loaderData, actionData }) { - return Link; + export default function Component() { + return Link; } `, - "app/routes/redirect.tsx": js` + "app/routes/redirect-down.redirect.tsx": js` import { Link, redirect } from 'react-router' - export const clientMiddleware = [ - ({ request, context }) => { throw redirect('/target'); } - ] + export const middleware = [({ request, context }) => { + throw redirect('/redirect-down/target'); + }]; + export function loader() { + return null; + } export default function Component() { - return

Redirect

+ return

Redirect

; } `, - "app/routes/target.tsx": js` + "app/routes/redirect-down.target.tsx": js` export default function Component() { - return

Target

+ return

Target

; } `, - }, - }); - - let appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('a:has-text("Link")'); - - (await page.getByRole("link"))?.click(); - await page.waitForSelector('h1:has-text("Target")'); - - appFixture.close(); - }); - - test("handles redirects thrown on the way up", async ({ page }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); + "app/routes/redirect-up._index.tsx": js` + import { Link } from 'react-router'; + export default function Component() { + return Link; + } `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - - export default function Component({ loaderData, actionData }) { - return Link; + "app/routes/redirect-up.redirect.tsx": js` + import { Link, redirect } from 'react-router'; + export const middleware = [async ({ request, context }, next) => { + await next(); throw redirect('/redirect-up/target'); + }]; + export function loader() { + return null; + } + export default function Component() { + return

Redirect

; + } + `, + "app/routes/redirect-up.target.tsx": js` + export default function Component() { + return

Target

; } `, - "app/routes/redirect.tsx": js` + "app/routes/single-fetch-serialize.redirect.tsx": js` import { Link, redirect } from 'react-router' - export const clientMiddleware = [ + export const middleware = [ async ({ request, context }, next) => { - await next(); - throw redirect('/target'); + let res = await next(); + // Should still be a normal redirect here, not yet encoded into + // a single fetch redirect + res.headers.set("X-Status", res.status); + res.headers.set("X-Location", res.headers.get('Location')); + return res; } ] + export function loader() { + throw redirect('/single-fetch-serialize/target'); + } export default function Component() { return

Redirect

} `, - "app/routes/target.tsx": js` + "app/routes/single-fetch-serialize.target.tsx": js` export default function Component() { return

Target

} `, - }, - }); - - let appFixture = await createAppFixture(fixture); + "app/routes/throw-data-before._index.tsx": js` + import { Link } from 'react-router' + export default function Component({ loaderData }) { + return /a/b/c; + } + `, + "app/routes/throw-data-before.a.tsx": js` + import { Outlet } from 'react-router' + export const middleware = [ + async (_, next) => { + let res = await next(); + res.headers.set('x-a', 'true'); + return res; + } + ]; + export default function Component() { + return + } + export function ErrorBoundary({ error }) { + return ( + <> +

A Error Boundary

+
{error.data}
+ + ); + } + `, + "app/routes/throw-data-before.a.b.tsx": js` + import { Link, Outlet } from 'react-router' + export const middleware = [ + async (_, next) => { + let res = await next(); + res.headers.set('x-b', 'true'); + return res; + } + ]; + export default function Component({ loaderData }) { + return ; + } + `, + "app/routes/throw-data-before.a.b.c.tsx": js` + import { data } from "react-router"; + export const middleware = [(_, next) => { + throw data('C ERROR', { status: 418, statusText: "I'm a teapot" }) + }]; + // Force middleware to run on client side navs + export function loader() { + return null; + } + export default function Component({ loaderData }) { + return

C

+ } + `, + "app/routes/throw-data-after._index.tsx": js` + import { Link } from 'react-router' + export default function Component({ loaderData }) { + return /a/b/c; + } + `, + "app/routes/throw-data-after.a.tsx": js` + import { Outlet } from 'react-router' + export const middleware = [ + async (_, next) => { + let res = await next(); + res.headers.set('x-a', 'true'); + return res; + } + ]; + export function loader() { + return "A LOADER"; + } + export default function Component() { + return + } + export function ErrorBoundary({ error, loaderData }) { + return ( + <> +

A Error Boundary

+
{error.data}
+

{loaderData}

+ + ); + } + `, + "app/routes/throw-data-after.a.b.tsx": js` + import { Link, Outlet } from 'react-router' + export const middleware = [ + async (_, next) => { + let res = await next(); + res.headers.set('x-b', 'true'); + return res; + } + ]; + export default function Component({ loaderData }) { + return ; + } + `, + "app/routes/throw-data-after.a.b.c.tsx": js` + import { data } from "react-router"; + export const middleware = [async (_, next) => { + let res = await next(); + throw data('C ERROR', { status: 418, statusText: "I'm a teapot" }) + }]; + // Force middleware to run on client side navs + export function loader() { + return null; + } + export default function Component({ loaderData }) { + return

C

+ } + `, + "app/routes/granular._index.tsx": js` + import { Link } from 'react-router' + export default function Component({ loaderData }) { + return Link; + } + `, + "app/routes/granular.a.tsx": js` + import { Outlet } from 'react-router' + import { orderContext } from '../context'; + export const middleware = [ + ({ context }) => { context.set(orderContext, ['a']); }, + ]; - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('a:has-text("Link")'); + export async function loader({ context }) { + return context.get(orderContext).join(','); + } - (await page.getByRole("link"))?.click(); - await page.waitForSelector('h1:has-text("Target")'); + // Force a granular call for this route + export function clientLoader({ serverLoader }) { + return serverLoader() + } - appFixture.close(); - }); + export default function Component({ loaderData }) { + return ( + <> +

A: {loaderData}

+ + + ); + } + `, + "app/routes/granular.a.b.tsx": js` + import { Outlet } from 'react-router' + import { orderContext } from '../context'; + export const middleware = [ + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'b']); + }, + ]; - test("handles errors thrown on the way down", async ({ page }) => { - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + export async function loader({ context }) { + return context.get(orderContext).join(','); + } - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - - export default function Component() { - return Link; - } - `, - "app/routes/broken.tsx": js` - export const clientMiddleware = [ - async ({ request, context }, next) => { - throw new Error('broken!') - } - ] - export default function Component() { - return

Should not see me

- } - export function ErrorBoundary({ error }) { - return

{error.message}

- } - `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('a:has-text("Link")'); - - (await page.getByRole("link"))?.click(); - await page.waitForSelector("[data-error]"); - expect(await page.innerText("[data-error]")).toBe("broken!"); - - appFixture.close(); - }); - - test("handles errors thrown on the way up", async ({ page }) => { - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - - export default function Component() { - return Link; - } - `, - "app/routes/broken.tsx": js` - import { useRouteError } from 'react-router' - export const clientMiddleware = [ - async ({ request, context }, next) => { - await next(); - throw new Error('broken!') - } - ] - export function clientLoader() { - return "nope" - } - export default function Component() { - return

Should not see me

- } - export function ErrorBoundary({ loaderData, error }) { - return ( - <> -

{error.message}

-
{loaderData ?? 'empty'}
- - ); - } - `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('a:has-text("Link")'); - - (await page.getByRole("link"))?.click(); - await page.waitForSelector("[data-error]"); - expect(await page.innerText("[data-error]")).toBe("broken!"); - expect(await page.innerText("pre")).toBe("empty"); - - appFixture.close(); - }); - - test("calls clientMiddleware for routes even without a loader", async ({ - page, - }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + // Force a granular call for this route + export function clientLoader({ serverLoader }) { + return serverLoader() + } - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const orderContext = createContext([]); + export default function Component({ loaderData }) { + return

B: {loaderData}

; + } `, - "app/routes/_index.tsx": js` + "app/routes/without-loader-document._index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return Link; + return Link; } `, - "app/routes/a.tsx": js` + "app/routes/without-loader-document.a.tsx": js` import { Outlet } from 'react-router' import { orderContext } from '../context'; - export const clientMiddleware = [ + export const middleware = [ ({ context }) => { context.set(orderContext, ['a']); } ]; - export function clientLoader({ context }) { + export function loader({ context }) { return context.get(orderContext).join(','); } @@ -1266,10 +1500,10 @@ test.describe("Middleware", () => { return <>

A: {loaderData}

; } `, - "app/routes/a.b.tsx": js` + "app/routes/without-loader-document.a.b.tsx": js` import { Outlet } from 'react-router' import { orderContext } from '../context'; - export const clientMiddleware = [ + export const middleware = [ ({ context }) => { context.set(orderContext, [...context.get(orderContext), 'b']); }, @@ -1279,9 +1513,9 @@ test.describe("Middleware", () => { return <>

B

; } `, - "app/routes/a.b.c.tsx": js` + "app/routes/without-loader-document.a.b.c.tsx": js` import { orderContext } from '../context'; - export function clientLoader({ context }) { + export function loader({ context }) { return context.get(orderContext).join(','); } @@ -1289,741 +1523,364 @@ test.describe("Middleware", () => { return

C: {loaderData}

; } `, - }, - }); - - let appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - (await page.$('a[href="/a/b/c"]'))?.click(); - await page.waitForSelector("h4"); - expect(await page.innerText("h2")).toBe("A: a,b"); - expect(await page.innerText("h3")).toBe("B"); - expect(await page.innerText("h4")).toBe("C: a,b"); - - appFixture.close(); - }); - - test("calls clientMiddleware once when multiple server requests happen", async ({ - page, - }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const countContext = createContext({ - parent: 0, - child: 0, - }); - `, - "app/routes/_index.tsx": js` + "app/routes/without-loader-data._index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return Go to /parent/child; + return Link; } `, - "app/routes/parent.tsx": js` - import { countContext } from '../context'; - import { Outlet } from 'react-router'; - export function loader() { - return 'PARENT' - } - export const clientMiddleware = [ - ({ context }) => { context.get(countContext).parent++ }, + "app/routes/without-loader-data.a.tsx": js` + import { Outlet } from 'react-router' + import { orderContext } from '../context'; + export const middleware = [ + ({ context }) => { context.set(orderContext, ['a']); } ]; - export async function clientLoader({ serverLoader, context }) { - return { - serverData: await serverLoader(), - context: context.get(countContext) - } + export function loader({ context }) { + return context.get(orderContext).join(','); } export default function Component({ loaderData }) { - return ( - <> -

{JSON.stringify(loaderData)}

- - - ); + return <>

A: {loaderData}

; } `, - "app/routes/parent.child.tsx": js` - import { countContext } from '../context'; - export function loader() { - return 'CHILD' - } - export const clientMiddleware = [ - ({ context }) => { context.get(countContext).child++ }, + "app/routes/without-loader-data.a.b.tsx": js` + import { Outlet } from 'react-router' + import { orderContext } from '../context'; + export const middleware = [ + ({ context }) => { + context.set(orderContext, [...context.get(orderContext), 'b']); + }, ]; - export async function clientLoader({ serverLoader, context }) { - return { - serverData: await serverLoader(), - context: context.get(countContext) - } + export default function Component() { + return <>

B

; + } + `, + "app/routes/without-loader-data.a.b.c.tsx": js` + import { orderContext } from '../context'; + export function loader({ context }) { + return context.get(orderContext).join(','); } export default function Component({ loaderData }) { - return

{JSON.stringify(loaderData)}

; + return

C: {loaderData}

; } `, - }, - }); - - let appFixture = await createAppFixture(fixture); - let requests: string[] = []; - page.on("request", (request: PlaywrightRequest) => { - if (request.url().includes(".data")) { - requests.push(request.url()); - } - }); + "app/routes/resource._index.tsx": js` + import * as React from 'react' + import { useFetcher } from 'react-router' + export default function Component({ loaderData }) { + let fetcher = useFetcher(); + let [data, setData] = React.useState(); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - (await page.$('a[href="/parent/child"]'))?.click(); - await page.waitForSelector("[data-child]"); + async function rawFetch() { + let res = await fetch('/resource/a/b?raw'); + let text = await res.text(); + setData(text); + } - // 2 separate server requests made - expect(requests.sort()).toEqual([ - expect.stringContaining("/parent/child.data?_routes=routes%2Fparent"), - expect.stringContaining( - "/parent/child.data?_routes=routes%2Fparent.child", - ), - ]); - - // But client middlewares only ran once - let json = (await page.locator("[data-parent]").textContent()) as string; - expect(JSON.parse(json)).toEqual({ - serverData: "PARENT", - context: { - parent: 1, - child: 1, - }, - }); - json = (await page.locator("[data-child]").textContent()) as string; - expect(JSON.parse(json)).toEqual({ - serverData: "CHILD", - context: { - parent: 1, - child: 1, - }, - }); - - appFixture.close(); - }); - - test("calls clientMiddleware once when multiple server requests happen and some routes opt out", async ({ - page, - }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const countContext = createContext({ - parent: 0, - child: 0, - index: 0, - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - export default function Component({ loaderData }) { - return Go to /parent/child; - } - `, - "app/routes/parent.tsx": js` - import { Outlet } from 'react-router'; - import { countContext } from '../context'; - export function loader() { - return 'PARENT' - } - export const clientMiddleware = [ - ({ context }) => { context.get(countContext).parent++ }, - ]; - export default function Component({ loaderData }) { return ( <> -

{loaderData}

- + + {fetcher.data ?
{fetcher.data}
: null} + +
+ + + {data ?
{data}
: null} ); } - export function shouldRevalidate() { - return false; - } `, - "app/routes/parent.child.tsx": js` - import { Outlet } from 'react-router'; - import { countContext } from '../context'; - export function loader() { - return 'CHILD' - } - export const clientMiddleware = [ - ({ context }) => { context.get(countContext).child++ }, + "app/routes/resource.a.tsx": js` + import { orderContext } from '../context'; + export const middleware = [ + async ({ context }, next) => { + context.set(orderContext, ['a']); + let res = await next(); + res.headers.set('x-a', 'true'); + return res; + }, ]; - export default function Component({ loaderData }) { - return ( - <> -

{loaderData}

- - - ); - } `, - "app/routes/parent.child._index.tsx": js` - import { Form } from 'react-router'; - import { countContext } from '../context'; - export function action() { - return 'INDEX ACTION' - } - export function loader() { - return 'INDEX' - } - export const clientMiddleware = [ - ({ context }) => { context.get(countContext).index++ }, + "app/routes/resource.a.b.tsx": js` + import { orderContext } from '../context'; + export const middleware = [ + async ({ context }, next) => { + context.set(orderContext, [...context.get(orderContext), 'b']); + let res = await next(); + res.headers.set('x-b', 'true'); + return res; + }, ]; - export async function clientLoader({ serverLoader, context }) { - return { - serverData: await serverLoader(), - context: context.get(countContext) - } - } - export default function Component({ loaderData, actionData }) { - return ( - <> -

{JSON.stringify(loaderData)}

-
- -
- {actionData ?

{JSON.stringify(actionData)}

: null} - - ); + + export async function loader({ request, context }) { + let data = context.get(orderContext).join(','); + let isRaw = new URL(request.url).searchParams.has('raw'); + return isRaw ? new Response(data) : data; } `, }, }); + appFixture = await createAppFixture(fixture); + }); - let appFixture = await createAppFixture(fixture); - - let requests: string[] = []; - page.on("request", (request: PlaywrightRequest) => { - if (request.method() === "GET" && request.url().includes(".data")) { - requests.push(request.url()); - } - }); + test.afterAll(() => { + appFixture.close(); + }); + test("calls middleware before/after loaders", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - (await page.$('a[href="/parent/child"]'))?.click(); - await page.waitForSelector("[data-child]"); - expect(await page.locator("[data-parent]").textContent()).toBe("PARENT"); - expect(await page.locator("[data-child]").textContent()).toBe("CHILD"); - expect( - JSON.parse((await page.locator("[data-index]").textContent())!), - ).toEqual({ - serverData: "INDEX", - context: { - parent: 1, - child: 1, - index: 1, - }, - }); + await app.goto("/loaders"); + await page.waitForSelector('[data-route]:has-text("Index")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "Index: a,b", + ); - requests = []; // clear before form submission - (await page.$('button[type="submit"]'))?.click(); - await page.waitForSelector("[data-action]"); + (await page.$('a[href="/loaders/about"]'))?.click(); + await page.waitForSelector('[data-route]:has-text("About")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "About: c,d", + ); + }); - // 2 separate server requests made - expect(requests.sort()).toEqual([ - // This is the normal request but only included parent.child because parent opted out - expect.stringMatching( - /\/parent\/child\.data\?_routes=routes%2Fparent\.child$/, - ), - // index gets it's own due to clientLoader - expect.stringMatching( - /\/parent\/child\.data\?_routes=routes%2Fparent\.child\._index$/, - ), + test("calls middleware when no loaders exist on document, but not data requests", async ({ + page, + }) => { + let oldConsoleLog = console.log; + let logs: any[] = []; + console.log = (...args) => logs.push(args); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/no-loaders/parent/a"); + await page.waitForSelector('h2:has-text("Parent")'); + await page.waitForSelector('h3:has-text("A")'); + expect(logs).toEqual([ + ["Running parent middleware", "/no-loaders/parent/a"], + ["Running A middleware", "/no-loaders/parent/a"], ]); - // But client middlewares only ran once for the action and once for the revalidation - expect(await page.locator("[data-parent]").textContent()).toBe("PARENT"); - expect(await page.locator("[data-child]").textContent()).toBe("CHILD"); - expect( - JSON.parse((await page.locator("[data-index]").textContent())!), - ).toEqual({ - serverData: "INDEX", - context: { - parent: 3, - child: 3, - index: 3, - }, - }); + (await page.$('a[href="/no-loaders/parent/b"]'))?.click(); + await page.waitForSelector('h3:has-text("B")'); + expect(logs).toEqual([ + ["Running parent middleware", "/no-loaders/parent/a"], + ["Running A middleware", "/no-loaders/parent/a"], + ]); - appFixture.close(); + console.log = oldConsoleLog; }); - }); - test.describe("Server Middleware", () => { - test("calls middleware before/after loaders", async ({ page }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + test("calls middleware before/after actions", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/actions"); + await page.waitForSelector('[data-route]:has-text("Index")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "Index: a,b - empty", + ); - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const orderContext = createContext([]); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - import { orderContext } from "../context";; + (await page.getByRole("button"))?.click(); + await new Promise((r) => setTimeout(r, 1000)); + await page.waitForSelector('[data-route]:has-text("- a,b")'); + expect(await page.locator("[data-route]").textContent()).toBe( + "Index: a,b - a,b", + ); + }); - export const middleware = [ - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'a']); - }, - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'b']); - }, - ]; + test("handles redirects thrown on the way down", async ({ page }) => { + let res = await fixture.requestDocument("/redirect-down/redirect"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/redirect-down/target"); + expect(res.body).toBeNull(); - export async function loader({ request, context }) { - return context.get(orderContext).join(','); - } + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect-down", true); + await page.waitForSelector('a:has-text("Link")'); - export default function Component({ loaderData }) { - return ( - <> -

Index: {loaderData}

- Go to about - - ); - } - `, - "app/routes/about.tsx": js` - import { orderContext } from "../context";; - export const middleware = [ - ({ context }) => { - context.set(orderContext, ['c']); - }, - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'd']); - } - ]; + (await page.$('a[href="/redirect-down/redirect"]'))?.click(); + await page.waitForSelector('h1:has-text("Target")'); + }); - export async function loader({ context }) { - return context.get(orderContext).join(','); - } + test("handles redirects thrown on the way up", async ({ page }) => { + let res = await fixture.requestDocument("/redirect-up/redirect"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/redirect-up/target"); + expect(res.body).toBeNull(); - export default function Component({ loaderData }) { - return

About: {loaderData}

; - } - `, + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect-up", true); + await page.waitForSelector('a:has-text("Link")'); + + (await page.$('a[href="/redirect-up/redirect"]'))?.click(); + await page.waitForSelector('h1:has-text("Target")'); + }); + + test("doesn't serialize single fetch redirects until after the middleware chain", async () => { + let res = await fixture.requestSingleFetchData( + "/single-fetch-serialize/redirect.data", + ); + expect(res.status).toBe(202); + expect(res.headers.get("location")).toBe(null); + expect(res.headers.get("x-status")).toBe("302"); + expect(res.headers.get("x-location")).toBe( + "/single-fetch-serialize/target", + ); + expect(res.data).toEqual({ + [UNSAFE_SingleFetchRedirectSymbol]: { + redirect: "/single-fetch-serialize/target", + reload: false, + replace: false, + revalidate: false, + status: 302, }, }); + }); - let appFixture = await createAppFixture(fixture); + test("bubbles response up the chain when middleware throws data() before next", async ({ + page, + }) => { + let res = await fixture.requestDocument("/throw-data-before/a/b/c"); + expect(res.status).toBe(418); + expect(res.headers.get("x-a")).toBe("true"); + expect(res.headers.get("x-b")).toBe("true"); + let html = await res.text(); + expect(html).toContain("A Error Boundary"); + expect(html).toContain("C ERROR"); + + let data = await fixture.requestSingleFetchData( + "/throw-data-before/a/b/c.data", + ); + expect(data.status).toBe(418); + expect(data.headers.get("x-a")).toBe("true"); + expect(data.headers.get("x-b")).toBe("true"); + expect((data.data as any)["routes/throw-data-before.a.b.c"]).toEqual({ + error: new UNSAFE_ErrorResponseImpl(418, "I'm a teapot", "C ERROR"), + }); let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('[data-route]:has-text("Index")'); - expect(await page.locator("[data-route]").textContent()).toBe( - "Index: a,b", + await app.goto("/throw-data-before"); + await app.clickLink("/throw-data-before/a/b/c"); + await page.waitForSelector("[data-error]"); + expect(await page.locator("[data-error]").textContent()).toBe( + "A Error Boundary", ); + expect(await page.locator("pre").textContent()).toBe("C ERROR"); + }); - (await page.$('a[href="/about"]'))?.click(); - await page.waitForSelector('[data-route]:has-text("About")'); - expect(await page.locator("[data-route]").textContent()).toBe( - "About: c,d", + test("bubbles response up the chain when middleware throws data() after next", async ({ + page, + }) => { + let res = await fixture.requestDocument("/throw-data-after/a/b/c"); + expect(res.status).toBe(418); + expect(res.headers.get("x-a")).toBe("true"); + expect(res.headers.get("x-b")).toBe("true"); + let html = await res.text(); + expect(html).toContain("A Error Boundary"); + expect(html).toContain("C ERROR"); + expect(html).toContain("A LOADER"); + + let data = await fixture.requestSingleFetchData( + "/throw-data-after/a/b/c.data", ); + expect(data.status).toBe(418); + expect(data.headers.get("x-a")).toBe("true"); + expect(data.headers.get("x-b")).toBe("true"); + expect((data.data as any)["routes/throw-data-after.a"]).toEqual({ + data: "A LOADER", + }); + expect((data.data as any)["routes/throw-data-after.a.b.c"]).toEqual({ + error: new UNSAFE_ErrorResponseImpl(418, "I'm a teapot", "C ERROR"), + }); - appFixture.close(); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/throw-data-after"); + await app.clickLink("/throw-data-after/a/b/c"); + await page.waitForSelector("[data-error]"); + expect(await page.locator("[data-error]").textContent()).toBe( + "A Error Boundary", + ); + expect(await page.locator("pre").textContent()).toBe("C ERROR"); + expect(await page.locator("p").textContent()).toBe("A LOADER"); }); - test("calls middleware when no loaders exist on document, but not data requests", async ({ + test("still calls middleware for all matches on granular data requests", async ({ page, }) => { - let oldConsoleLog = console.log; - let logs: any[] = []; - console.log = (...args) => logs.push(args); - - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + let appFixture = await createAppFixture(fixture); - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/routes/parent.tsx": js` - import { Link, Outlet } from 'react-router' + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/granular"); + await page.waitForSelector('a[href="/granular/a/b"]'); - export const middleware = [ - ({ request }) => { - console.log('Running parent middleware', new URL(request.url).pathname) - }, - ]; + (await page.$('a[href="/granular/a/b"]'))?.click(); + await page.waitForSelector("[data-b]"); + expect(await page.locator("[data-a]").textContent()).toBe("A: a,b"); + expect(await page.locator("[data-b]").textContent()).toBe("B: a,b"); + }); - export default function Component() { - return ( - <> -

Parent

- Go to A - Go to B - - - ); - } - `, - "app/routes/parent.a.tsx": js` - export const middleware = [ - ({ request }) => { - console.log('Running A middleware', new URL(request.url).pathname) - }, - ]; + test("calls middleware for routes even without a loader (document)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/without-loader-document/a/b/c"); + expect(await page.innerText("h2")).toBe("A: a,b"); + expect(await page.innerText("h3")).toBe("B"); + expect(await page.innerText("h4")).toBe("C: a,b"); + }); - export default function Component() { - return

A

; - } - `, - "app/routes/parent.b.tsx": js` - export const middleware = [ - ({ request }) => { - console.log('Running B middleware', new URL(request.url).pathname) - }, - ]; + test("calls middleware for routes even without a loader (data)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/without-loader-data"); + (await page.$('a[href="/without-loader-data/a/b/c"]'))?.click(); + await page.waitForSelector("h4"); + expect(await page.innerText("h2")).toBe("A: a,b"); + expect(await page.innerText("h3")).toBe("B"); + expect(await page.innerText("h4")).toBe("C: a,b"); + }); - export default function Component() { - return

B

; - } - `, - }, + test("calls middleware on resource routes", async ({ page }) => { + let fetcherHeaders: ReturnType | undefined; + let fetchHeaders: ReturnType | undefined; + page.on("request", async (r: PlaywrightRequest) => { + if (r.url().includes("/resource/a/b.data")) { + let res = await r.response(); + fetcherHeaders = res?.headers(); + } else if (r.url().endsWith("/resource/a/b?raw")) { + let res = await r.response(); + fetchHeaders = res?.headers(); + } }); - let appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/a"); - await page.waitForSelector('h2:has-text("Parent")'); - await page.waitForSelector('h3:has-text("A")'); - expect(logs).toEqual([ - ["Running parent middleware", "/parent/a"], - ["Running A middleware", "/parent/a"], - ]); + await app.goto("/resource"); - (await page.$('a[href="/parent/b"]'))?.click(); - await page.waitForSelector('h3:has-text("B")'); - expect(logs).toEqual([ - ["Running parent middleware", "/parent/a"], - ["Running A middleware", "/parent/a"], - ]); + (await page.$("#fetcher"))?.click(); + await page.waitForSelector("[data-fetcher]"); + expect(await page.locator("[data-fetcher]").textContent()).toBe("a,b"); + expect(fetcherHeaders!["x-a"]).toBe("true"); + expect(fetcherHeaders!["x-b"]).toBe("true"); - appFixture.close(); - console.log = oldConsoleLog; + (await page.$("#fetch"))?.click(); + await page.waitForSelector("[data-fetch]"); + expect(await page.locator("[data-fetch]").textContent()).toBe("a,b"); + expect(fetchHeaders!["x-a"]).toBe("true"); + expect(fetchHeaders!["x-b"]).toBe("true"); }); + }); - test("calls middleware before/after actions", async ({ page }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const orderContext = createContext([]); - `, - "app/routes/_index.tsx": js` - import { Form } from 'react-router' - import { orderContext } from "../context";; + test.describe("Server Middleware (dev)", () => { + let fixture: Fixture; + let appFixture: AppFixture; - export const middleware = [ - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'a']); - }, - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'b']); - }, - ]; - - export async function action({ request, context }) { - return context.get(orderContext).join(','); - } - - export async function loader({ request, context }) { - return context.get(orderContext).join(','); - } - - export default function Component({ loaderData, actionData }) { - return ( - <> -

Index: {loaderData} - {actionData || 'empty'}

-
- - -
- - ); - } - `, - }, - }); - - let appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('[data-route]:has-text("Index")'); - expect(await page.locator("[data-route]").textContent()).toBe( - "Index: a,b - empty", - ); - - (await page.getByRole("button"))?.click(); - await new Promise((r) => setTimeout(r, 1000)); - await page.waitForSelector('[data-route]:has-text("- a,b")'); - expect(await page.locator("[data-route]").textContent()).toBe( - "Index: a,b - a,b", - ); - - appFixture.close(); - }); - - test("handles redirects thrown on the way down", async ({ page }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - - export default function Component({ loaderData, actionData }) { - return Link; - } - `, - "app/routes/redirect.tsx": js` - import { Link, redirect } from 'react-router' - export const middleware = [ - ({ request, context }) => { throw redirect('/target'); } - ] - export function loader() { - return null; - } - export default function Component() { - return

Redirect

- } - `, - "app/routes/target.tsx": js` - export default function Component() { - return

Target

- } - `, - }, - }); - - let res = await fixture.requestDocument("/redirect"); - expect(res.status).toBe(302); - expect(res.headers.get("location")).toBe("/target"); - expect(res.body).toBeNull(); - - let appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('a:has-text("Link")'); - - (await page.getByRole("link"))?.click(); - await page.waitForSelector('h1:has-text("Target")'); - - appFixture.close(); - }); - - test("handles redirects thrown on the way up", async ({ page }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - - export default function Component({ loaderData, actionData }) { - return Link; - } - `, - "app/routes/redirect.tsx": js` - import { Link, redirect } from 'react-router' - export const middleware = [ - async ({ request, context }, next) => { - await next(); - throw redirect('/target'); - } - ] - export function loader() { - return null; - } - export default function Component() { - return

Redirect

- } - `, - "app/routes/target.tsx": js` - export default function Component() { - return

Target

- } - `, - }, - }); - - let res = await fixture.requestDocument("/redirect"); - expect(res.status).toBe(302); - expect(res.headers.get("location")).toBe("/target"); - expect(res.body).toBeNull(); - - let appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('a:has-text("Link")'); - - (await page.getByRole("link"))?.click(); - await page.waitForSelector('h1:has-text("Target")'); - - appFixture.close(); - }); - - test("doesn't serialize single fetch redirects until after the middleware chain", async ({ - page, - }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/routes/redirect.tsx": js` - import { Link, redirect } from 'react-router' - export const middleware = [ - async ({ request, context }, next) => { - let res = await next(); - // Should still be a normal redirect here, not yet encoded into - // a single fetch redirect - res.headers.set("X-Status", res.status); - res.headers.set("X-Location", res.headers.get('Location')); - return res; - } - ] - export function loader() { - throw redirect('/target'); - } - export default function Component() { - return

Redirect

- } - `, - "app/routes/target.tsx": js` - export default function Component() { - return

Target

- } - `, - }, - }); - - let res = await fixture.requestSingleFetchData("/redirect.data"); - expect(res.status).toBe(202); - expect(res.headers.get("location")).toBe(null); - expect(res.headers.get("x-status")).toBe("302"); - expect(res.headers.get("x-location")).toBe("/target"); - expect(res.data).toEqual({ - [UNSAFE_SingleFetchRedirectSymbol]: { - redirect: "/target", - reload: false, - replace: false, - revalidate: false, - status: 302, - }, - }); - }); - - test("handles errors thrown on the way down (document)", async ({ - page, - }) => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - - let fixture = await createFixture( + test.beforeAll(async () => { + fixture = await createFixture( { files: { "react-router.config.ts": reactRouterConfig({ @@ -2038,7 +1895,71 @@ test.describe("Middleware", () => { plugins: [reactRouter()], }); `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + + import type { AppLoadContext, EntryContext } from "react-router"; + import { createReadableStreamFromReadable } from "@react-router/node"; + import { ServerRouter } from "react-router"; + import type { RenderToPipeableStreamOptions } from "react-dom/server"; + import { renderToPipeableStream } from "react-dom/server"; + + export const streamTimeout = 5_000; + + export function handleError(error, { request }) { + if (!request.signal.aborted) { + let {pathname, search} = new URL(request.url); + console.error("handleError", request.method, pathname + search, error); + } + } + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + ) { + return new Promise((resolve, reject) => { + let shellRendered = false; + + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, streamTimeout + 1000); + }); + } + `, "app/routes/_index.tsx": js` import { Link } from 'react-router' @@ -2059,53 +1980,34 @@ test.describe("Middleware", () => { return

{error.message}

} `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/broken"); - expect(await page.innerText("[data-error]")).toBe("broken!"); - expect(errors).toEqual([ - ["handleError", "GET", "/broken", new Error("broken!")], - ]); - - appFixture.close(); - }); - - test("handles errors thrown on the way down (data)", async ({ page }) => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/_index.tsx": js` + "app/routes/error-down-document._index.tsx": js` import { Link } from 'react-router' export default function Component() { - return Link; + return Link; } `, - "app/routes/broken.tsx": js` + "app/routes/error-down-document.broken.tsx": js` + export const middleware = [ + async ({ request, context }, next) => { + throw new Error('broken!'); + } + ] + export default function Component() { + return

Should not see me

+ } + export function ErrorBoundary({ error }) { + return

{error.message}

+ } + `, + "app/routes/error-down-data._index.tsx": js` + import { Link } from 'react-router' + + export default function Component() { + return Link; + } + `, + "app/routes/error-down-data.broken.tsx": js` export const middleware = [ async ({ request, context }, next) => { throw new Error('broken!'); @@ -2121,56 +2023,14 @@ test.describe("Middleware", () => { return

{error.message}

} `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - (await page.$('a[href="/broken"]'))?.click(); - await page.waitForSelector("[data-error]"); - expect(await page.innerText("[data-error]")).toBe("broken!"); - expect(errors).toEqual([ - ["handleError", "GET", "/broken.data", new Error("broken!")], - ]); - - appFixture.close(); - }); - - test("handles errors thrown on the way up (document)", async ({ page }) => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/_index.tsx": js` + "app/routes/error-up-document._index.tsx": js` import { Link } from 'react-router' export default function Component() { - return Link; + return Link; } `, - "app/routes/broken.tsx": js` + "app/routes/error-up-document.broken.tsx": js` export const middleware = [ async ({ request, context }, next) => { await next(); @@ -2192,54 +2052,14 @@ test.describe("Middleware", () => { ); } `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/broken"); - expect(await page.innerText("[data-error]")).toBe("broken!"); - expect(await page.innerText("pre")).toBe("empty"); - expect(errors).toEqual([ - ["handleError", "GET", "/broken", new Error("broken!")], - ]); - - appFixture.close(); - }); - - test("handles errors thrown on the way up (data)", async ({ page }) => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/_index.tsx": js` + "app/routes/error-up-data._index.tsx": js` import { Link } from 'react-router' export default function Component() { - return Link; + return Link; } `, - "app/routes/broken.tsx": js` + "app/routes/error-up-data.broken.tsx": js` export const middleware = [ async ({ request, context }, next) => { await next() @@ -2261,57 +2081,14 @@ test.describe("Middleware", () => { ); } `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - (await page.$('a[href="/broken"]'))?.click(); - await page.waitForSelector("h1"); - await page.waitForSelector("[data-error]"); - expect(await page.innerText("[data-error]")).toBe("broken!"); - expect(await page.innerText("pre")).toBe("empty"); - expect(errors).toEqual([ - ["handleError", "GET", "/broken.data", new Error("broken!")], - ]); - - appFixture.close(); - }); - - test("bubbles errors up on document requests", async ({ page }) => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/_index.tsx": js` + "app/routes/bubble-document._index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return Link + return Link } `, - "app/routes/a.tsx": js` + "app/routes/bubble-document.a.tsx": js` import { Outlet } from 'react-router' export const middleware = [ @@ -2334,7 +2111,7 @@ test.describe("Middleware", () => { return <>

A Error Boundary

{error.message}
} `, - "app/routes/a.b.tsx": js` + "app/routes/bubble-document.a.b.tsx": js` export const middleware = [ async ({ context }, next) => { let res = await next(); @@ -2350,53 +2127,13 @@ test.describe("Middleware", () => { return

B

; } `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/a/b"); - expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); - expect(await page.locator("pre").textContent()).toBe("broken!"); - expect(errors).toEqual([ - ["handleError", "GET", "/a/b", new Error("broken!")], - ]); - - appFixture.close(); - }); - - test("bubbles errors up on data requests", async ({ page }) => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/_index.tsx": js` + "app/routes/bubble-data._index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return Link + return Link } `, - "app/routes/a.tsx": js` + "app/routes/bubble-data.a.tsx": js` import { Outlet } from 'react-router' export const middleware = [ @@ -2419,7 +2156,7 @@ test.describe("Middleware", () => { return <>

A Error Boundary

{error.message}
} `, - "app/routes/a.b.tsx": js` + "app/routes/bubble-data.a.b.tsx": js` export const middleware = [ async ({ context }, next) => { let res = await next(); @@ -2435,58 +2172,13 @@ test.describe("Middleware", () => { return

B

; } `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - (await page.$('a[href="/a/b"]'))?.click(); - await page.waitForSelector("pre"); - expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); - expect(await page.locator("pre").textContent()).toBe("broken!"); - expect(errors).toEqual([ - ["handleError", "GET", "/a/b.data", new Error("broken!")], - ]); - - appFixture.close(); - }); - - test("bubbles errors on the way down up to at least the highest route with a loader", async ({ - page, - }) => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/_index.tsx": js` + "app/routes/bubble-down-a._index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return Link + return Link } `, - "app/routes/a.tsx": js` + "app/routes/bubble-down-a.a.tsx": js` import { Outlet } from 'react-router' export default function Component() { return @@ -2495,7 +2187,7 @@ test.describe("Middleware", () => { return <>

A Error Boundary

{error.message}
} `, - "app/routes/a.b.tsx": js` + "app/routes/bubble-down-a.a.b.tsx": js` import { Outlet } from 'react-router' export function loader() { return null; @@ -2504,7 +2196,7 @@ test.describe("Middleware", () => { return } `, - "app/routes/a.b.c.tsx": js` + "app/routes/bubble-down-a.a.b.c.tsx": js` import { Outlet } from 'react-router' export default function Component() { return @@ -2513,77 +2205,26 @@ test.describe("Middleware", () => { return <>

C Error Boundary

{error.message}
} `, - "app/routes/a.b.c.d.tsx": js` + "app/routes/bubble-down-a.a.b.c.d.tsx": js` import { Outlet } from 'react-router' export const middleware = [() => { throw new Error("broken!") }] export default function Component() { return } `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/a/b/c/d"); - expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); - expect(await page.locator("pre").textContent()).toBe("broken!"); - expect(errors).toEqual([ - ["handleError", "GET", "/a/b/c/d", new Error("broken!")], - ]); - errors.splice(0); - - await app.goto("/"); - await app.clickLink("/a/b/c/d"); - expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); - expect(await page.locator("pre").textContent()).toBe("broken!"); - expect(errors).toEqual([ - ["handleError", "GET", "/a/b/c/d.data", new Error("broken!")], - ]); - - appFixture.close(); - }); - - test("bubbles errors on the way down up to the deepest error boundary when loaders aren't revalidating", async ({ - page, - }) => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/_index.tsx": js` + "app/routes/bubble-down-b._index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { return ( <> - /a/b + /a/b
- /a/b/c/d + /a/b/c/d ); } `, - "app/routes/a.tsx": js` + "app/routes/bubble-down-b.a.tsx": js` import { Outlet } from 'react-router' export default function Component() { return @@ -2592,7 +2233,7 @@ test.describe("Middleware", () => { return <>

A Error Boundary

{error.message}
} `, - "app/routes/a.b.tsx": js` + "app/routes/bubble-down-b.a.b.tsx": js` import { Link, Outlet } from 'react-router' export function loader() { return { message: "DATA" }; @@ -2601,7 +2242,7 @@ test.describe("Middleware", () => { return ( <>

AB: {loaderData.message}

- /a/b/c/d + /a/b/c/d ); @@ -2610,7 +2251,7 @@ test.describe("Middleware", () => { return false; } `, - "app/routes/a.b.c.tsx": js` + "app/routes/bubble-down-b.a.b.c.tsx": js` import { Outlet } from 'react-router' export default function Component() { return @@ -2619,7 +2260,7 @@ test.describe("Middleware", () => { return <>

C Error Boundary

{error.message}
} `, - "app/routes/a.b.c.d.tsx": js` + "app/routes/bubble-down-b.a.b.c.d.tsx": js` import { Outlet } from 'react-router' export const middleware = [() => { throw new Error("broken!") }] export const loader = () => null; @@ -2627,64 +2268,13 @@ test.describe("Middleware", () => { return } `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/a/b"); - await page.waitForSelector("[data-ab]"); - expect(await page.locator("[data-ab]").textContent()).toBe("AB: DATA"); - expect(errors).toEqual([]); - - await app.clickLink("/a/b/c/d"); - await page.waitForSelector("[data-error-c]"); - expect(await page.locator("h1").textContent()).toBe("C Error Boundary"); - expect(await page.locator("pre").textContent()).toBe("broken!"); - expect(errors).toEqual([ - [ - "handleError", - "GET", - "/a/b/c/d.data?_routes=routes%2Fa.b.c.d", - new Error("broken!"), - ], - ]); - - appFixture.close(); - }); - - test("bubbles response up the chain when middleware throws before next", async ({ - page, - }) => { - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` + "app/routes/bubble-response-before._index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return /a/b/c; + return /a/b/c; } `, - "app/routes/a.tsx": js` + "app/routes/bubble-response-before.a.tsx": js` import { Outlet } from 'react-router' export const middleware = [ async (_, next) => { @@ -2700,7 +2290,7 @@ test.describe("Middleware", () => { return <>

A Error Boundary

{error.message}
} `, - "app/routes/a.b.tsx": js` + "app/routes/bubble-response-before.a.b.tsx": js` import { Link, Outlet } from 'react-router' export const middleware = [ async (_, next) => { @@ -2713,7 +2303,7 @@ test.describe("Middleware", () => { return ; } `, - "app/routes/a.b.c.tsx": js` + "app/routes/bubble-response-before.a.b.c.tsx": js` export const middleware = [(_, next) => { throw new Error('C ERROR') }]; @@ -2725,69 +2315,13 @@ test.describe("Middleware", () => { return

C

} `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let res = await fixture.requestDocument("/a/b/c"); - expect(res.status).toBe(500); - expect(res.headers.get("x-a")).toBe("true"); - expect(res.headers.get("x-b")).toBe("true"); - let html = await res.text(); - expect(html).toContain("A Error Boundary"); - expect(html).toContain("C ERROR"); - - let data = await fixture.requestSingleFetchData("/a/b/c.data"); - expect(data.status).toBe(500); - expect(data.headers.get("x-a")).toBe("true"); - expect(data.headers.get("x-b")).toBe("true"); - expect((data.data as any)["routes/a.b.c"]).toEqual({ - error: new Error("C ERROR"), - }); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/a/b/c"); - await page.waitForSelector("[data-error]"); - expect(await page.locator("[data-error]").textContent()).toBe( - "A Error Boundary", - ); - expect(await page.locator("pre").textContent()).toBe("C ERROR"); - - appFixture.close(); - }); - - test("bubbles response up the chain when middleware throws after next", async ({ - page, - }) => { - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` + "app/routes/bubble-response-after._index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return /a/b/c; + return /a/b/c; } `, - "app/routes/a.tsx": js` + "app/routes/bubble-response-after.a.tsx": js` import { Outlet } from 'react-router' export const middleware = [ async (_, next) => { @@ -2812,7 +2346,7 @@ test.describe("Middleware", () => { ); } `, - "app/routes/a.b.tsx": js` + "app/routes/bubble-response-after.a.b.tsx": js` import { Link, Outlet } from 'react-router' export const middleware = [ async (_, next) => { @@ -2825,7 +2359,7 @@ test.describe("Middleware", () => { return ; } `, - "app/routes/a.b.c.tsx": js` + "app/routes/bubble-response-after.a.b.c.tsx": js` export const middleware = [async (_, next) => { let res = await next(); throw new Error('C ERROR') @@ -2838,77 +2372,13 @@ test.describe("Middleware", () => { return

C

} `, - }, - }, - UNSAFE_ServerMode.Development, - ); - - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); - - let res = await fixture.requestDocument("/a/b/c"); - expect(res.status).toBe(500); - expect(res.headers.get("x-a")).toBe("true"); - expect(res.headers.get("x-b")).toBe("true"); - let html = await res.text(); - expect(html).toContain("A Error Boundary"); - expect(html).toContain("C ERROR"); - expect(html).toContain("A LOADER"); - - let data = await fixture.requestSingleFetchData("/a/b/c.data"); - expect(data.status).toBe(500); - expect(data.headers.get("x-a")).toBe("true"); - expect(data.headers.get("x-b")).toBe("true"); - expect((data.data as any)["routes/a"]).toEqual({ - data: "A LOADER", - }); - expect((data.data as any)["routes/a.b.c"]).toEqual({ - error: new Error("C ERROR"), - }); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/a/b/c"); - await page.waitForSelector("[data-error]"); - expect(await page.locator("[data-error]").textContent()).toBe( - "A Error Boundary", - ); - expect(await page.locator("pre").textContent()).toBe("C ERROR"); - expect(await page.locator("p").textContent()).toBe("A LOADER"); - - appFixture.close(); - }); - - test("bubbles response up the chain when multiple middlewares throw in sequence", async ({ - page, - }) => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/_index.tsx": js` + "app/routes/bubble-response-multiple._index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return /a/b/c; + return /a/b/c; } `, - "app/routes/a.tsx": js` + "app/routes/bubble-response-multiple.a.tsx": js` import { Outlet } from 'react-router' export const middleware = [ async (_, next) => { @@ -2933,7 +2403,7 @@ test.describe("Middleware", () => { ); } `, - "app/routes/a.b.tsx": js` + "app/routes/bubble-response-multiple.a.b.tsx": js` import { Link, Outlet } from 'react-router' export const middleware = [async (_, next) => { let res = await next(); @@ -2943,7 +2413,7 @@ test.describe("Middleware", () => { return ; } `, - "app/routes/a.b.c.tsx": js` + "app/routes/bubble-response-multiple.a.b.c.tsx": js` export const middleware = [async (_, next) => { let res = await next(); throw new Error('C ERROR') @@ -2956,840 +2426,434 @@ test.describe("Middleware", () => { return

C

} `, + "app/routes/error-down-resource.a.tsx": js` + export const middleware = [ + async ({ context }, next) => { + throw new Error("broken!"); + }, + ]; + `, + "app/routes/error-down-resource.a.b.tsx": js` + export async function loader({ request, context }) { + return new Response("ok"); + } + `, + "app/routes/error-up-resource.a.tsx": js` + export const middleware = [ + async ({ context }, next) => { + let res = await next() + throw new Error("broken!"); + }, + ]; + `, + "app/routes/error-up-resource.a.b.tsx": js` + export async function loader({ request, context }) { + return new Response("ok"); + } + `, }, }, UNSAFE_ServerMode.Development, ); - - let appFixture = await createAppFixture( + appFixture = await createAppFixture( fixture, UNSAFE_ServerMode.Development, ); + }); - let res = await fixture.requestDocument("/a/b/c"); - expect(res.status).toBe(500); - expect(res.headers.get("x-a")).toBe("true"); - expect(res.headers.get("x-b")).toBe(null); - let html = await res.text(); - expect(html).toContain("A Error Boundary"); - expect(html).toContain("B ERROR"); - expect(html).toContain("A LOADER"); - expect(errors).toEqual([ - ["handleError", "GET", "/a/b/c", new Error("C ERROR")], - ["handleError", "GET", "/a/b/c", new Error("B ERROR")], - ]); - errors.splice(0); + test.afterAll(() => { + appFixture.close(); + }); - let data = await fixture.requestSingleFetchData("/a/b/c.data"); - expect(data.status).toBe(500); - expect(data.headers.get("x-a")).toBe("true"); - expect(data.headers.get("x-b")).toBe(null); - expect((data.data as any)["routes/a"]).toEqual({ - data: "A LOADER", - }); - expect((data.data as any)["routes/a.b"]).toEqual({ - error: new Error("B ERROR"), - }); + test("handles errors thrown on the way down (document)", async ({ + page, + }) => { + let errors: any[] = []; + console.error = (...args) => errors.push(args); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/error-down-document/broken"); + expect(await page.innerText("[data-error]")).toBe("broken!"); expect(errors).toEqual([ - ["handleError", "GET", "/a/b/c.data", new Error("C ERROR")], - ["handleError", "GET", "/a/b/c.data", new Error("B ERROR")], + [ + "handleError", + "GET", + "/error-down-document/broken", + new Error("broken!"), + ], ]); - errors.splice(0); + }); + + test("handles errors thrown on the way down (data)", async ({ page }) => { + let errors: any[] = []; + console.error = (...args) => errors.push(args); let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/a/b/c"); + await app.goto("/error-down-data"); + + (await page.$('a[href="/error-down-data/broken"]'))?.click(); await page.waitForSelector("[data-error]"); - expect(await page.locator("[data-error]").textContent()).toBe( - "A Error Boundary", - ); - expect(await page.locator("pre").textContent()).toBe("B ERROR"); - expect(await page.locator("p").textContent()).toBe("A LOADER"); + expect(await page.innerText("[data-error]")).toBe("broken!"); expect(errors).toEqual([ - ["handleError", "GET", "/a/b/c.data", new Error("C ERROR")], - ["handleError", "GET", "/a/b/c.data", new Error("B ERROR")], + [ + "handleError", + "GET", + "/error-down-data/broken.data", + new Error("broken!"), + ], ]); - errors.splice(0); + }); - appFixture.close(); + test("handles errors thrown on the way up (document)", async ({ page }) => { + let errors: any[] = []; + console.error = (...args) => errors.push(args); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/error-up-document/broken"); + expect(await page.innerText("[data-error]")).toBe("broken!"); + expect(await page.innerText("pre")).toBe("empty"); + expect(errors).toEqual([ + [ + "handleError", + "GET", + "/error-up-document/broken", + new Error("broken!"), + ], + ]); }); - test("bubbles response up the chain when middleware throws data() before next", async ({ - page, - }) => { - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; + test("handles errors thrown on the way up (data)", async ({ page }) => { + let errors: any[] = []; + console.error = (...args) => errors.push(args); - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - export default function Component({ loaderData }) { - return /a/b/c; - } - `, - "app/routes/a.tsx": js` - import { Outlet } from 'react-router' - export const middleware = [ - async (_, next) => { - let res = await next(); - res.headers.set('x-a', 'true'); - return res; - } - ]; - export default function Component() { - return - } - export function ErrorBoundary({ error }) { - return ( - <> -

A Error Boundary

-
{error.data}
- - ); - } - `, - "app/routes/a.b.tsx": js` - import { Link, Outlet } from 'react-router' - export const middleware = [ - async (_, next) => { - let res = await next(); - res.headers.set('x-b', 'true'); - return res; - } - ]; - export default function Component({ loaderData }) { - return ; - } - `, - "app/routes/a.b.c.tsx": js` - import { data } from "react-router"; - export const middleware = [(_, next) => { - throw data('C ERROR', { status: 418, statusText: "I'm a teapot" }) - }]; - // Force middleware to run on client side navs - export function loader() { - return null; - } - export default function Component({ loaderData }) { - return

C

- } - `, - }, - }, - UNSAFE_ServerMode.Development, - ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/error-up-data"); - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); + (await page.$('a[href="/error-up-data/broken"]'))?.click(); + await page.waitForSelector("h1"); + await page.waitForSelector("[data-error]"); + expect(await page.innerText("[data-error]")).toBe("broken!"); + expect(await page.innerText("pre")).toBe("empty"); + expect(errors).toEqual([ + [ + "handleError", + "GET", + "/error-up-data/broken.data", + new Error("broken!"), + ], + ]); + }); - let res = await fixture.requestDocument("/a/b/c"); - expect(res.status).toBe(418); - expect(res.headers.get("x-a")).toBe("true"); - expect(res.headers.get("x-b")).toBe("true"); - let html = await res.text(); - expect(html).toContain("A Error Boundary"); - expect(html).toContain("C ERROR"); + test("bubbles errors up on document requests", async ({ page }) => { + let errors: any[] = []; + console.error = (...args) => errors.push(args); - let data = await fixture.requestSingleFetchData("/a/b/c.data"); - expect(data.status).toBe(418); - expect(data.headers.get("x-a")).toBe("true"); - expect(data.headers.get("x-b")).toBe("true"); - expect((data.data as any)["routes/a.b.c"]).toEqual({ - error: new UNSAFE_ErrorResponseImpl(418, "I'm a teapot", "C ERROR"), - }); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/bubble-document/a/b"); + expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); + expect(await page.locator("pre").textContent()).toBe("broken!"); + expect(errors).toEqual([ + ["handleError", "GET", "/bubble-document/a/b", new Error("broken!")], + ]); + }); + + test("bubbles errors up on data requests", async ({ page }) => { + let errors: any[] = []; + console.error = (...args) => errors.push(args); let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/a/b/c"); - await page.waitForSelector("[data-error]"); - expect(await page.locator("[data-error]").textContent()).toBe( - "A Error Boundary", - ); - expect(await page.locator("pre").textContent()).toBe("C ERROR"); + await app.goto("/bubble-data"); - appFixture.close(); + (await page.$('a[href="/bubble-data/a/b"]'))?.click(); + await page.waitForSelector("pre"); + expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); + expect(await page.locator("pre").textContent()).toBe("broken!"); + expect(errors).toEqual([ + ["handleError", "GET", "/bubble-data/a/b.data", new Error("broken!")], + ]); }); - test("bubbles response up the chain when middleware throws data() after next", async ({ + test("bubbles errors on the way down up to at least the highest route with a loader", async ({ page, }) => { - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - export default function Component({ loaderData }) { - return /a/b/c; - } - `, - "app/routes/a.tsx": js` - import { Outlet } from 'react-router' - export const middleware = [ - async (_, next) => { - let res = await next(); - res.headers.set('x-a', 'true'); - return res; - } - ]; - export function loader() { - return "A LOADER"; - } - export default function Component() { - return - } - export function ErrorBoundary({ error, loaderData }) { - return ( - <> -

A Error Boundary

-
{error.data}
-

{loaderData}

- - ); - } - `, - "app/routes/a.b.tsx": js` - import { Link, Outlet } from 'react-router' - export const middleware = [ - async (_, next) => { - let res = await next(); - res.headers.set('x-b', 'true'); - return res; - } - ]; - export default function Component({ loaderData }) { - return ; - } - `, - "app/routes/a.b.c.tsx": js` - import { data } from "react-router"; - export const middleware = [async (_, next) => { - let res = await next(); - throw data('C ERROR', { status: 418, statusText: "I'm a teapot" }) - }]; - // Force middleware to run on client side navs - export function loader() { - return null; - } - export default function Component({ loaderData }) { - return

C

- } - `, - }, - }, - UNSAFE_ServerMode.Development, - ); + let errors: any[] = []; + console.error = (...args) => errors.push(args); - let appFixture = await createAppFixture( - fixture, - UNSAFE_ServerMode.Development, - ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/bubble-down-a/a/b/c/d"); + expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); + expect(await page.locator("pre").textContent()).toBe("broken!"); + expect(errors).toEqual([ + ["handleError", "GET", "/bubble-down-a/a/b/c/d", new Error("broken!")], + ]); + errors.splice(0); - let res = await fixture.requestDocument("/a/b/c"); - expect(res.status).toBe(418); - expect(res.headers.get("x-a")).toBe("true"); - expect(res.headers.get("x-b")).toBe("true"); - let html = await res.text(); - expect(html).toContain("A Error Boundary"); - expect(html).toContain("C ERROR"); - expect(html).toContain("A LOADER"); + await app.goto("/bubble-down-a"); + await app.clickLink("/bubble-down-a/a/b/c/d"); + expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); + expect(await page.locator("pre").textContent()).toBe("broken!"); + expect(errors).toEqual([ + [ + "handleError", + "GET", + "/bubble-down-a/a/b/c/d.data", + new Error("broken!"), + ], + ]); + }); - let data = await fixture.requestSingleFetchData("/a/b/c.data"); - expect(data.status).toBe(418); - expect(data.headers.get("x-a")).toBe("true"); - expect(data.headers.get("x-b")).toBe("true"); - expect((data.data as any)["routes/a"]).toEqual({ - data: "A LOADER", - }); - expect((data.data as any)["routes/a.b.c"]).toEqual({ - error: new UNSAFE_ErrorResponseImpl(418, "I'm a teapot", "C ERROR"), - }); + test("bubbles errors on the way down up to the deepest error boundary when loaders aren't revalidating", async ({ + page, + }) => { + let errors: any[] = []; + console.error = (...args) => errors.push(args); let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/a/b/c"); - await page.waitForSelector("[data-error]"); - expect(await page.locator("[data-error]").textContent()).toBe( - "A Error Boundary", - ); - expect(await page.locator("pre").textContent()).toBe("C ERROR"); - expect(await page.locator("p").textContent()).toBe("A LOADER"); + await app.goto("/bubble-down-b"); + await app.clickLink("/bubble-down-b/a/b"); + await page.waitForSelector("[data-ab]"); + expect(await page.locator("[data-ab]").textContent()).toBe("AB: DATA"); + expect(errors).toEqual([]); - appFixture.close(); + await app.clickLink("/bubble-down-b/a/b/c/d"); + await page.waitForSelector("[data-error-c]"); + expect(await page.locator("h1").textContent()).toBe("C Error Boundary"); + expect(await page.locator("pre").textContent()).toBe("broken!"); + expect(errors).toEqual([ + [ + "handleError", + "GET", + "/bubble-down-b/a/b/c/d.data?_routes=routes%2Fbubble-down-b.a.b.c.d", + new Error("broken!"), + ], + ]); }); - test("still calls middleware for all matches on granular data requests", async ({ + test("bubbles response up the chain when middleware throws before next", async ({ page, }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const orderContext = createContext([]); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - export default function Component({ loaderData }) { - return Link; - } - `, - "app/routes/a.tsx": js` - import { Outlet } from 'react-router' - import { orderContext } from '../context'; - export const middleware = [ - ({ context }) => { context.set(orderContext, ['a']); }, - ]; - - export async function loader({ context }) { - return context.get(orderContext).join(','); - } - - // Force a granular call for this route - export function clientLoader({ serverLoader }) { - return serverLoader() - } - - export default function Component({ loaderData }) { - return ( - <> -

A: {loaderData}

- - - ); - } - `, - "app/routes/a.b.tsx": js` - import { Outlet } from 'react-router' - import { orderContext } from '../context'; - export const middleware = [ - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'b']); - }, - ]; - - export async function loader({ context }) { - return context.get(orderContext).join(','); - } - - // Force a granular call for this route - export function clientLoader({ serverLoader }) { - return serverLoader() - } - - export default function Component({ loaderData }) { - return

B: {loaderData}

; - } - `, - }, - }); - - let appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.waitForSelector('a[href="/a/b"]'); - - (await page.$('a[href="/a/b"]'))?.click(); - await page.waitForSelector("[data-b]"); - expect(await page.locator("[data-a]").textContent()).toBe("A: a,b"); - expect(await page.locator("[data-b]").textContent()).toBe("B: a,b"); - - appFixture.close(); - }); - - test("calls middleware for routes even without a loader (document)", async ({ - page, - }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const orderContext = createContext([]); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - export default function Component({ loaderData }) { - return Link; - } - `, - "app/routes/a.tsx": js` - import { Outlet } from 'react-router' - import { orderContext } from '../context'; - export const middleware = [ - ({ context }) => { context.set(orderContext, ['a']); } - ]; - - export function loader({ context }) { - return context.get(orderContext).join(','); - } - - export default function Component({ loaderData }) { - return <>

A: {loaderData}

; - } - `, - "app/routes/a.b.tsx": js` - import { Outlet } from 'react-router' - import { orderContext } from '../context'; - export const middleware = [ - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'b']); - }, - ]; - - export default function Component() { - return <>

B

; - } - `, - "app/routes/a.b.c.tsx": js` - import { orderContext } from '../context'; - export function loader({ context }) { - return context.get(orderContext).join(','); - } - - export default function Component({ loaderData }) { - return

C: {loaderData}

; - } - `, - }, - }); - - let appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/a/b/c"); - expect(await page.innerText("h2")).toBe("A: a,b"); - expect(await page.innerText("h3")).toBe("B"); - expect(await page.innerText("h4")).toBe("C: a,b"); - - appFixture.close(); - }); - - test("calls middleware for routes even without a loader (data)", async ({ - page, - }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const orderContext = createContext([]); - `, - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - export default function Component({ loaderData }) { - return Link; - } - `, - "app/routes/a.tsx": js` - import { Outlet } from 'react-router' - import { orderContext } from '../context'; - export const middleware = [ - ({ context }) => { context.set(orderContext, ['a']); } - ]; - - export function loader({ context }) { - return context.get(orderContext).join(','); - } - - export default function Component({ loaderData }) { - return <>

A: {loaderData}

; - } - `, - "app/routes/a.b.tsx": js` - import { Outlet } from 'react-router' - import { orderContext } from '../context'; - export const middleware = [ - ({ context }) => { - context.set(orderContext, [...context.get(orderContext), 'b']); - }, - ]; - - export default function Component() { - return <>

B

; - } - `, - "app/routes/a.b.c.tsx": js` - import { orderContext } from '../context'; - export function loader({ context }) { - return context.get(orderContext).join(','); - } - - export default function Component({ loaderData }) { - return

C: {loaderData}

; - } - `, - }, - }); - - let appFixture = await createAppFixture(fixture); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - (await page.$('a[href="/a/b/c"]'))?.click(); - await page.waitForSelector("h4"); - expect(await page.innerText("h2")).toBe("A: a,b"); - expect(await page.innerText("h3")).toBe("B"); - expect(await page.innerText("h4")).toBe("C: a,b"); - - appFixture.close(); - }); - - test("calls middleware on resource routes", async ({ page }) => { - let fixture = await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/context.ts": js` - import { createContext } from 'react-router' - export const orderContext = createContext([]); - `, - "app/routes/_index.tsx": js` - import * as React from 'react' - import { useFetcher } from 'react-router' - export default function Component({ loaderData }) { - let fetcher = useFetcher(); - let [data, setData] = React.useState(); - - async function rawFetch() { - let res = await fetch('/a/b?raw'); - let text = await res.text(); - setData(text); - } - - return ( - <> - - {fetcher.data ?
{fetcher.data}
: null} - -
- - - {data ?
{data}
: null} - - ); - } - `, - "app/routes/a.tsx": js` - import { orderContext } from '../context'; - export const middleware = [ - async ({ context }, next) => { - context.set(orderContext, ['a']); - let res = await next(); - res.headers.set('x-a', 'true'); - return res; - }, - ]; - `, - "app/routes/a.b.tsx": js` - import { orderContext } from '../context'; - export const middleware = [ - async ({ context }, next) => { - context.set(orderContext, [...context.get(orderContext), 'b']); - let res = await next(); - res.headers.set('x-b', 'true'); - return res; - }, - ]; - - export async function loader({ request, context }) { - let data = context.get(orderContext).join(','); - let isRaw = new URL(request.url).searchParams.has('raw'); - return isRaw ? new Response(data) : data; - } - `, - }, - }); - - let appFixture = await createAppFixture(fixture); + let res = await fixture.requestDocument("/bubble-response-before/a/b/c"); + expect(res.status).toBe(500); + expect(res.headers.get("x-a")).toBe("true"); + expect(res.headers.get("x-b")).toBe("true"); + let html = await res.text(); + expect(html).toContain("A Error Boundary"); + expect(html).toContain("C ERROR"); - let fetcherHeaders: ReturnType | undefined; - let fetchHeaders: ReturnType | undefined; - page.on("request", async (r: PlaywrightRequest) => { - if (r.url().includes("/a/b.data")) { - let res = await r.response(); - fetcherHeaders = res?.headers(); - } else if (r.url().endsWith("/a/b?raw")) { - let res = await r.response(); - fetchHeaders = res?.headers(); - } - }); + let data = await fixture.requestSingleFetchData( + "/bubble-response-before/a/b/c.data", + ); + expect(data.status).toBe(500); + expect(data.headers.get("x-a")).toBe("true"); + expect(data.headers.get("x-b")).toBe("true"); + expect((data.data as any)["routes/bubble-response-before.a.b.c"]).toEqual( + { + error: new Error("C ERROR"), + }, + ); let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); + await app.goto("/bubble-response-before"); + await app.clickLink("/bubble-response-before/a/b/c"); + await page.waitForSelector("[data-error]"); + expect(await page.locator("[data-error]").textContent()).toBe( + "A Error Boundary", + ); + expect(await page.locator("pre").textContent()).toBe("C ERROR"); + }); - (await page.$("#fetcher"))?.click(); - await page.waitForSelector("[data-fetcher]"); - expect(await page.locator("[data-fetcher]").textContent()).toBe("a,b"); - expect(fetcherHeaders!["x-a"]).toBe("true"); - expect(fetcherHeaders!["x-b"]).toBe("true"); + test("bubbles response up the chain when middleware throws after next", async ({ + page, + }) => { + let res = await fixture.requestDocument("/bubble-response-after/a/b/c"); + expect(res.status).toBe(500); + expect(res.headers.get("x-a")).toBe("true"); + expect(res.headers.get("x-b")).toBe("true"); + let html = await res.text(); + expect(html).toContain("A Error Boundary"); + expect(html).toContain("C ERROR"); + expect(html).toContain("A LOADER"); - (await page.$("#fetch"))?.click(); - await page.waitForSelector("[data-fetch]"); - expect(await page.locator("[data-fetch]").textContent()).toBe("a,b"); - expect(fetchHeaders!["x-a"]).toBe("true"); - expect(fetchHeaders!["x-b"]).toBe("true"); + let data = await fixture.requestSingleFetchData( + "/bubble-response-after/a/b/c.data", + ); + expect(data.status).toBe(500); + expect(data.headers.get("x-a")).toBe("true"); + expect(data.headers.get("x-b")).toBe("true"); + expect((data.data as any)["routes/bubble-response-after.a"]).toEqual({ + data: "A LOADER", + }); + expect((data.data as any)["routes/bubble-response-after.a.b.c"]).toEqual({ + error: new Error("C ERROR"), + }); - appFixture.close(); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/bubble-response-after"); + await app.clickLink("/bubble-response-after/a/b/c"); + await page.waitForSelector("[data-error]"); + expect(await page.locator("[data-error]").textContent()).toBe( + "A Error Boundary", + ); + expect(await page.locator("pre").textContent()).toBe("C ERROR"); + expect(await page.locator("p").textContent()).toBe("A LOADER"); }); - test("handles errors on the way down on resource routes (document)", async () => { + test("bubbles response up the chain when multiple middlewares throw in sequence", async ({ + page, + }) => { let errors: any[] = []; console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/a.tsx": js` - export const middleware = [ - async ({ context }, next) => { - throw new Error("broken!"); - }, - ]; - `, - "app/routes/a.b.tsx": js` - export async function loader({ request, context }) { - return new Response("ok"); - } - `, - }, - }, - UNSAFE_ServerMode.Development, + let res = await fixture.requestDocument( + "/bubble-response-multiple/a/b/c", ); - - let res = await fixture.requestResource("/a/b"); expect(res.status).toBe(500); - await expect(res.text()).resolves.toBe( - "Unexpected Server Error\n\nError: broken!", - ); + expect(res.headers.get("x-a")).toBe("true"); + expect(res.headers.get("x-b")).toBe(null); + let html = await res.text(); + expect(html).toContain("A Error Boundary"); + expect(html).toContain("B ERROR"); + expect(html).toContain("A LOADER"); expect(errors).toEqual([ - ["handleError", "GET", "/a/b", new Error("broken!")], + [ + "handleError", + "GET", + "/bubble-response-multiple/a/b/c", + new Error("C ERROR"), + ], + [ + "handleError", + "GET", + "/bubble-response-multiple/a/b/c", + new Error("B ERROR"), + ], ]); - }); + errors.splice(0); - test("handles errors on the way down on resource routes (data)", async () => { - let errors: any[] = []; - console.error = (...args) => errors.push(args); - let fixture = await createFixture( + let data = await fixture.requestSingleFetchData( + "/bubble-response-multiple/a/b/c.data", + ); + expect(data.status).toBe(500); + expect(data.headers.get("x-a")).toBe("true"); + expect(data.headers.get("x-b")).toBe(null); + expect((data.data as any)["routes/bubble-response-multiple.a"]).toEqual({ + data: "A LOADER", + }); + expect((data.data as any)["routes/bubble-response-multiple.a.b"]).toEqual( { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/a.tsx": js` - export const middleware = [ - async ({ context }, next) => { - throw new Error("broken!"); - }, - ]; - `, - "app/routes/a.b.tsx": js` - export async function loader({ request, context }) { - return new Response("ok"); - } - `, - }, + error: new Error("B ERROR"), }, - UNSAFE_ServerMode.Development, ); + expect(errors).toEqual([ + [ + "handleError", + "GET", + "/bubble-response-multiple/a/b/c.data", + new Error("C ERROR"), + ], + [ + "handleError", + "GET", + "/bubble-response-multiple/a/b/c.data", + new Error("B ERROR"), + ], + ]); + errors.splice(0); - let res = await fixture.requestSingleFetchData("/a/b.data"); - expect(res.status).toBe(500); - expect(res.data).toEqual({ - "routes/a": { error: new Error("broken!") }, - }); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/bubble-response-multiple"); + await app.clickLink("/bubble-response-multiple/a/b/c"); + await page.waitForSelector("[data-error]"); + expect(await page.locator("[data-error]").textContent()).toBe( + "A Error Boundary", + ); + expect(await page.locator("pre").textContent()).toBe("B ERROR"); + expect(await page.locator("p").textContent()).toBe("A LOADER"); expect(errors).toEqual([ - ["handleError", "GET", "/a/b.data", new Error("broken!")], + [ + "handleError", + "GET", + "/bubble-response-multiple/a/b/c.data", + new Error("C ERROR"), + ], + [ + "handleError", + "GET", + "/bubble-response-multiple/a/b/c.data", + new Error("B ERROR"), + ], ]); + errors.splice(0); }); - test("handles errors on the way up on resource routes (document)", async () => { + test("handles errors on the way down on resource routes", async () => { let errors: any[] = []; console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/a.tsx": js` - export const middleware = [ - async ({ context }, next) => { - let res = await next() - throw new Error("broken!"); - }, - ]; - `, - "app/routes/a.b.tsx": js` - export async function loader({ request, context }) { - return new Response("ok"); - } - `, - }, - }, - UNSAFE_ServerMode.Development, - ); - let res = await fixture.requestResource("/a/b"); + let res = await fixture.requestResource("/error-down-resource/a/b"); expect(res.status).toBe(500); await expect(res.text()).resolves.toBe( "Unexpected Server Error\n\nError: broken!", ); expect(errors).toEqual([ - ["handleError", "GET", "/a/b", new Error("broken!")], + [ + "handleError", + "GET", + "/error-down-resource/a/b", + new Error("broken!"), + ], + ]); + errors.splice(0); + + let res2 = await fixture.requestSingleFetchData( + "/error-down-resource/a/b.data", + ); + expect(res2.status).toBe(500); + expect(res2.data).toEqual({ + "routes/error-down-resource.a": { error: new Error("broken!") }, + }); + expect(errors).toEqual([ + [ + "handleError", + "GET", + "/error-down-resource/a/b.data", + new Error("broken!"), + ], ]); + errors.splice(0); }); - test("handles errors on the way up on resource routes (data)", async () => { + test("handles errors on the way up on resource routes", async () => { let errors: any[] = []; console.error = (...args) => errors.push(args); - let fixture = await createFixture( - { - files: { - "react-router.config.ts": reactRouterConfig({ - v8_middleware: true, - }), - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { reactRouter } from "@react-router/dev/vite"; - export default defineConfig({ - build: { manifest: true, minify: false }, - plugins: [reactRouter()], - }); - `, - "app/entry.server.tsx": ENTRY_SERVER_WITH_HANDLE_ERROR, - "app/routes/a.tsx": js` - export const middleware = [ - async ({ context }, next) => { - let res = await next(); - throw new Error("broken!"); - }, - ]; - `, - "app/routes/a.b.tsx": js` - export async function loader({ request, context }) { - return "ok" - } - `, - }, - }, - UNSAFE_ServerMode.Development, + let res = await fixture.requestResource("/error-up-resource/a/b"); + expect(res.status).toBe(500); + await expect(res.text()).resolves.toBe( + "Unexpected Server Error\n\nError: broken!", ); + expect(errors).toEqual([ + ["handleError", "GET", "/error-up-resource/a/b", new Error("broken!")], + ]); + errors.splice(0); - let res = await fixture.requestSingleFetchData("/a/b.data"); - expect(res.status).toBe(500); - expect(res.data).toEqual({ - "routes/a": { error: new Error("broken!") }, - "routes/a.b": { data: "ok" }, + let res2 = await fixture.requestSingleFetchData( + "/error-up-resource/a/b.data", + ); + expect(res2.status).toBe(500); + expect(res2.data).toEqual({ + "routes/error-up-resource.a": { error: new Error("broken!") }, + "routes/error-up-resource.a.b": { data: "ok" }, }); expect(errors).toEqual([ - ["handleError", "GET", "/a/b.data", new Error("broken!")], + [ + "handleError", + "GET", + "/error-up-resource/a/b.data", + new Error("broken!"), + ], ]); + errors.splice(0); }); }); });