From aab4ea0e2b52ae91f93bd87653a2786e23252d49 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 9 May 2024 11:52:18 -0700 Subject: [PATCH] feat: remove singleFetch flag (#11522) * feat: remove singleFetch flag * remove another reference * chore: update unit tests * fix: spa mode tests * update resource tests * fix commented out test * Remove server://singlefetch origin stub * Remove unused headers.ts file and set-cookie-parser dependencies * Remove entry.server.spa.tsx * Remove/adjust a few remaining _data references in tests * Turn off integration test debug logs * Remove unused _data request code paths * Revert "Remove entry.server.spa.tsx" This reverts commit dd6a86c2216bc3b082016212bc0cc0b2980e2143. * Revert "Remove server://singlefetch origin stub" This reverts commit d16ec891e915719b0ea64ae02bed3279e5adde4d. * Update comment for clarity * format * chore: ignore playgrounds from changesets chore: add changeset --------- Co-authored-by: Matt Brophy --- .changeset/config.json | 2 +- .changeset/curvy-teachers-explain.md | 11 + integration/action-test.ts | 218 +- integration/bug-report-test.ts | 2 +- integration/catch-boundary-data-test.ts | 229 +- integration/catch-boundary-test.ts | 366 +-- integration/client-data-test.ts | 1330 +--------- integration/defer-loader-test.ts | 189 +- integration/defer-test.ts | 1391 +--------- integration/error-boundary-test.ts | 1390 +--------- integration/error-boundary-v2-test.ts | 249 +- integration/error-data-request-test.ts | 300 +-- integration/error-sanitization-test.ts | 959 ++----- integration/fetcher-layout-test.ts | 790 ++---- integration/fetcher-test.ts | 548 +--- integration/file-uploads-test.ts | 239 +- integration/flat-routes-test.ts | 4 +- integration/form-data-test.ts | 124 +- integration/form-test.ts | 2348 +++++------------ integration/headers-test.ts | 2 +- integration/helpers/create-fixture.ts | 17 - integration/helpers/playwright-fixture.ts | 9 - integration/helpers/vite.ts | 3 - integration/link-test.ts | 2 +- integration/loader-test.ts | 206 +- integration/multiple-cookies-test.ts | 4 +- integration/navigation-state-test.ts | 758 ++---- integration/redirects-test.ts | 237 +- integration/remix-serve-test.ts | 2 +- integration/resource-routes-test.ts | 4 +- integration/revalidate-test.ts | 546 +--- integration/server-entry-test.ts | 2 +- integration/set-cookie-revalidation-test.ts | 170 +- integration/transition-test.ts | 65 +- integration/vite-spa-mode-test.ts | 94 +- .../__tests__/dom/scroll-restoration-test.tsx | 1 - .../__tests__/dom/ssr/components-test.tsx | 23 +- packages/react-router/__tests__/setup.ts | 10 + packages/react-router/lib/dom/ssr/browser.tsx | 65 +- .../react-router/lib/dom/ssr/components.tsx | 316 +-- .../lib/dom/ssr/create-remix-stub.tsx | 1 - packages/react-router/lib/dom/ssr/data.ts | 282 -- packages/react-router/lib/dom/ssr/entry.ts | 1 - packages/react-router/lib/dom/ssr/links.ts | 18 - packages/react-router/lib/dom/ssr/routes.tsx | 124 +- packages/react-router/lib/dom/ssr/server.tsx | 2 +- .../react-router/lib/dom/ssr/single-fetch.tsx | 9 +- packages/remix-dev/cli/commands.ts | 4 +- packages/remix-dev/config.ts | 4 +- .../config/defaults/entry.server.spa.tsx | 73 +- .../__tests__/data-test.ts | 329 +-- .../__tests__/server-test.ts | 17 +- packages/remix-server-runtime/entry.ts | 1 - packages/remix-server-runtime/headers.ts | 99 - packages/remix-server-runtime/package.json | 2 - packages/remix-server-runtime/routes.ts | 4 +- packages/remix-server-runtime/server.ts | 175 +- playground/compiler-express/package.json | 1 + playground/compiler-spa/package.json | 1 + playground/compiler/package.json | 1 + pnpm-lock.yaml | 6 - 61 files changed, 2125 insertions(+), 12254 deletions(-) create mode 100644 .changeset/curvy-teachers-explain.md delete mode 100644 packages/remix-server-runtime/headers.ts diff --git a/.changeset/config.json b/.changeset/config.json index e0d60b0475..bfad6e8a0a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -21,7 +21,7 @@ "baseBranch": "dev", "updateInternalDependencies": "patch", "bumpVersionsWithWorkspaceProtocolOnly": true, - "ignore": ["integration", "integration-*"], + "ignore": ["integration", "integration-*", "@playground/*"], "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true } diff --git a/.changeset/curvy-teachers-explain.md b/.changeset/curvy-teachers-explain.md new file mode 100644 index 0000000000..941857bb46 --- /dev/null +++ b/.changeset/curvy-teachers-explain.md @@ -0,0 +1,11 @@ +--- +"@react-router/server-runtime": major +"react-router-dom": major +"@react-router/express": major +"react-router": major +"@react-router/serve": major +"@react-router/node": major +"@react-router/dev": major +--- + +Remove single_fetch future flag. diff --git a/integration/action-test.ts b/integration/action-test.ts index 5dc4b5e0d2..46464026f4 100644 --- a/integration/action-test.ts +++ b/integration/action-test.ts @@ -8,7 +8,7 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture, selectHtml } from "./helpers/playwright-fixture.js"; -test.describe.skip("actions", () => { +test.describe("actions", () => { let fixture: Fixture; let appFixture: AppFixture; @@ -21,6 +21,7 @@ test.describe.skip("actions", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/routes/urlencoded.tsx": js` import { Form, useActionData } from "react-router-dom"; @@ -199,226 +200,15 @@ test.describe.skip("actions", () => { }) => { let app = new PlaywrightFixture(appFixture, page); await app.goto(`/${THROWS_REDIRECT}`); - let responses = app.collectDataResponses(); + let responses = app.collectSingleFetchResponses(); await app.clickSubmitButton(`/${THROWS_REDIRECT}`); await page.waitForSelector(`#${REDIRECT_TARGET}`); expect(responses.length).toBe(1); - expect(responses[0].status()).toBe(204); + expect(responses[0].status()).toBe(200); expect(new URL(page.url()).pathname).toBe(`/${REDIRECT_TARGET}`); expect(await app.getHtml()).toMatch(PAGE_TEXT); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("actions", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - let FIELD_NAME = "message"; - let WAITING_VALUE = "Waiting..."; - let SUBMITTED_VALUE = "Submission"; - let THROWS_REDIRECT = "redirect-throw"; - let REDIRECT_TARGET = "page"; - let PAGE_TEXT = "PAGE_TEXT"; - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/routes/urlencoded.tsx": js` - import { Form, useActionData } from "react-router-dom"; - - export let action = async ({ request }) => { - let formData = await request.formData(); - return formData.get("${FIELD_NAME}"); - }; - - export default function Actions() { - let data = useActionData() - - return ( -
-

- {data ? {data} : "${WAITING_VALUE}"} -

-

- - -

-
- ); - } - `, - - "app/routes/request-text.tsx": js` - import { Form, useActionData } from "react-router-dom"; - - export let action = async ({ request }) => { - let text = await request.text(); - return text; - }; - - export default function Actions() { - let data = useActionData() - - return ( -
-

- {data ? {data} : "${WAITING_VALUE}"} -

-

- - - -

-
- ); - } - `, - - [`app/routes/${THROWS_REDIRECT}.jsx`]: js` - import { redirect } from "@react-router/node"; - import { Form } from "react-router-dom"; - - export function action() { - throw redirect("/${REDIRECT_TARGET}") - } - - export default function () { - return ( -
- -
- ) - } - `, - - [`app/routes/${REDIRECT_TARGET}.jsx`]: js` - export default function () { - return
${PAGE_TEXT}
- } - `, - - "app/routes/no-action.tsx": js` - import { Form } from "react-router-dom"; - - export default function Component() { - return ( -
- -
- ); - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - let logs: string[] = []; - - test.beforeEach(({ page }) => { - page.on("console", (msg) => { - logs.push(msg.text()); - }); - }); - - test.afterEach(() => { - expect(logs).toHaveLength(0); - }); - - test("is not called on document GET requests", async () => { - let res = await fixture.requestDocument("/urlencoded"); - let html = selectHtml(await res.text(), "#text"); - expect(html).toMatch(WAITING_VALUE); - }); - - test("is called on document POST requests", async () => { - let FIELD_VALUE = "cheeseburger"; - - let params = new URLSearchParams(); - params.append(FIELD_NAME, FIELD_VALUE); - - let res = await fixture.postDocument("/urlencoded", params); - - let html = selectHtml(await res.text(), "#text"); - expect(html).toMatch(FIELD_VALUE); - }); - - test("is called on script transition POST requests", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/urlencoded`); - await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); - - await page.click("button[type=submit]"); - await page.waitForSelector("#action-text"); - await page.waitForSelector(`#text:has-text("${SUBMITTED_VALUE}")`); - }); - - test("throws a 405 when no action exists", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/no-action`); - await page.click("button[type=submit]"); - await page.waitForSelector(`h1:has-text("405 Method Not Allowed")`); - expect(logs.length).toBe(2); - expect(logs[0]).toMatch( - 'Route "routes/no-action" does not have an action' - ); - // logs[1] is the raw ErrorResponse instance from the boundary but playwright - // seems to just log the name of the constructor, which in the minified code - // is meaningless so we don't bother asserting - - // The rest of the tests in this suite assert no logs, so clear this out to - // avoid failures in afterEach - logs = []; - }); - - test("properly encodes form data for request.text() usage", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/request-text`); - await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); - - await page.click("button[type=submit]"); - await page.waitForSelector("#action-text"); - expect(await app.getHtml("#action-text")).toBe( - 'a=1&b=2' - ); - }); - - test("redirects a thrown response on document requests", async () => { - let params = new URLSearchParams(); - let res = await fixture.postDocument(`/${THROWS_REDIRECT}`, params); - expect(res.status).toBe(302); - expect(res.headers.get("Location")).toBe(`/${REDIRECT_TARGET}`); - }); - - test("redirects a thrown response on script transitions", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/${THROWS_REDIRECT}`); - let responses = app.collectSingleFetchResponses(); - await app.clickSubmitButton(`/${THROWS_REDIRECT}`); - - await page.waitForSelector(`#${REDIRECT_TARGET}`); - - expect(responses.length).toBe(1); - expect(responses[0].status()).toBe(200); - - expect(new URL(page.url()).pathname).toBe(`/${REDIRECT_TARGET}`); - expect(await app.getHtml()).toMatch(PAGE_TEXT); - }); - }); -}); diff --git a/integration/bug-report-test.ts b/integration/bug-report-test.ts index a9a7b9ab35..d3c2dc68f6 100644 --- a/integration/bug-report-test.ts +++ b/integration/bug-report-test.ts @@ -45,7 +45,7 @@ let appFixture: AppFixture; //////////////////////////////////////////////////////////////////////////////// test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { + await context.route(/\.data$/, async (route) => { await new Promise((resolve) => setTimeout(resolve, 50)); route.continue(); }); diff --git a/integration/catch-boundary-data-test.ts b/integration/catch-boundary-data-test.ts index e01e86b04f..4005cf279d 100644 --- a/integration/catch-boundary-data-test.ts +++ b/integration/catch-boundary-data-test.ts @@ -28,9 +28,9 @@ let HAS_BOUNDARY_NESTED_LOADER = "/yes/loader-self-boundary" as const; let ROOT_DATA = "root data"; let LAYOUT_DATA = "root data"; -test.describe.skip("ErrorBoundary (thrown responses)", () => { +test.describe("ErrorBoundary (thrown responses)", () => { test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { + await context.route(/.data/, async (route) => { await new Promise((resolve) => setTimeout(resolve, 50)); route.continue(); }); @@ -38,6 +38,7 @@ test.describe.skip("ErrorBoundary (thrown responses)", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` import { json } from "@react-router/node"; @@ -242,227 +243,3 @@ test.describe.skip("ErrorBoundary (thrown responses)", () => { ); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("ErrorBoundary (thrown responses)", () => { - test.beforeEach(async ({ context }) => { - await context.route(/.data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); - }); - }); - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { json } from "@react-router/node"; - import { - Links, - Meta, - Outlet, - Scripts, - useLoaderData, - useMatches, - } from "react-router-dom"; - - export const loader = () => json("${ROOT_DATA}"); - - export default function Root() { - const data = useLoaderData(); - - return ( - - - - - - -
{data}
- - - - - ); - } - - export function ErrorBoundary() { - let matches = useMatches(); - let { data } = matches.find(match => match.id === "root"); - - return ( - - - -
${ROOT_BOUNDARY_TEXT}
-
{data}
- - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { Link } from "react-router-dom"; - export default function Index() { - return ( -
- ${NO_BOUNDARY_LOADER} - ${HAS_BOUNDARY_LAYOUT_NESTED_LOADER} - ${HAS_BOUNDARY_NESTED_LOADER} -
- ); - } - `, - - [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` - export function loader() { - throw new Response("", { status: 401 }); - } - export default function Index() { - return
; - } - `, - - [`app/routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}.jsx`]: js` - import { useMatches } from "react-router-dom"; - export function loader() { - return "${LAYOUT_DATA}"; - } - export default function Layout() { - return
; - } - export function ErrorBoundary() { - let matches = useMatches(); - let { data } = matches.find(match => match.id === "routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}"); - - return ( -
-
${LAYOUT_BOUNDARY_TEXT}
-
{data}
-
- ); - } - `, - - [`app/routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}._index.jsx`]: js` - export function loader() { - throw new Response("", { status: 401 }); - } - export default function Index() { - return
; - } - `, - - [`app/routes${HAS_BOUNDARY_NESTED_LOADER_FILE}.jsx`]: js` - import { Outlet, useLoaderData } from "react-router-dom"; - export function loader() { - return "${LAYOUT_DATA}"; - } - export default function Layout() { - let data = useLoaderData(); - return ( -
-
{data}
- -
- ); - } - `, - - [`app/routes${HAS_BOUNDARY_NESTED_LOADER_FILE}._index.jsx`]: js` - export function loader() { - throw new Response("", { status: 401 }); - } - export default function Index() { - return
; - } - export function ErrorBoundary() { - return ( -
${OWN_BOUNDARY_TEXT}
- ); - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("renders root boundary with data available", async () => { - let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); - expect(res.status).toBe(401); - let html = await res.text(); - expect(html).toMatch(ROOT_BOUNDARY_TEXT); - expect(html).toMatch(ROOT_DATA); - }); - - test("renders root boundary with data available on transition", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(NO_BOUNDARY_LOADER); - await page.waitForSelector("#root-boundary"); - await page.waitForSelector( - `#root-boundary-data:has-text("${ROOT_DATA}")` - ); - }); - - test("renders layout boundary with data available", async () => { - let res = await fixture.requestDocument( - HAS_BOUNDARY_LAYOUT_NESTED_LOADER - ); - expect(res.status).toBe(401); - let html = await res.text(); - expect(html).toMatch(ROOT_DATA); - expect(html).toMatch(LAYOUT_BOUNDARY_TEXT); - expect(html).toMatch(LAYOUT_DATA); - }); - - test("renders layout boundary with data available on transition", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(HAS_BOUNDARY_LAYOUT_NESTED_LOADER); - await page.waitForSelector(`#root-data:has-text("${ROOT_DATA}")`); - await page.waitForSelector( - `#layout-boundary:has-text("${LAYOUT_BOUNDARY_TEXT}")` - ); - await page.waitForSelector( - `#layout-boundary-data:has-text("${LAYOUT_DATA}")` - ); - }); - - test("renders self boundary with layout data available", async () => { - let res = await fixture.requestDocument(HAS_BOUNDARY_NESTED_LOADER); - expect(res.status).toBe(401); - let html = await res.text(); - expect(html).toMatch(ROOT_DATA); - expect(html).toMatch(LAYOUT_DATA); - expect(html).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("renders self boundary with layout data available on transition", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(HAS_BOUNDARY_NESTED_LOADER); - await page.waitForSelector(`#root-data:has-text("${ROOT_DATA}")`); - await page.waitForSelector(`#layout-data:has-text("${LAYOUT_DATA}")`); - await page.waitForSelector( - `#own-boundary:has-text("${OWN_BOUNDARY_TEXT}")` - ); - }); - }); -}); diff --git a/integration/catch-boundary-test.ts b/integration/catch-boundary-test.ts index d33ffc0701..283f54fc71 100644 --- a/integration/catch-boundary-test.ts +++ b/integration/catch-boundary-test.ts @@ -8,7 +8,7 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe.skip("ErrorBoundary (thrown responses)", () => { +test.describe("ErrorBoundary (thrown responses)", () => { let fixture: Fixture; let appFixture: AppFixture; @@ -28,6 +28,7 @@ test.describe.skip("ErrorBoundary (thrown responses)", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` import { json } from "@react-router/node"; @@ -365,366 +366,3 @@ test.describe.skip("ErrorBoundary (thrown responses)", () => { expect(await app.getHtml("#status")).toMatch("401"); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("ErrorBoundary (thrown responses)", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - let ROOT_BOUNDARY_TEXT = "ROOT_TEXT" as const; - let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT" as const; - - let HAS_BOUNDARY_LOADER = "/yes/loader" as const; - let HAS_BOUNDARY_LOADER_FILE = "/yes.loader" as const; - let HAS_BOUNDARY_ACTION = "/yes/action" as const; - let HAS_BOUNDARY_ACTION_FILE = "/yes.action" as const; - let NO_BOUNDARY_ACTION = "/no/action" as const; - let NO_BOUNDARY_ACTION_FILE = "/no.action" as const; - let NO_BOUNDARY_LOADER = "/no/loader" as const; - let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; - - let NOT_FOUND_HREF = "/not/found"; - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { json } from "@react-router/node"; - import { Links, Meta, Outlet, Scripts, useMatches } from "react-router-dom"; - - export function loader() { - return json({ data: "ROOT LOADER" }); - } - - export default function Root() { - return ( - - - - - - - - - - - ); - } - - export function ErrorBoundary() { - let matches = useMatches() - return ( - - - -
${ROOT_BOUNDARY_TEXT}
-
{JSON.stringify(matches)}
- - - - ) - } - `, - - "app/routes/_index.tsx": js` - import { Link, Form } from "react-router-dom"; - export default function() { - return ( -
- ${NOT_FOUND_HREF} - -
-
- ) - } - `, - - [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` - import { Form } from "react-router-dom"; - export async function action() { - throw new Response("", { status: 401 }) - } - export function ErrorBoundary() { - return

${OWN_BOUNDARY_TEXT}

- } - export default function Index() { - return ( -
- -
- ); - } - `, - - [`app/routes${NO_BOUNDARY_ACTION_FILE}.jsx`]: js` - import { Form } from "react-router-dom"; - export function action() { - throw new Response("", { status: 401 }) - } - export default function Index() { - return ( -
- -
- ) - } - `, - - [`app/routes${HAS_BOUNDARY_LOADER_FILE}.jsx`]: js` - import { useRouteError } from "react-router-dom"; - export function loader() { - throw new Response("", { status: 401 }) - } - export function ErrorBoundary() { - let error = useRouteError(); - return ( - <> -
${OWN_BOUNDARY_TEXT}
-
{error.status}
- - ); - } - export default function Index() { - return
- } - `, - - [`app/routes${HAS_BOUNDARY_LOADER_FILE}.child.jsx`]: js` - export function loader() { - throw new Response("", { status: 404 }) - } - export default function Index() { - return
- } - `, - - [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` - export function loader() { - throw new Response("", { status: 401 }) - } - export default function Index() { - return
- } - `, - - "app/routes/action.tsx": js` - import { Outlet, useLoaderData } from "react-router-dom"; - - export function loader() { - return "PARENT"; - } - - export default function () { - return ( -
-

{useLoaderData()}

- -
- ) - } - `, - - "app/routes/action.child-catch.tsx": js` - import { Form, useLoaderData, useRouteError } from "react-router-dom"; - - export function loader() { - return "CHILD"; - } - - export function action() { - throw new Response("Caught!", { status: 400 }); - } - - export default function () { - return ( - <> -

{useLoaderData()}

-
- -
- - ) - } - - export function ErrorBoundary() { - let error = useRouteError() - return

{error.status} {error.data}

; - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("non-matching urls on document requests", async () => { - let oldConsoleError; - oldConsoleError = console.error; - console.error = () => {}; - - let res = await fixture.requestDocument(NOT_FOUND_HREF); - expect(res.status).toBe(404); - let html = await res.text(); - expect(html).toMatch(ROOT_BOUNDARY_TEXT); - - // There should be no loader data on the root route - let expected = JSON.stringify([ - { id: "root", pathname: "", params: {} }, - ]).replace(/"/g, """); - expect(html).toContain(`
${expected}
`); - - console.error = oldConsoleError; - }); - - test("non-matching urls on client transitions", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(NOT_FOUND_HREF, { wait: false }); - await page.waitForSelector("#root-boundary"); - - // Root loader data sticks around from previous load - let expected = JSON.stringify([ - { id: "root", pathname: "", params: {}, data: { data: "ROOT LOADER" } }, - ]); - expect(await app.getHtml("#matches")).toContain(expected); - }); - - test("own boundary, action, document request", async () => { - let params = new URLSearchParams(); - let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); - expect(res.status).toBe(401); - expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("own boundary, action, client transition from other route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickSubmitButton(HAS_BOUNDARY_ACTION); - await page.waitForSelector("#action-boundary"); - }); - - test("own boundary, action, client transition from itself", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(HAS_BOUNDARY_ACTION); - await app.clickSubmitButton(HAS_BOUNDARY_ACTION); - await page.waitForSelector("#action-boundary"); - }); - - test("bubbles to parent in action document requests", async () => { - let params = new URLSearchParams(); - let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); - expect(res.status).toBe(401); - expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("bubbles to parent in action script transitions from other routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickSubmitButton(NO_BOUNDARY_ACTION); - await page.waitForSelector("#root-boundary"); - }); - - test("bubbles to parent in action script transitions from self", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(NO_BOUNDARY_ACTION); - await app.clickSubmitButton(NO_BOUNDARY_ACTION); - await page.waitForSelector("#root-boundary"); - }); - - test("own boundary, loader, document request", async () => { - let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); - expect(res.status).toBe(401); - expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("own boundary, loader, client transition", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(HAS_BOUNDARY_LOADER); - await page.waitForSelector("#boundary-loader"); - }); - - test("bubbles to parent in loader document requests", async () => { - let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); - expect(res.status).toBe(401); - expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("bubbles to parent in loader transitions from other routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(NO_BOUNDARY_LOADER); - await page.waitForSelector("#root-boundary"); - }); - - test("uses correct catch boundary on server action errors", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/action/child-catch`); - expect(await app.getHtml("#parent-data")).toMatch("PARENT"); - expect(await app.getHtml("#child-data")).toMatch("CHILD"); - await page.click("button[type=submit]"); - await page.waitForSelector("#child-catch"); - // Preserves parent loader data - expect(await app.getHtml("#parent-data")).toMatch("PARENT"); - expect(await app.getHtml("#child-catch")).toMatch("400"); - expect(await app.getHtml("#child-catch")).toMatch("Caught!"); - }); - - test("prefers parent catch when child loader also bubbles, document request", async () => { - let res = await fixture.requestDocument(`${HAS_BOUNDARY_LOADER}/child`); - expect(res.status).toBe(401); - let text = await res.text(); - expect(text).toMatch(OWN_BOUNDARY_TEXT); - expect(text).toMatch('
401
'); - }); - - test("prefers parent catch when child loader also bubbles, client transition", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(`${HAS_BOUNDARY_LOADER}/child`); - await page.waitForSelector("#boundary-loader"); - expect(await app.getHtml("#boundary-loader")).toMatch(OWN_BOUNDARY_TEXT); - expect(await app.getHtml("#status")).toMatch("401"); - }); - }); -}); diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 030d2c7a25..4019a3e6c1 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -138,7 +138,7 @@ function getFiles({ }; } -test.describe.skip("Client Data", () => { +test.describe("Client Data", () => { let appFixture: AppFixture; test.afterAll(() => { @@ -146,7 +146,13 @@ test.describe.skip("Client Data", () => { }); function createTestFixture(init: FixtureInit, serverMode?: ServerMode) { - return createFixture(init, serverMode); + return createFixture( + { + ...init, + singleFetch: true, + }, + serverMode + ); } test.describe("clientLoader - critical route module", () => { @@ -784,68 +790,6 @@ test.describe.skip("Client Data", () => { expect(html).not.toMatch("Should not see me"); console.error = _consoleError; }); - - test("server loader errors are persisted for non-hydrating routes", async ({ - page, - }) => { - let _consoleError = console.error; - console.error = () => {}; - appFixture = await createAppFixture( - await createFixture( - { - files: { - ...getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: false, - // Hydrate the parent clientLoader but don't add a HydrateFallback - parentAdditions: js` - clientLoader.hydrate = true; - `, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import { json } from '@react-router/node' - import { useRouteError } from "react-router-dom" - export function loader() { - throw json({ message: 'Child Server Error'}); - } - export default function Component() { - return

Should not see me

; - } - export function ErrorBoundary() { - const error = useRouteError(); - return ( - <> -

Child Error

-
{JSON.stringify(error, null, 2)}
- - ); - } - `, - }, - }, - ServerMode.Development // Avoid error sanitization - ), - ServerMode.Development // Avoid error sanitization - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child", false); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader

"); - expect(html).toMatch("Child Server Error"); - expect(html).not.toMatch("Should not see me"); - // Ensure we hydrate and remain on the boundary - await page.waitForSelector( - ":has-text('Parent Server Loader (mutated by client)')" - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)

"); - expect(html).toMatch("Child Server Error"); - expect(html).not.toMatch("Should not see me"); - console.error = _consoleError; - }); }); test.describe("clientLoader - lazy route module", () => { @@ -982,13 +926,14 @@ test.describe.skip("Client Data", () => { test.describe("clientAction - critical route module", () => { test("child.clientAction", async ({ page }) => { appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` + await createTestFixture( + { + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` export async function clientAction({ serverAction }) { let data = await serverAction(); return { @@ -996,8 +941,11 @@ test.describe.skip("Client Data", () => { } } `, - }), - }) + }), + }, + ServerMode.Development + ), + ServerMode.Development ); let app = new PlaywrightFixture(appFixture, page); await app.goto("/parent/child"); @@ -1407,1239 +1355,3 @@ test.describe.skip("Client Data", () => { }); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("Client Data", () => { - let appFixture: AppFixture; - - test.afterAll(() => { - appFixture.close(); - }); - - function createTestFixture(init: FixtureInit, serverMode?: ServerMode) { - return createFixture( - { - ...init, - singleFetch: true, - }, - serverMode - ); - } - - test.describe("clientLoader - critical route module", () => { - test("no client loaders or fallbacks", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - // Full SSR - normal Remix behavior due to lack of clientLoader - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - }); - - test("parent.clientLoader/child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - // Full SSR - normal Remix behavior due to lack of HydrateFallback components - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - }); - - test("parent.clientLoader.hydrate/child.clientLoader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: true, - childClientLoader: true, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Fallback"); - expect(html).not.toMatch("Parent Server Loader"); - expect(html).not.toMatch("Child Server Loader"); - - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).not.toMatch("Parent Fallback"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader"); - }); - - test("parent.clientLoader/child.clientLoader.hydrate", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: true, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Fallback"); - expect(html).not.toMatch("Child Server Loader"); - - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).not.toMatch("Child Fallback"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - }); - - test("parent.clientLoader.hydrate/child.clientLoader.hydrate", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: true, - childClientLoader: true, - childClientLoaderHydrate: true, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Fallback"); - expect(html).not.toMatch("Parent Server Loader"); - expect(html).not.toMatch("Child Fallback"); - expect(html).not.toMatch("Child Server Loader"); - - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).not.toMatch("Parent Fallback"); - expect(html).not.toMatch("Child Fallback"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - }); - - test("handles synchronous client loaders", async ({ page }) => { - let fixture = await createTestFixture({ - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - parentAdditions: js` - export function clientLoader() { - return { message: "Parent Client Loader" }; - } - clientLoader.hydrate=true - export function HydrateFallback() { - return

Parent Fallback

- } - `, - childAdditions: js` - export function clientLoader() { - return { message: "Child Client Loader" }; - } - clientLoader.hydrate=true - `, - }), - }); - - // Ensure we SSR the fallbacks - let doc = await fixture.requestDocument("/parent/child"); - let html = await doc.text(); - expect(html).toMatch("Parent Fallback"); - - appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Client Loader"); - expect(html).toMatch("Child Client Loader"); - }); - - test("handles deferred data through client loaders", async ({ page }) => { - let fixture = await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { defer, json } from '@react-router/node' - import { Await, useLoaderData } from "react-router-dom" - export function loader() { - return defer({ - message: 'Child Server Loader', - lazy: new Promise(r => setTimeout(() => r("Child Deferred Data"), 1000)), - }); - } - export async function clientLoader({ serverLoader }) { - let data = await serverLoader(); - return { - ...data, - message: data.message + " (mutated by client)", - }; - } - clientLoader.hydrate = true; - export function HydrateFallback() { - return

Child Fallback

- } - export default function Component() { - let data = useLoaderData(); - return ( - <> -

{data.message}

- Loading Deferred Data...

}> - - {(value) =>

{value}

} -
-
- - ); - } - `, - }, - }); - - // Ensure initial document request contains the child fallback _and_ the - // subsequent streamed/resolved deferred data - let doc = await fixture.requestDocument("/parent/child"); - let html = await doc.text(); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Fallback"); - expect(html).toMatch("Child Deferred Data"); - - appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-deferred-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - // app.goto() doesn't resolve until the document finishes loading so by - // then the HTML has updated via the streamed suspense updates - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Deferred Data"); - }); - - test("allows hydration execution without rendering a fallback", async ({ - page, - }) => { - let fixture = await createTestFixture({ - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientLoader() { - await new Promise(r => setTimeout(r, 100)); - return { message: "Child Client Loader" }; - } - clientLoader.hydrate=true - `, - }), - }); - - appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Child Server Loader"); - await page.waitForSelector(':has-text("Child Client Loader")'); - html = await app.getHtml("main"); - expect(html).toMatch("Child Client Loader"); - }); - - test("HydrateFallback is not rendered if clientLoader.hydrate is not set (w/server loader)", async ({ - page, - }) => { - let fixture = await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { json } from '@react-router/node'; - import { useLoaderData } from "react-router-dom"; - export function loader() { - return json({ - message: "Child Server Loader Data", - }); - } - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - return { - message: "Child Client Loader Data", - }; - } - export function HydrateFallback() { - return

SHOULD NOT SEE ME

- } - export default function Component() { - let data = useLoaderData(); - return

{data.message}

; - } - `, - }, - }); - appFixture = await createAppFixture(fixture); - - // Ensure initial document request contains the child fallback _and_ the - // subsequent streamed/resolved deferred data - let doc = await fixture.requestDocument("/parent/child"); - let html = await doc.text(); - expect(html).toMatch("Child Server Loader Data"); - expect(html).not.toMatch("SHOULD NOT SEE ME"); - - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Child Server Loader Data"); - }); - - test("clientLoader.hydrate is automatically implied when no server loader exists (w HydrateFallback)", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData } from "react-router-dom"; - // Even without setting hydrate=true, this should run on hydration - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - return { - message: "Loader Data (clientLoader only)", - }; - } - export function HydrateFallback() { - return

Child Fallback

- } - export default function Component() { - let data = useLoaderData(); - return

{data.message}

; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Child Fallback"); - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Loader Data (clientLoader only)"); - }); - - test("clientLoader.hydrate is automatically implied when no server loader exists (w/o HydrateFallback)", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData } from "react-router-dom"; - // Even without setting hydrate=true, this should run on hydration - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - return { - message: "Loader Data (clientLoader only)", - }; - } - export default function Component() { - let data = useLoaderData(); - return

{data.message}

; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml(); - expect(html).toMatch( - "💿 Hey developer 👋. You can provide a way better UX than this" - ); - expect(html).not.toMatch("child-data"); - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Loader Data (clientLoader only)"); - }); - - test("throws a 400 if you call serverLoader without a server loader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData, useRouteError } from "react-router-dom"; - export async function clientLoader({ serverLoader }) { - return await serverLoader(); - } - export default function Component() { - return

Child

; - } - export function HydrateFallback() { - return

Loading...

; - } - export function ErrorBoundary() { - let error = useRouteError(); - return

{error.status} {error.data}

; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-error"); - let html = await app.getHtml("#child-error"); - expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( - "400 Error: You are trying to call serverLoader() on a route that does " + - 'not have a server loader (routeId: "routes/parent.child")' - ); - }); - - test("initial hydration data check functions properly", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { json } from '@react-router/node'; - import { useLoaderData, useRevalidator } from "react-router-dom"; - let isFirstCall = true; - export async function loader({ serverLoader }) { - if (isFirstCall) { - isFirstCall = false - return json({ - message: "Child Server Loader Data (1)", - }); - } - return json({ - message: "Child Server Loader Data (2+)", - }); - } - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - let serverData = await serverLoader(); - return { - message: serverData.message + " (mutated by client)", - }; - } - clientLoader.hydrate=true; - export default function Component() { - let data = useLoaderData(); - let revalidator = useRevalidator(); - return ( - <> -

{data.message}

- - - ); - } - export function HydrateFallback() { - return

Loading...

- } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml(); - expect(html).toMatch( - "Child Server Loader Data (1) (mutated by client)" - ); - app.clickElement("button"); - await page.waitForSelector( - ':has-text("Child Server Loader Data (2+)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch( - "Child Server Loader Data (2+) (mutated by client)" - ); - }); - - test("initial hydration data check functions properly even if serverLoader isn't called on hydration", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { json } from '@react-router/node'; - import { useLoaderData, useRevalidator } from "react-router-dom"; - let isFirstCall = true; - export async function loader({ serverLoader }) { - if (isFirstCall) { - isFirstCall = false - return json({ - message: "Child Server Loader Data (1)", - }); - } - return json({ - message: "Child Server Loader Data (2+)", - }); - } - let isFirstClientCall = true; - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - if (isFirstClientCall) { - isFirstClientCall = false; - // First time through - don't even call serverLoader - return { - message: "Child Client Loader Data", - }; - } - // Only call the serverLoader on subsequent calls and this - // should *not* return us the initialData any longer - let serverData = await serverLoader(); - return { - message: serverData.message + " (mutated by client)", - }; - } - clientLoader.hydrate=true; - export default function Component() { - let data = useLoaderData(); - let revalidator = useRevalidator(); - return ( - <> -

{data.message}

- - - ); - } - export function HydrateFallback() { - return

Loading...

- } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml(); - expect(html).toMatch("Child Client Loader Data"); - app.clickElement("button"); - await page.waitForSelector( - ':has-text("Child Server Loader Data (2+)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch( - "Child Server Loader Data (2+) (mutated by client)" - ); - }); - - test("server loader errors are re-thrown from serverLoader()", async ({ - page, - }) => { - let _consoleError = console.error; - console.error = () => {}; - appFixture = await createAppFixture( - await createTestFixture( - { - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import { ClientLoaderFunctionArgs, useRouteError } from "react-router-dom"; - - export function loader() { - throw new Error("Broken!") - } - - export async function clientLoader({ serverLoader }) { - return await serverLoader(); - } - clientLoader.hydrate = true; - - export default function Index() { - return

Should not see me

; - } - - export function ErrorBoundary() { - let error = useRouteError(); - return

{error.message}

; - } - `, - }, - }, - ServerMode.Development // Avoid error sanitization - ), - ServerMode.Development // Avoid error sanitization - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Broken!"); - // Ensure we hydrate and remain on the boundary - await new Promise((r) => setTimeout(r, 100)); - html = await app.getHtml("main"); - expect(html).toMatch("Broken!"); - expect(html).not.toMatch("Should not see me"); - console.error = _consoleError; - }); - }); - - test.describe("clientLoader - lazy route module", () => { - test("no client loaders or fallbacks", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - - // Normal Remix behavior due to lack of clientLoader - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - }); - - test("parent.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader"); - }); - - test("child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - }); - - test("parent.clientLoader/child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader (mutated by client"); - }); - - test("throws a 400 if you call serverLoader without a server loader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData, useRouteError } from "react-router-dom"; - export async function clientLoader({ serverLoader }) { - return await serverLoader(); - } - export default function Component() { - return

Child

; - } - export function HydrateFallback() { - return

Loading...

; - } - export function ErrorBoundary() { - let error = useRouteError(); - return

{error.status} {error.data}

; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-error"); - let html = await app.getHtml("#child-error"); - expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( - "400 Error: You are trying to call serverLoader() on a route that does " + - 'not have a server loader (routeId: "routes/parent.child")' - ); - }); - }); - - test.describe("clientAction - critical route module", () => { - test("child.clientAction", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture( - { - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }, - ServerMode.Development - ), - ServerMode.Development - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/parent.childLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Parent Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Child Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/parent.childLoader/child.clientLoader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); // still revalidating - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Child Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("throws a 400 if you call serverAction without a server action", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { json } from '@react-router/node'; - import { Form, useRouteError } from "react-router-dom"; - export async function clientAction({ serverAction }) { - return await serverAction(); - } - export default function Component() { - return ( -
- -
- ); - } - export function ErrorBoundary() { - let error = useRouteError(); - return

{error.status} {error.data}

; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-error"); - let html = await app.getHtml("#child-error"); - expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( - "400 Error: You are trying to call serverAction() on a route that does " + - 'not have a server action (routeId: "routes/parent.child")' - ); - }); - }); - - test.describe("clientAction - lazy route module", () => { - test("child.clientAction", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/parent.childLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Parent Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Child Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/parent.childLoader/child.clientLoader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: getFiles({ - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); // still revalidating - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Child Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("throws a 400 if you call serverAction without a server action", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createTestFixture({ - files: { - ...getFiles({ - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { json } from '@react-router/node'; - import { Form, useRouteError } from "react-router-dom"; - export async function clientAction({ serverAction }) { - return await serverAction(); - } - export default function Component() { - return ( -
- -
- ); - } - export function ErrorBoundary() { - let error = useRouteError(); - return

{error.status} {error.data}

; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.goto("/parent/child"); - await page.waitForSelector("form"); - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-error"); - let html = await app.getHtml("#child-error"); - expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( - "400 Error: You are trying to call serverAction() on a route that does " + - 'not have a server action (routeId: "routes/parent.child")' - ); - }); - }); - }); -}); diff --git a/integration/defer-loader-test.ts b/integration/defer-loader-test.ts index 0b1d50b361..09575f3335 100644 --- a/integration/defer-loader-test.ts +++ b/integration/defer-loader-test.ts @@ -11,62 +11,63 @@ import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; let fixture: Fixture; let appFixture: AppFixture; -test.describe.skip("deferred loaders", () => { +test.describe("deferred loaders", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/routes/_index.tsx": js` - import { useLoaderData, Link } from "react-router-dom"; - export default function Index() { - return ( -
- Redirect - Direct Promise Access -
- ) - } - `, + import { useLoaderData, Link } from "react-router-dom"; + export default function Index() { + return ( +
+ Redirect + Direct Promise Access +
+ ) + } + `, "app/routes/redirect.tsx": js` - import { defer } from "@react-router/node"; - export function loader() { - return defer({food: "pizza"}, { status: 301, headers: { Location: "/?redirected" } }); - } - export default function Redirect() {return null;} - `, + import { defer } from "@react-router/node"; + export function loader() { + return defer({food: "pizza"}, { status: 301, headers: { Location: "/?redirected" } }); + } + export default function Redirect() {return null;} + `, "app/routes/direct-promise-access.tsx": js` - import * as React from "react"; - import { defer } from "@react-router/node"; - import { useLoaderData, Link, Await } from "react-router-dom"; - export function loader() { - return defer({ - bar: new Promise(async (resolve, reject) => { - resolve("hamburger"); - }), - }); - } - let count = 0; - export default function Index() { - let {bar} = useLoaderData(); - React.useEffect(() => { - let aborted = false; - bar.then((data) => { - if (aborted) return; - document.getElementById("content").innerHTML = data + " " + (++count); - document.getElementById("content").setAttribute("data-done", ""); + import * as React from "react"; + import { defer } from "@react-router/node"; + import { useLoaderData, Link, Await } from "react-router-dom"; + export function loader() { + return defer({ + bar: new Promise(async (resolve, reject) => { + resolve("hamburger"); + }), }); - return () => { - aborted = true; - }; - }, [bar]); - return ( -
- Waiting for client hydration.... -
- ) - } - `, + } + let count = 0; + export default function Index() { + let {bar} = useLoaderData(); + React.useEffect(() => { + let aborted = false; + bar.then((data) => { + if (aborted) return; + document.getElementById("content").innerHTML = data + " " + (++count); + document.getElementById("content").setAttribute("data-done", ""); + }); + return () => { + aborted = true; + }; + }, [bar]); + return ( +
+ Waiting for client hydration.... +
+ ) + } + `, }, }); @@ -99,97 +100,3 @@ test.describe.skip("deferred loaders", () => { expect(await element.innerText()).toMatch("hamburger 1"); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("deferred loaders", () => { - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/routes/_index.tsx": js` - import { useLoaderData, Link } from "react-router-dom"; - export default function Index() { - return ( -
- Redirect - Direct Promise Access -
- ) - } - `, - - "app/routes/redirect.tsx": js` - import { defer } from "@react-router/node"; - export function loader() { - return defer({food: "pizza"}, { status: 301, headers: { Location: "/?redirected" } }); - } - export default function Redirect() {return null;} - `, - - "app/routes/direct-promise-access.tsx": js` - import * as React from "react"; - import { defer } from "@react-router/node"; - import { useLoaderData, Link, Await } from "react-router-dom"; - export function loader() { - return defer({ - bar: new Promise(async (resolve, reject) => { - resolve("hamburger"); - }), - }); - } - let count = 0; - export default function Index() { - let {bar} = useLoaderData(); - React.useEffect(() => { - let aborted = false; - bar.then((data) => { - if (aborted) return; - document.getElementById("content").innerHTML = data + " " + (++count); - document.getElementById("content").setAttribute("data-done", ""); - }); - return () => { - aborted = true; - }; - }, [bar]); - return ( -
- Waiting for client hydration.... -
- ) - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(async () => appFixture.close()); - - test("deferred response can redirect on document request", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/redirect"); - await page.waitForURL(/\?redirected/); - }); - - test("deferred response can redirect on transition", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/redirect"); - await page.waitForURL(/\?redirected/); - }); - - test("can directly access result from deferred promise on document request", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/direct-promise-access"); - let element = await page.waitForSelector("[data-done]"); - expect(await element.innerText()).toMatch("hamburger 1"); - }); - }); -}); diff --git a/integration/defer-test.ts b/integration/defer-test.ts index 6fc6f8dea6..5cbefb05f0 100644 --- a/integration/defer-test.ts +++ b/integration/defer-test.ts @@ -32,12 +32,12 @@ declare global { }; } -test.describe.skip("non-aborted", () => { +test.describe("non-aborted", () => { let fixture: Fixture; let appFixture: AppFixture; test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { + await context.route(/.data/, async (route) => { await new Promise((resolve) => setTimeout(resolve, 50)); route.continue(); }); @@ -45,6 +45,7 @@ test.describe.skip("non-aborted", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/components/counter.tsx": js` import { useState } from "react"; @@ -594,14 +595,19 @@ test.describe.skip("non-aborted", () => { appFixture.close(); }); + function counterHtml(id: string, val: number) { + return `

${val}

`; + } + test("works with critical JSON like data", async ({ page }) => { let response = await fixture.requestDocument("/"); let html = await response.text(); let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(ROOT_ID); - expect(criticalHTML).toContain(INDEX_ID); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(INDEX_ID, 0)); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toBe(""); + expect(deferredHTML).not.toBe(""); + expect(deferredHTML).not.toContain('

{ let response = await fixture.requestDocument("/deferred-noscript-resolved"); let html = await response.text(); let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(ROOT_ID); - expect(criticalHTML).toContain(DEFERRED_ID); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); expect(criticalHTML).not.toContain(FALLBACK_ID); - expect(criticalHTML).toContain(RESOLVED_DEFERRED_ID); + expect(criticalHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toBe(""); + expect(deferredHTML).not.toBe(""); + expect(deferredHTML).not.toContain('

{ ); let html = await response.text(); let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(ROOT_ID); - expect(criticalHTML).toContain(DEFERRED_ID); - expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`

`); expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(RESOLVED_DEFERRED_ID); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); let app = new PlaywrightFixture(appFixture, page); await app.goto("/deferred-noscript-unresolved"); @@ -659,12 +666,13 @@ test.describe.skip("non-aborted", () => { let response = await fixture.requestDocument("/deferred-script-resolved"); let html = await response.text(); let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(ROOT_ID); - expect(criticalHTML).toContain(DEFERRED_ID); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); expect(criticalHTML).not.toContain(FALLBACK_ID); - expect(criticalHTML).toContain(RESOLVED_DEFERRED_ID); + expect(criticalHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toBe(""); + expect(deferredHTML).not.toBe(""); + expect(deferredHTML).not.toContain('

{ let response = await fixture.requestDocument("/deferred-script-unresolved"); let html = await response.text(); let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(ROOT_ID); - expect(criticalHTML).toContain(DEFERRED_ID); - expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`

`); expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(RESOLVED_DEFERRED_ID); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); let app = new PlaywrightFixture(appFixture, page); let assertConsole = monitorConsole(page); @@ -713,12 +721,13 @@ test.describe.skip("non-aborted", () => { let response = await fixture.requestDocument("/deferred-script-rejected"); let html = await response.text(); let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(ROOT_ID); - expect(criticalHTML).toContain(DEFERRED_ID); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); expect(criticalHTML).not.toContain(FALLBACK_ID); - expect(criticalHTML).toContain(ERROR_ID); + expect(criticalHTML).toContain(counterHtml(ERROR_ID, 0)); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toBe(""); + expect(deferredHTML).not.toBe(""); + expect(deferredHTML).not.toContain('

{ let response = await fixture.requestDocument("/deferred-script-unrejected"); let html = await response.text(); let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(ROOT_ID); - expect(criticalHTML).toContain(DEFERRED_ID); - expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`

`); expect(criticalHTML).not.toContain(ERROR_ID); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(ERROR_ID); + expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); let app = new PlaywrightFixture(appFixture, page); let assertConsole = monitorConsole(page); @@ -965,19 +974,19 @@ test.describe.skip("non-aborted", () => { }); test("returns headers on data requests", async ({ page }) => { - let response = await fixture.requestData("/headers", "routes/headers"); + let response = await fixture.requestSingleFetchData("/headers.data"); expect(response.headers.get("x-custom-header")).toEqual( "value from loader" ); }); }); -test.describe.skip("aborted", () => { +test.describe("aborted", () => { let fixture: Fixture; let appFixture: AppFixture; test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { + await context.route(/\.data$/, async (route) => { await new Promise((resolve) => setTimeout(resolve, 50)); route.continue(); }); @@ -985,6 +994,7 @@ test.describe.skip("aborted", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/entry.server.tsx": js` import { PassThrough } from "node:stream"; @@ -994,14 +1004,17 @@ test.describe.skip("aborted", () => { import { isbot } from "isbot"; import { renderToPipeableStream } from "react-dom/server"; - const ABORT_DELAY = 1; + // Exported for use by the server runtime so we can abort the + // turbo-stream encode() call + export const streamTimeout = 250; + const renderTimeout = streamTimeout + 250; export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, - loadContext: AppLoadContext + loadContext: AppLoadContext, ) { return isbot(request.headers.get("user-agent") || "") ? handleBotRequest( @@ -1028,11 +1041,7 @@ test.describe.skip("aborted", () => { let didError = false; let { pipe, abort } = renderToPipeableStream( - , + , { onAllReady() { let body = new PassThrough(); @@ -1060,7 +1069,7 @@ test.describe.skip("aborted", () => { } ); - setTimeout(abort, ABORT_DELAY); + setTimeout(abort, renderTimeout); }); } @@ -1074,11 +1083,7 @@ test.describe.skip("aborted", () => { let didError = false; let { pipe, abort } = renderToPipeableStream( - , + , { onShellReady() { let body = new PassThrough(); @@ -1106,7 +1111,7 @@ test.describe.skip("aborted", () => { } ); - setTimeout(abort, ABORT_DELAY); + setTimeout(abort, renderTimeout); }); } `, @@ -1307,1300 +1312,6 @@ test.describe.skip("aborted", () => { }); }); -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("non-aborted", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeEach(async ({ context }) => { - await context.route(/.data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); - }); - }); - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/components/counter.tsx": js` - import { useState } from "react"; - - export default function Counter({ id }) { - let [count, setCount] = useState(0); - return ( -
- -

{count}

-
- ) - } - `, - "app/components/interactive.tsx": js` - import { useEffect, useState } from "react"; - - export default function Interactive() { - let [interactive, setInteractive] = useState(false); - useEffect(() => { - setInteractive(true); - }, []); - return interactive ? ( -
-

interactive

-
- ) : null; - } - `, - "app/root.tsx": js` - import { defer } from "@react-router/node"; - import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - import Interactive from "~/components/interactive"; - - export const meta: MetaFunction = () => { - return [{ title: "New Remix App" }]; - }; - - export const loader = () => defer({ - id: "${ROOT_ID}", - }); - - export default function Root() { - let { id } = useLoaderData(); - return ( - - - - - - - - -
-

{id}

- - - -
- - {/* Send arbitrary data so safari renders the initial shell before - the document finishes downloading. */} - {Array(10000).fill(null).map((_, i)=>

YOOOOOOOOOO {i}

)} - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { defer } from "@react-router/node"; - import { Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - id: "${INDEX_ID}", - }); - } - - export default function Index() { - let { id } = useLoaderData(); - return ( -
-

{id}

- - -
    -
  • deferred-script-resolved
  • -
  • deferred-script-unresolved
  • -
  • deferred-script-rejected
  • -
  • deferred-script-unrejected
  • -
  • deferred-script-rejected-no-error-element
  • -
  • deferred-script-unrejected-no-error-element
  • -
-
- ); - } - `, - - "app/routes/deferred-noscript-resolved.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - ( -
-

{resolvedDeferredId}

- -
- )} - /> - -
- ); - } - `, - - "app/routes/deferred-noscript-unresolved.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: new Promise( - (resolve) => setTimeout(() => { - resolve("${RESOLVED_DEFERRED_ID}"); - }, 10) - ), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - ( -
-

{resolvedDeferredId}

- -
- )} - /> - -
- ); - } - `, - - "app/routes/deferred-script-resolved.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), - deferredUndefined: Promise.resolve(undefined), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - ( -
-

{resolvedDeferredId}

- -
- )} - /> - -
- ); - } - `, - - "app/routes/deferred-script-unresolved.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: new Promise( - (resolve) => setTimeout(() => { - resolve("${RESOLVED_DEFERRED_ID}"); - }, 10) - ), - deferredUndefined: new Promise( - (resolve) => setTimeout(() => { - resolve(undefined); - }, 10) - ), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - ( -
-

{resolvedDeferredId}

- -
- )} - /> - -
- ); - } - `, - - "app/routes/deferred-script-rejected.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - - error - -
- } - children={(resolvedDeferredId) => ( -
-

{resolvedDeferredId}

- -
- )} - /> - -
- ); - } - `, - - "app/routes/deferred-script-unrejected.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: new Promise( - (_, reject) => setTimeout(() => { - reject(new Error("${RESOLVED_DEFERRED_ID}")); - }, 10) - ), - resolvedUndefined: new Promise( - (resolve) => setTimeout(() => { - resolve(undefined); - }, 10) - ), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId, resolvedUndefined } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - - error - -
- } - children={(resolvedDeferredId) => ( -
-

{resolvedDeferredId}

- -
- )} - /> - - - - error - -
- } - children={(resolvedDeferredId) => ( -
- {"${NEVER_SHOW_ID}"} -
- )} - /> - -
- ); - } - `, - - "app/routes/deferred-script-rejected-no-error-element.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - ( -
-

{resolvedDeferredId}

- -
- )} - /> - -
- ); - } - - export function ErrorBoundary() { - return ( -
- error - -
- ); - } - `, - - "app/routes/deferred-script-unrejected-no-error-element.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: new Promise( - (_, reject) => setTimeout(() => { - reject(new Error("${RESOLVED_DEFERRED_ID}")); - }, 10) - ), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - ( -
-

{resolvedDeferredId}

- -
- )} - /> - - - ); - } - - export function ErrorBoundary() { - return ( -
- error - -
- ); - } - `, - - "app/routes/deferred-manual-resolve.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - global.__deferredManualResolveCache = global.__deferredManualResolveCache || { - nextId: 1, - deferreds: {}, - }; - - let id = "" + global.__deferredManualResolveCache.nextId++; - let promise = new Promise((resolve, reject) => { - global.__deferredManualResolveCache.deferreds[id] = { resolve, reject }; - }); - - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: new Promise( - (resolve) => setTimeout(() => { - resolve("${RESOLVED_DEFERRED_ID}"); - }, 10) - ), - id, - manualValue: promise, - }); - } - - export default function Deferred() { - let { deferredId, resolvedId, id, manualValue } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - ( -
-

{id}

- -
- )} - /> - - manual fallback}> - - error - - - } - children={(value) => ( -
-
{JSON.stringify(value)}
- -
- )} - /> -
- - ); - } - `, - - "app/routes/headers.tsx": js` - import { defer } from "@react-router/node"; - export function loader() { - return defer({}, { headers: { "x-custom-header": "value from loader" } }); - } - export function headers({ loaderHeaders }) { - return { - "x-custom-header": loaderHeaders.get("x-custom-header") - } - } - export default function Component() { - return ( -
Headers
- ) - } - `, - }, - }); - - // This creates an interactive app using playwright. - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - function counterHtml(id: string, val: number) { - return `

${val}

`; - } - - test("works with critical JSON like data", async ({ page }) => { - let response = await fixture.requestDocument("/"); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(INDEX_ID, 0)); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).not.toBe(""); - expect(deferredHTML).not.toContain('

{ - let response = await fixture.requestDocument( - "/deferred-noscript-resolved" - ); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).not.toContain(FALLBACK_ID); - expect(criticalHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).not.toBe(""); - expect(deferredHTML).not.toContain('

{ - let response = await fixture.requestDocument( - "/deferred-noscript-unresolved" - ); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).toContain(`

`); - expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-noscript-unresolved"); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - }); - - test("resolved promises render in initial payload and hydrates", async ({ - page, - }) => { - let response = await fixture.requestDocument("/deferred-script-resolved"); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).not.toContain(FALLBACK_ID); - expect(criticalHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).not.toBe(""); - expect(deferredHTML).not.toContain('

{ - let response = await fixture.requestDocument( - "/deferred-script-unresolved" - ); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).toContain(`

`); - expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); - - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/deferred-script-unresolved", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - - await assertConsole(); - }); - - test("rejected promises render in initial payload and hydrates", async ({ - page, - }) => { - let response = await fixture.requestDocument("/deferred-script-rejected"); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).not.toContain(FALLBACK_ID); - expect(criticalHTML).toContain(counterHtml(ERROR_ID, 0)); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).not.toBe(""); - expect(deferredHTML).not.toContain('

{ - let response = await fixture.requestDocument( - "/deferred-script-unrejected" - ); - let html = await response.text(); - let criticalHTML = html.slice(0, html.indexOf("") + 7); - expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); - expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).toContain(`

`); - expect(criticalHTML).not.toContain(ERROR_ID); - let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); - - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/deferred-script-unrejected", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${ERROR_ID}`); - await page.waitForSelector(`#${UNDEFINED_ERROR_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, ERROR_ID); - await ensureInteractivity(page, UNDEFINED_ERROR_ID); - - await assertConsole(); - }); - - test("rejected promises bubble to ErrorBoundary on hydrate", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-script-rejected-no-error-element", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, ERROR_BOUNDARY_ID); - }); - - test("slow to reject promises bubble to ErrorBoundary on hydrate", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-script-unrejected-no-error-element", true); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, ERROR_BOUNDARY_ID); - }); - - test("routes are interactive when deferred promises are suspended and after resolve in subsequent payload", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - app.goto("/deferred-manual-resolve", false); - - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); - let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - let id = await idElement.innerText(); - expect(id).toBeTruthy(); - - // Ensure the deferred promise is suspended - await page.waitForSelector(`#${MANUAL_RESOLVED_ID}`, { state: "hidden" }); - - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - - global.__deferredManualResolveCache.deferreds[id].resolve("value"); - - await ensureInteractivity(page, MANUAL_RESOLVED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); - await ensureInteractivity(page, DEFERRED_ID, 2); - await ensureInteractivity(page, ROOT_ID, 2); - - await assertConsole(); - }); - - test("routes are interactive when deferred promises are suspended and after rejection in subsequent payload", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/deferred-manual-resolve", false); - - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); - let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); - let id = await idElement.innerText(); - expect(id).toBeTruthy(); - - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - - global.__deferredManualResolveCache.deferreds[id].reject( - new Error("error") - ); - - await ensureInteractivity(page, ROOT_ID, 2); - await ensureInteractivity(page, DEFERRED_ID, 2); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); - await ensureInteractivity(page, MANUAL_ERROR_ID); - - await assertConsole(); - }); - - test("client transition with resolved promises work", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/"); - - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); - - await app.clickLink("/deferred-script-resolved"); - - await ensureInteractivity(page, ROOT_ID, 2); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - - await assertConsole(); - }); - - test("client transition with unresolved promises work", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/"); - - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); - - await app.clickLink("/deferred-script-unresolved"); - - await ensureInteractivity(page, ROOT_ID, 2); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, RESOLVED_DEFERRED_ID); - - await assertConsole(); - }); - - test("client transition with rejected promises work", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/"); - - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); - - app.clickLink("/deferred-script-rejected"); - - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, ERROR_ID); - await ensureInteractivity(page, DEFERRED_ID, 2); - await ensureInteractivity(page, ROOT_ID, 2); - - await assertConsole(); - }); - - test("client transition with unrejected promises work", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - let assertConsole = monitorConsole(page); - await app.goto("/"); - - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); - - await app.clickLink("/deferred-script-unrejected"); - - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, ERROR_ID); - await ensureInteractivity(page, UNDEFINED_ERROR_ID); - await ensureInteractivity(page, DEFERRED_ID, 2); - await ensureInteractivity(page, ROOT_ID, 2); - - await assertConsole(); - }); - - test("client transition with rejected promises bubble to ErrorBoundary", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); - - await app.clickLink("/deferred-script-rejected-no-error-element"); - - await ensureInteractivity(page, ERROR_BOUNDARY_ID); - await ensureInteractivity(page, ROOT_ID, 2); - }); - - test("client transition with unrejected promises bubble to ErrorBoundary", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - await page.waitForSelector("#interactive"); - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, INDEX_ID); - - await app.clickLink("/deferred-script-unrejected-no-error-element"); - - await ensureInteractivity(page, ERROR_BOUNDARY_ID); - await ensureInteractivity(page, ROOT_ID, 2); - }); - - test("returns headers on document requests", async ({ page }) => { - let response = await fixture.requestDocument("/headers"); - expect(response.headers.get("x-custom-header")).toEqual( - "value from loader" - ); - }); - - test("returns headers on data requests", async ({ page }) => { - let response = await fixture.requestSingleFetchData("/headers.data"); - expect(response.headers.get("x-custom-header")).toEqual( - "value from loader" - ); - }); - }); - - test.describe("aborted", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); - }); - }); - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/entry.server.tsx": js` - import { PassThrough } from "node:stream"; - import type { AppLoadContext, EntryContext } from "@react-router/node"; - import { createReadableStreamFromReadable } from "@react-router/node"; - import { RemixServer } from "react-router-dom"; - import { isbot } from "isbot"; - import { renderToPipeableStream } from "react-dom/server"; - - // Exported for use by the server runtime so we can abort the - // turbo-stream encode() call - export const streamTimeout = 250; - const renderTimeout = streamTimeout + 250; - - export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, - loadContext: AppLoadContext, - ) { - return isbot(request.headers.get("user-agent") || "") - ? handleBotRequest( - request, - responseStatusCode, - responseHeaders, - remixContext - ) - : handleBrowserRequest( - request, - responseStatusCode, - responseHeaders, - remixContext - ); - } - - function handleBotRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext - ) { - return new Promise((resolve, reject) => { - let didError = false; - - let { pipe, abort } = renderToPipeableStream( - , - { - onAllReady() { - let body = new PassThrough(); - let stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: didError ? 500 : responseStatusCode, - }) - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - didError = true; - - console.error(error); - }, - } - ); - - setTimeout(abort, renderTimeout); - }); - } - - function handleBrowserRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext - ) { - return new Promise((resolve, reject) => { - let didError = false; - - let { pipe, abort } = renderToPipeableStream( - , - { - onShellReady() { - let body = new PassThrough(); - let stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: didError ? 500 : responseStatusCode, - }) - ); - - pipe(body); - }, - onShellError(err: unknown) { - reject(err); - }, - onError(error: unknown) { - didError = true; - - console.error(error); - }, - } - ); - - setTimeout(abort, renderTimeout); - }); - } - `, - "app/components/counter.tsx": js` - import { useState } from "react"; - - export default function Counter({ id }) { - let [count, setCount] = useState(0); - return ( -
- -

{count}

-
- ) - } - `, - "app/components/interactive.tsx": js` - import { useEffect, useState } from "react"; - - export default function Interactive() { - let [interactive, setInteractive] = useState(false); - useEffect(() => { - setInteractive(true); - }, []); - return interactive ? ( -
-

interactive

-
- ) : null; - } - `, - "app/root.tsx": js` - import { defer } from "@react-router/node"; - import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - import Interactive from "~/components/interactive"; - - export const meta: MetaFunction = () => { - return [{ title: "New Remix App" }]; - }; - - export const loader = () => defer({ - id: "${ROOT_ID}", - }); - - export default function Root() { - let { id } = useLoaderData(); - return ( - - - - - - - - -
-

{id}

- - - -
- - {/* Send arbitrary data so safari renders the initial shell before - the document finishes downloading. */} - {Array(6000).fill(null).map((_, i)=>

YOOOOOOOOOO {i}

)} - - - ); - } - `, - - "app/routes/deferred-server-aborted.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: new Promise( - (resolve) => setTimeout(() => { - resolve("${RESOLVED_DEFERRED_ID}"); - }, 10000) - ), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - - error - -
- } - children={(resolvedDeferredId) => ( -
-

{resolvedDeferredId}

- -
- )} - /> - -
- ); - } - `, - - "app/routes/deferred-server-aborted-no-error-element.tsx": js` - import { Suspense } from "react"; - import { defer } from "@react-router/node"; - import { Await, Link, useLoaderData } from "react-router-dom"; - import Counter from "~/components/counter"; - - export function loader() { - return defer({ - deferredId: "${DEFERRED_ID}", - resolvedId: new Promise( - (resolve) => setTimeout(() => { - resolve("${RESOLVED_DEFERRED_ID}"); - }, 10000) - ), - }); - } - - export default function Deferred() { - let { deferredId, resolvedId } = useLoaderData(); - return ( -
-

{deferredId}

- - fallback
}> - ( -
-

{resolvedDeferredId}

- -
- )} - /> - -
- ); - } - - export function ErrorBoundary() { - return ( -
- error - -
- ); - } - `, - }, - }); - - // This creates an interactive app using playwright. - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("server aborts render the errorElement", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-server-aborted"); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${DEFERRED_ID}`); - await page.waitForSelector(`#${ERROR_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, DEFERRED_ID); - await ensureInteractivity(page, ERROR_ID); - }); - - test("server aborts render the ErrorBoundary when no errorElement", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/deferred-server-aborted-no-error-element"); - await page.waitForSelector(`#${ROOT_ID}`); - await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); - - await ensureInteractivity(page, ROOT_ID); - await ensureInteractivity(page, ERROR_BOUNDARY_ID); - }); - }); -}); - async function ensureInteractivity(page: Page, id: string, expect: number = 1) { await page.waitForSelector("#interactive"); let increment = await page.waitForSelector("#increment-" + id); diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts index 5fc112f7ee..9ef26a308e 100644 --- a/integration/error-boundary-test.ts +++ b/integration/error-boundary-test.ts @@ -9,7 +9,7 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe.skip("ErrorBoundary", () => { +test.describe("ErrorBoundary", () => { let fixture: Fixture; let appFixture: AppFixture; let _consoleError: any; @@ -46,6 +46,7 @@ test.describe.skip("ErrorBoundary", () => { console.error = () => {}; fixture = await createFixture( { + singleFetch: true, files: { "app/root.tsx": js` import { Links, Meta, Outlet, Scripts } from "react-router-dom"; @@ -498,6 +499,7 @@ test.describe.skip("ErrorBoundary", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` import { Links, Meta, Outlet, Scripts } from "react-router-dom"; @@ -655,7 +657,7 @@ test.describe.skip("ErrorBoundary", () => { }); }); -test.describe.skip("loaderData in ErrorBoundary", () => { +test.describe("loaderData in ErrorBoundary", () => { let fixture: Fixture; let appFixture: AppFixture; let consoleErrors: string[]; @@ -663,6 +665,7 @@ test.describe.skip("loaderData in ErrorBoundary", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` import { Links, Meta, Outlet, Scripts } from "react-router-dom"; @@ -873,9 +876,19 @@ test.describe.skip("loaderData in ErrorBoundary", () => { expect(await app.getHtml("#parent-error")).toEqual( '

Broken!

' ); - expect(await app.getHtml("#parent-matches-data")).toEqual( - '

' - ); + if (javaScriptEnabled) { + // This data remains in single fetch with JS because we don't revalidate + // due to the 500 action response + expect(await app.getHtml("#parent-matches-data")).toEqual( + '

PARENT

' + ); + } else { + // But without JS document requests call all loaders up to the + // boundary route so parent's data clears out + expect(await app.getHtml("#parent-matches-data")).toEqual( + '

' + ); + } expect(await app.getHtml("#parent-data")).toEqual( '

' ); @@ -895,7 +908,7 @@ test.describe.skip("loaderData in ErrorBoundary", () => { } }); -test.describe.skip("Default ErrorBoundary", () => { +test.describe("Default ErrorBoundary", () => { let fixture: Fixture; let appFixture: AppFixture; let _consoleError: any; @@ -1011,6 +1024,7 @@ test.describe.skip("Default ErrorBoundary", () => { test.beforeAll(async () => { fixture = await createFixture( { + singleFetch: true, files: getFiles({ includeRootErrorBoundary: false }), }, ServerMode.Development @@ -1082,6 +1096,7 @@ test.describe.skip("Default ErrorBoundary", () => { test.beforeAll(async () => { fixture = await createFixture( { + singleFetch: true, files: getFiles({ includeRootErrorBoundary: true }), }, ServerMode.Development @@ -1147,6 +1162,7 @@ test.describe.skip("Default ErrorBoundary", () => { test.describe("When the root route has a boundary but it also throws 😦", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: getFiles({ includeRootErrorBoundary: true, rootErrorBoundaryThrows: true, @@ -1223,7 +1239,7 @@ test.describe.skip("Default ErrorBoundary", () => { }); }); -test.skip("Allows back-button out of an error boundary after a hard reload", async ({ +test("Allows back-button out of an error boundary after a hard reload", async ({ page, browserName, }) => { @@ -1231,6 +1247,7 @@ test.skip("Allows back-button out of an error boundary after a hard reload", asy console.error = () => {}; let fixture = await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` import { Links, Meta, Outlet, Scripts, useRouteError } from "react-router-dom"; @@ -1329,1362 +1346,3 @@ test.skip("Allows back-button out of an error boundary after a hard reload", asy appFixture.close(); console.error = _consoleError; }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("ErrorBoundary", () => { - let fixture: Fixture; - let appFixture: AppFixture; - let _consoleError: any; - - let ROOT_BOUNDARY_TEXT = "ROOT_BOUNDARY_TEXT"; - let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT"; - - let HAS_BOUNDARY_LOADER = "/yes/loader" as const; - let HAS_BOUNDARY_LOADER_FILE = "/yes.loader" as const; - let HAS_BOUNDARY_ACTION = "/yes/action" as const; - let HAS_BOUNDARY_ACTION_FILE = "/yes.action" as const; - let HAS_BOUNDARY_RENDER = "/yes/render" as const; - let HAS_BOUNDARY_RENDER_FILE = "/yes.render" as const; - let HAS_BOUNDARY_NO_LOADER_OR_ACTION = "/yes/no-loader-or-action" as const; - let HAS_BOUNDARY_NO_LOADER_OR_ACTION_FILE = - "/yes.no-loader-or-action" as const; - - let NO_BOUNDARY_ACTION = "/no/action" as const; - let NO_BOUNDARY_ACTION_FILE = "/no.action" as const; - let NO_BOUNDARY_LOADER = "/no/loader" as const; - let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; - let NO_BOUNDARY_RENDER = "/no/render" as const; - let NO_BOUNDARY_RENDER_FILE = "/no.render" as const; - let NO_BOUNDARY_NO_LOADER_OR_ACTION = "/no/no-loader-or-action" as const; - let NO_BOUNDARY_NO_LOADER_OR_ACTION_FILE = - "/no.no-loader-or-action" as const; - - let NOT_FOUND_HREF = "/not/found"; - - // packages/remix-react/errorBoundaries.tsx - let INTERNAL_ERROR_BOUNDARY_HEADING = "Application Error"; - - test.beforeAll(async () => { - _consoleError = console.error; - console.error = () => {}; - fixture = await createFixture( - { - singleFetch: true, - files: { - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "react-router-dom"; - - export default function Root() { - return ( - - - - - - -
- -
- - - - ); - } - - export function ErrorBoundary() { - return ( - - - -
-
${ROOT_BOUNDARY_TEXT}
-
- - - - ) - } - `, - - "app/routes/_index.tsx": js` - import { Link, Form } from "react-router-dom"; - export default function () { - return ( -
- ${NOT_FOUND_HREF} - -
- - - - -
- - - ${HAS_BOUNDARY_LOADER} - - - ${NO_BOUNDARY_LOADER} - - - ${HAS_BOUNDARY_RENDER} - - - ${NO_BOUNDARY_RENDER} - -
- ) - } - `, - - [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` - import { Form } from "react-router-dom"; - export async function action() { - throw new Error("Kaboom!") - } - export function ErrorBoundary() { - return

${OWN_BOUNDARY_TEXT}

- } - export default function () { - return ( -
- -
- ); - } - `, - - [`app/routes${NO_BOUNDARY_ACTION_FILE}.jsx`]: js` - import { Form } from "react-router-dom"; - export function action() { - throw new Error("Kaboom!") - } - export default function () { - return ( -
- -
- ) - } - `, - - [`app/routes${HAS_BOUNDARY_LOADER_FILE}.jsx`]: js` - export function loader() { - throw new Error("Kaboom!") - } - export function ErrorBoundary() { - return
${OWN_BOUNDARY_TEXT}
- } - export default function () { - return
- } - `, - - [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` - export function loader() { - throw new Error("Kaboom!") - } - export default function () { - return
- } - `, - - [`app/routes${NO_BOUNDARY_RENDER_FILE}.jsx`]: js` - export default function () { - throw new Error("Kaboom!") - return
- } - `, - - [`app/routes${HAS_BOUNDARY_RENDER_FILE}.jsx`]: js` - export default function () { - throw new Error("Kaboom!") - return
- } - - export function ErrorBoundary() { - return
${OWN_BOUNDARY_TEXT}
- } - `, - - [`app/routes${HAS_BOUNDARY_NO_LOADER_OR_ACTION_FILE}.jsx`]: js` - export function ErrorBoundary() { - return
${OWN_BOUNDARY_TEXT}
- } - export default function Index() { - return
- } - `, - - [`app/routes${NO_BOUNDARY_NO_LOADER_OR_ACTION_FILE}.jsx`]: js` - export default function Index() { - return
- } - `, - - "app/routes/fetcher-boundary.tsx": js` - import { useFetcher } from "react-router-dom"; - export function ErrorBoundary() { - return

${OWN_BOUNDARY_TEXT}

- } - export default function() { - let fetcher = useFetcher(); - - return ( -
- -
- ) - } - `, - - "app/routes/fetcher-no-boundary.tsx": js` - import { useFetcher } from "react-router-dom"; - export default function() { - let fetcher = useFetcher(); - - return ( -
- - - -
- ) - } - `, - - "app/routes/action.tsx": js` - import { Outlet, useLoaderData } from "react-router-dom"; - - export function loader() { - return "PARENT"; - } - - export default function () { - return ( -
-

{useLoaderData()}

- -
- ) - } - `, - - "app/routes/action.child-error.tsx": js` - import { Form, useLoaderData, useRouteError } from "react-router-dom"; - - export function loader() { - return "CHILD"; - } - - export function action() { - throw new Error("Broken!"); - } - - export default function () { - return ( - <> -

{useLoaderData()}

-
- -
- - ) - } - - export function ErrorBoundary() { - let error = useRouteError(); - return

{error.message}

; - } - `, - }, - }, - ServerMode.Development - ); - - appFixture = await createAppFixture(fixture, ServerMode.Development); - }); - - test.afterAll(() => { - console.error = _consoleError; - appFixture.close(); - }); - - test("invalid request methods", async () => { - let res = await fixture.requestDocument("/", { method: "OPTIONS" }); - expect(res.status).toBe(405); - expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("own boundary, action, document request", async () => { - let params = new URLSearchParams(); - let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); - expect(res.status).toBe(500); - expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("own boundary, action, client transition from other route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickSubmitButton(HAS_BOUNDARY_ACTION); - await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); - expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("own boundary, action, client transition from itself", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(HAS_BOUNDARY_ACTION); - await app.clickSubmitButton(HAS_BOUNDARY_ACTION); - await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); - expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("bubbles to parent in action document requests", async () => { - let params = new URLSearchParams(); - let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); - expect(res.status).toBe(500); - expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("bubbles to parent in action script transitions from other routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickSubmitButton(NO_BOUNDARY_ACTION); - await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); - expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("bubbles to parent in action script transitions from self", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(NO_BOUNDARY_ACTION); - await app.clickSubmitButton(NO_BOUNDARY_ACTION); - await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); - expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("own boundary, loader, document request", async () => { - let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); - expect(res.status).toBe(500); - expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("own boundary, loader, client transition", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(HAS_BOUNDARY_LOADER); - await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); - expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("bubbles to parent in loader document requests", async () => { - let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); - expect(res.status).toBe(500); - expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("bubbles to parent in loader script transitions from other routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(NO_BOUNDARY_LOADER); - await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); - expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("ssr rendering errors with no boundary", async () => { - let res = await fixture.requestDocument(NO_BOUNDARY_RENDER); - expect(res.status).toBe(500); - expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("script transition rendering errors with no boundary", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(NO_BOUNDARY_RENDER); - await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); - expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("ssr rendering errors with boundary", async () => { - let res = await fixture.requestDocument(HAS_BOUNDARY_RENDER); - expect(res.status).toBe(500); - expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("script transition rendering errors with boundary", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(HAS_BOUNDARY_RENDER); - await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); - expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("uses correct error boundary on server action errors in nested routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/action/child-error`); - expect(await app.getHtml("#parent-data")).toMatch("PARENT"); - expect(await app.getHtml("#child-data")).toMatch("CHILD"); - await page.click("button[type=submit]"); - await page.waitForSelector("#child-error"); - // Preserves parent loader data - expect(await app.getHtml("#parent-data")).toMatch("PARENT"); - expect(await app.getHtml("#child-error")).toMatch("Broken!"); - }); - - test("renders own boundary in fetcher action submission without action from other routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/fetcher-boundary"); - await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); - await page.waitForSelector("#fetcher-boundary"); - }); - - test("renders root boundary in fetcher action submission without action from other routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/fetcher-no-boundary"); - await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); - await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); - }); - - test("renders root boundary in document POST without action requests", async () => { - let res = await fixture.requestDocument(NO_BOUNDARY_NO_LOADER_OR_ACTION, { - method: "post", - }); - expect(res.status).toBe(405); - expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); - }); - - test("renders root boundary in action script transitions without action from other routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); - await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); - }); - - test("renders own boundary in document POST without action requests", async () => { - let res = await fixture.requestDocument( - HAS_BOUNDARY_NO_LOADER_OR_ACTION, - { - method: "post", - } - ); - expect(res.status).toBe(405); - expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("renders own boundary in action script transitions without action from other routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickSubmitButton(HAS_BOUNDARY_NO_LOADER_OR_ACTION); - await page.waitForSelector("#boundary-no-loader-or-action"); - }); - - test.describe("if no error boundary exists in the app", () => { - let NO_ROOT_BOUNDARY_LOADER = "/loader-bad" as const; - let NO_ROOT_BOUNDARY_ACTION = "/action-bad" as const; - let NO_ROOT_BOUNDARY_LOADER_RETURN = "/loader-no-return" as const; - let NO_ROOT_BOUNDARY_ACTION_RETURN = "/action-no-return" as const; - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "react-router-dom"; - - export default function Root() { - return ( - - - - - - - - - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { Link, Form } from "react-router-dom"; - - export default function () { - return ( -
-

Home

- Loader no return -
- - -
-
- ) - } - `, - - [`app/routes${NO_ROOT_BOUNDARY_LOADER}.jsx`]: js` - export async function loader() { - throw Error("BLARGH"); - } - - export default function () { - return ( -
-

Hello

-
- ) - } - `, - - [`app/routes${NO_ROOT_BOUNDARY_ACTION}.jsx`]: js` - export async function action() { - throw Error("YOOOOOOOO WHAT ARE YOU DOING"); - } - - export default function () { - return ( -
-

Goodbye

-
- ) - } - `, - - [`app/routes${NO_ROOT_BOUNDARY_LOADER_RETURN}.jsx`]: js` - import { useLoaderData } from "react-router-dom"; - - export async function loader() {} - - export default function () { - let data = useLoaderData(); - return ( -
-

{data}

-
- ) - } - `, - - [`app/routes${NO_ROOT_BOUNDARY_ACTION_RETURN}.jsx`]: js` - import { useActionData } from "react-router-dom"; - - export async function action() {} - - export default function () { - let data = useActionData(); - return ( -
-

{data}

-
- ) - } - `, - }, - }); - appFixture = await createAppFixture(fixture); - }); - - test("bubbles to internal boundary in loader document requests", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(NO_ROOT_BOUNDARY_LOADER); - expect(await app.getHtml("h1")).toMatch( - INTERNAL_ERROR_BOUNDARY_HEADING - ); - }); - - test("bubbles to internal boundary in action script transitions from other routes", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickSubmitButton(NO_ROOT_BOUNDARY_ACTION); - await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`); - expect(await app.getHtml("h1")).toMatch( - INTERNAL_ERROR_BOUNDARY_HEADING - ); - }); - - test("bubbles to internal boundary if loader doesn't return (document requests)", async () => { - let res = await fixture.requestDocument(NO_ROOT_BOUNDARY_LOADER_RETURN); - expect(res.status).toBe(500); - expect(await res.text()).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); - }); - - test("bubbles to internal boundary if loader doesn't return", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(NO_ROOT_BOUNDARY_LOADER_RETURN); - await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`); - expect(await app.getHtml("h1")).toMatch( - INTERNAL_ERROR_BOUNDARY_HEADING - ); - }); - - test("bubbles to internal boundary if action doesn't return (document requests)", async () => { - let res = await fixture.requestDocument( - NO_ROOT_BOUNDARY_ACTION_RETURN, - { - method: "post", - } - ); - expect(res.status).toBe(500); - expect(await res.text()).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); - }); - - test("bubbles to internal boundary if action doesn't return", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickSubmitButton(NO_ROOT_BOUNDARY_ACTION_RETURN); - await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`); - expect(await app.getHtml("h1")).toMatch( - INTERNAL_ERROR_BOUNDARY_HEADING - ); - }); - }); - }); - - test.describe("loaderData in ErrorBoundary", () => { - let fixture: Fixture; - let appFixture: AppFixture; - let consoleErrors: string[]; - let oldConsoleError: () => void; - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "react-router-dom"; - - export default function Root() { - return ( - - - - - - -
- -
- - - - ); - } - `, - - "app/routes/parent.tsx": js` - import { Outlet, useLoaderData, useMatches, useRouteError } from "react-router-dom"; - - export function loader() { - return "PARENT"; - } - - export default function () { - return ( -
-

{useLoaderData()}

- -
- ) - } - - export function ErrorBoundary() { - let error = useRouteError(); - return ( - <> -

{useLoaderData()}

-

- {useMatches().find(m => m.id === 'routes/parent').data} -

-

{error.message}

- - ); - } - `, - - "app/routes/parent.child-with-boundary.tsx": js` - import { Form, useLoaderData, useRouteError } from "react-router-dom"; - - export function loader() { - return "CHILD"; - } - - export function action() { - throw new Error("Broken!"); - } - - export default function () { - return ( - <> -

{useLoaderData()}

-
- -
- - ) - } - - export function ErrorBoundary() { - let error = useRouteError(); - return ( - <> -

{useLoaderData()}

-

{error.message}

- - ); - } - `, - - "app/routes/parent.child-without-boundary.tsx": js` - import { Form, useLoaderData } from "react-router-dom"; - - export function loader() { - return "CHILD"; - } - - export function action() { - throw new Error("Broken!"); - } - - export default function () { - return ( - <> -

{useLoaderData()}

-
- -
- - ) - } - `, - }, - }); - - appFixture = await createAppFixture(fixture, ServerMode.Development); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test.beforeEach(({ page }) => { - oldConsoleError = console.error; - console.error = () => {}; - consoleErrors = []; - // Listen for all console events and handle errors - page.on("console", (msg) => { - if (msg.type() === "error") { - consoleErrors.push(msg.text()); - } - }); - }); - - test.afterEach(() => { - console.error = oldConsoleError; - }); - - test.describe("without JavaScript", () => { - test.use({ javaScriptEnabled: false }); - runBoundaryTests(); - }); - - test.describe("with JavaScript", () => { - test.use({ javaScriptEnabled: true }); - runBoundaryTests(); - }); - - function runBoundaryTests() { - test("Prevents useLoaderData in self ErrorBoundary", async ({ - page, - javaScriptEnabled, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child-with-boundary"); - - expect(await app.getHtml("#parent-data")).toEqual( - '

PARENT

' - ); - expect(await app.getHtml("#child-data")).toEqual( - '

CHILD

' - ); - expect(consoleErrors).toEqual([]); - - await app.clickSubmitButton("/parent/child-with-boundary"); - await page.waitForSelector("#child-error"); - - expect(await app.getHtml("#child-error")).toEqual( - '

Broken!

' - ); - expect(await app.getHtml("#parent-data")).toEqual( - '

PARENT

' - ); - expect(await app.getHtml("#child-data")).toEqual( - '

' - ); - - // Only look for this message. Chromium browsers will also log the - // network error but firefox does not - // "Failed to load resource: the server responded with a status of 500 (Internal Server Error)", - let msg = - "You cannot `useLoaderData` in an errorElement (routeId: routes/parent.child-with-boundary)"; - if (javaScriptEnabled) { - expect(consoleErrors.filter((m) => m === msg)).toEqual([msg]); - } else { - // We don't get the useLoaderData message in the client when JS is disabled - expect(consoleErrors.filter((m) => m === msg)).toEqual([]); - } - }); - - test("Prevents useLoaderData in bubbled ErrorBoundary", async ({ - page, - javaScriptEnabled, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child-without-boundary"); - - expect(await app.getHtml("#parent-data")).toEqual( - '

PARENT

' - ); - expect(await app.getHtml("#child-data")).toEqual( - '

CHILD

' - ); - expect(consoleErrors).toEqual([]); - - await app.clickSubmitButton("/parent/child-without-boundary"); - await page.waitForSelector("#parent-error"); - - expect(await app.getHtml("#parent-error")).toEqual( - '

Broken!

' - ); - if (javaScriptEnabled) { - // This data remains in single fetch with JS because we don't revalidate - // due to the 500 action response - expect(await app.getHtml("#parent-matches-data")).toEqual( - '

PARENT

' - ); - } else { - // But without JS document requests call all loaders up to the - // boundary route so parent's data clears out - expect(await app.getHtml("#parent-matches-data")).toEqual( - '

' - ); - } - expect(await app.getHtml("#parent-data")).toEqual( - '

' - ); - - // Only look for this message. Chromium browsers will also log the - // network error but firefox does not - // "Failed to load resource: the server responded with a status of 500 (Internal Server Error)", - let msg = - "You cannot `useLoaderData` in an errorElement (routeId: routes/parent)"; - if (javaScriptEnabled) { - expect(consoleErrors.filter((m) => m === msg)).toEqual([msg]); - } else { - // We don't get the useLoaderData message in the client when JS is disabled - expect(consoleErrors.filter((m) => m === msg)).toEqual([]); - } - }); - } - }); - - test.describe("Default ErrorBoundary", () => { - let fixture: Fixture; - let appFixture: AppFixture; - let _consoleError: any; - - function getFiles({ - includeRootErrorBoundary = false, - rootErrorBoundaryThrows = false, - } = {}) { - let errorBoundaryCode = !includeRootErrorBoundary - ? "" - : rootErrorBoundaryThrows - ? js` - export function ErrorBoundary() { - let error = useRouteError(); - return ( - - - -
-
Root Error Boundary
-

{error.message}

-

{oh.no.what.have.i.done}

-
- - - - ) - } - ` - : js` - export function ErrorBoundary() { - let error = useRouteError(); - return ( - - - -
-
Root Error Boundary
-

{error.message}

-
- - - - ) - } - `; - - return { - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts, useRouteError } from "react-router-dom"; - - export default function Root() { - return ( - - - - - - -
- -
- - - - ); - } - - ${errorBoundaryCode} - `, - - "app/routes/_index.tsx": js` - import { Link } from "react-router-dom"; - export default function () { - return ( -
-

Index

- Loader Error - Render Error -
- ); - } - `, - - "app/routes/loader-error.tsx": js` - export function loader() { - throw new Error('Loader Error'); - } - export default function () { - return

Loader Error

- } - `, - - "app/routes/render-error.tsx": js` - export default function () { - throw new Error("Render Error") - } - `, - }; - } - - test.beforeAll(async () => { - _consoleError = console.error; - console.error = () => {}; - }); - - test.afterAll(async () => { - console.error = _consoleError; - appFixture.close(); - }); - - test.describe("When the root route does not have a boundary", () => { - test.beforeAll(async () => { - fixture = await createFixture( - { - singleFetch: true, - files: getFiles({ includeRootErrorBoundary: false }), - }, - ServerMode.Development - ); - appFixture = await createAppFixture(fixture, ServerMode.Development); - }); - - test.afterAll(() => appFixture.close()); - - test.describe("document requests", () => { - test("renders default boundary on loader errors", async () => { - let res = await fixture.requestDocument("/loader-error"); - expect(res.status).toBe(500); - let text = await res.text(); - expect(text).toMatch("Application Error"); - expect(text).toMatch("Loader Error"); - expect(text).not.toMatch("Root Error Boundary"); - }); - - test("renders default boundary on render errors", async () => { - let res = await fixture.requestDocument("/render-error"); - expect(res.status).toBe(500); - let text = await res.text(); - expect(text).toMatch("Application Error"); - expect(text).toMatch("Render Error"); - expect(text).not.toMatch("Root Error Boundary"); - }); - }); - - test.describe("SPA navigations", () => { - test("renders default boundary on loader errors", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/loader-error"); - await page.waitForSelector("pre"); - let html = await app.getHtml(); - expect(html).toMatch("Application Error"); - expect(html).toMatch("Loader Error"); - expect(html).not.toMatch("Root Error Boundary"); - - // Ensure we can click back to our prior page - await app.goBack(); - await page.waitForSelector("h1#index"); - }); - - test("renders default boundary on render errors", async ({ - page, - }, workerInfo) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/render-error"); - await page.waitForSelector("pre"); - let html = await app.getHtml(); - expect(html).toMatch("Application Error"); - // Chromium seems to be the only one that includes the message in the stack - if (workerInfo.project.name === "chromium") { - expect(html).toMatch("Render Error"); - } - expect(html).not.toMatch("Root Error Boundary"); - - // Ensure we can click back to our prior page - await app.goBack(); - await page.waitForSelector("h1#index"); - }); - }); - }); - - test.describe("When the root route has a boundary", () => { - test.beforeAll(async () => { - fixture = await createFixture( - { - singleFetch: true, - files: getFiles({ includeRootErrorBoundary: true }), - }, - ServerMode.Development - ); - appFixture = await createAppFixture(fixture, ServerMode.Development); - }); - - test.afterAll(() => appFixture.close()); - - test.describe("document requests", () => { - test("renders root boundary on loader errors", async () => { - let res = await fixture.requestDocument("/loader-error"); - expect(res.status).toBe(500); - let text = await res.text(); - expect(text).toMatch("Root Error Boundary"); - expect(text).toMatch("Loader Error"); - expect(text).not.toMatch("Application Error"); - }); - - test("renders root boundary on render errors", async () => { - let res = await fixture.requestDocument("/render-error"); - expect(res.status).toBe(500); - let text = await res.text(); - expect(text).toMatch("Root Error Boundary"); - expect(text).toMatch("Render Error"); - expect(text).not.toMatch("Application Error"); - }); - }); - - test.describe("SPA navigations", () => { - test("renders root boundary on loader errors", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/loader-error"); - await page.waitForSelector("#root-error-boundary"); - let html = await app.getHtml(); - expect(html).toMatch("Root Error Boundary"); - expect(html).toMatch("Loader Error"); - expect(html).not.toMatch("Application Error"); - - // Ensure we can click back to our prior page - await app.goBack(); - await page.waitForSelector("h1#index"); - }); - - test("renders root boundary on render errors", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/render-error"); - await page.waitForSelector("#root-error-boundary"); - let html = await app.getHtml(); - expect(html).toMatch("Root Error Boundary"); - expect(html).toMatch("Render Error"); - expect(html).not.toMatch("Application Error"); - - // Ensure we can click back to our prior page - await app.goBack(); - await page.waitForSelector("h1#index"); - }); - }); - }); - - test.describe("When the root route has a boundary but it also throws 😦", () => { - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: getFiles({ - includeRootErrorBoundary: true, - rootErrorBoundaryThrows: true, - }), - }); - appFixture = await createAppFixture(fixture, ServerMode.Development); - }); - - test.afterAll(() => appFixture.close()); - - test.describe("document requests", () => { - test("tries to render root boundary on loader errors but bubbles to default boundary", async () => { - let res = await fixture.requestDocument("/loader-error"); - expect(res.status).toBe(500); - let text = await res.text(); - expect(text).toMatch("Unexpected Server Error"); - expect(text).not.toMatch("Application Error"); - expect(text).not.toMatch("Loader Error"); - expect(text).not.toMatch("Root Error Boundary"); - }); - - test("tries to render root boundary on render errors but bubbles to default boundary", async () => { - let res = await fixture.requestDocument("/render-error"); - expect(res.status).toBe(500); - let text = await res.text(); - expect(text).toMatch("Unexpected Server Error"); - expect(text).not.toMatch("Application Error"); - expect(text).not.toMatch("Render Error"); - expect(text).not.toMatch("Root Error Boundary"); - }); - }); - - test.describe("SPA navigations", () => { - test("tries to render root boundary on loader errors but bubbles to default boundary", async ({ - page, - }, workerInfo) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/loader-error"); - await page.waitForSelector("pre"); - let html = await app.getHtml(); - expect(html).toMatch("Application Error"); - if (workerInfo.project.name === "chromium") { - expect(html).toMatch("ReferenceError: oh is not defined"); - } - expect(html).not.toMatch("Loader Error"); - expect(html).not.toMatch("Root Error Boundary"); - - // Ensure we can click back to our prior page - await app.goBack(); - await page.waitForSelector("h1#index"); - }); - - test("tries to render root boundary on render errors but bubbles to default boundary", async ({ - page, - }, workerInfo) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/render-error"); - await page.waitForSelector("pre"); - let html = await app.getHtml(); - expect(html).toMatch("Application Error"); - if (workerInfo.project.name === "chromium") { - expect(html).toMatch("ReferenceError: oh is not defined"); - } - expect(html).not.toMatch("Render Error"); - expect(html).not.toMatch("Root Error Boundary"); - - // Ensure we can click back to our prior page - await app.goBack(); - await page.waitForSelector("h1#index"); - }); - }); - }); - }); - - test("Allows back-button out of an error boundary after a hard reload", async ({ - page, - browserName, - }) => { - let _consoleError = console.error; - console.error = () => {}; - - let fixture = await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts, useRouteError } from "react-router-dom"; - - export default function App() { - return ( - - - - - - - - - - - ); - } - - export function ErrorBoundary() { - let error = useRouteError(); - return ( - - - Oh no! - - - - -

ERROR BOUNDARY

- - - - ); - } - `, - "app/routes/_index.tsx": js` - import { Link } from "react-router-dom"; - - export default function Index() { - return ( -
-

INDEX

- This will error -
- ); - } - `, - - "app/routes/boom.tsx": js` - import { json } from "@react-router/node"; - export function loader() { return boom(); } - export default function() { return my page; } - `, - }, - }); - - let appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/"); - await page.waitForSelector("#index"); - expect(app.page.url()).not.toMatch("/boom"); - - await app.clickLink("/boom"); - await page.waitForSelector("#error"); - expect(app.page.url()).toMatch("/boom"); - - await app.reload(); - await page.waitForSelector("#error"); - expect(app.page.url()).toMatch("boom"); - - await app.goBack(); - - // Here be dragons - // - Playwright sets the Firefox `fission.webContentIsolationStrategy=0` preference - // for reasons having to do with out-of-process iframes: - // https://github.com/microsoft/playwright/issues/22640#issuecomment-1543287282 - // - That preference exposes a bug in firefox where a hard reload adds to the - // history stack: https://bugzilla.mozilla.org/show_bug.cgi?id=1832341 - // - Your can disable this preference via the Playwright `firefoxUserPrefs` config, - // but that is broken until 1.34: - // https://github.com/microsoft/playwright/issues/22640#issuecomment-1546230104 - // https://github.com/microsoft/playwright/issues/15405 - // - We can't yet upgrade to 1.34 because it drops support for Node 14: - // https://github.com/microsoft/playwright/releases/tag/v1.34.0 - // - // So for now when in firefox we just navigate back twice to work around the issue - if (browserName === "firefox") { - await app.goBack(); - } - - await page.waitForSelector("#index"); - expect(app.page.url()).not.toContain("boom"); - - appFixture.close(); - console.error = _consoleError; - }); -}); diff --git a/integration/error-boundary-v2-test.ts b/integration/error-boundary-v2-test.ts index e9fad82de3..f037fc3584 100644 --- a/integration/error-boundary-v2-test.ts +++ b/integration/error-boundary-v2-test.ts @@ -10,13 +10,14 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe.skip("ErrorBoundary", () => { +test.describe("ErrorBoundary", () => { let fixture: Fixture; let appFixture: AppFixture; let oldConsoleError: () => void; test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` import { Links, Meta, Outlet, Scripts } from "react-router-dom"; @@ -166,16 +167,15 @@ test.describe.skip("ErrorBoundary", () => { test("Network errors that never reach the Remix server", async ({ page, }) => { - // Cause a ?_data request to trigger an HTTP error that never reaches the + // Cause a .data request to trigger an HTTP error that never reaches the // Remix server, and ensure we properly handle it at the ErrorBoundary - await page.route( - "**/parent/child-with-boundary?_data=routes%2Fparent.child-with-boundary", - (route) => route.fulfill({ status: 500, body: "CDN Error!" }) - ); + await page.route(/\/parent\/child-with-boundary\.data$/, (route) => { + route.fulfill({ status: 500, body: "CDN Error!" }); + }); let app = new PlaywrightFixture(appFixture, page); await app.goto("/parent"); await app.clickLink("/parent/child-with-boundary"); - await waitForAndAssert(page, app, "#child-error", "CDN Error!"); + await waitForAndAssert(page, app, "#child-error", "CDN Error"); }); }); @@ -241,241 +241,6 @@ test.describe.skip("ErrorBoundary", () => { } }); -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("ErrorBoundary", () => { - let fixture: Fixture; - let appFixture: AppFixture; - let oldConsoleError: () => void; - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "react-router-dom"; - - export default function Root() { - return ( - - - - - - -
- -
- - - - ); - } - `, - - "app/routes/parent.tsx": js` - import { - Link, - Outlet, - isRouteErrorResponse, - useLoaderData, - useRouteError, - } from "react-router-dom"; - - export function loader() { - return "PARENT LOADER"; - } - - export default function Component() { - return ( -
- -

{useLoaderData()}

- -
- ) - } - - export function ErrorBoundary() { - let error = useRouteError(); - return isRouteErrorResponse(error) ? -

{error.status + ' ' + error.data}

: -

{error.message}

; - } - `, - - "app/routes/parent.child-with-boundary.tsx": js` - import { - isRouteErrorResponse, - useLoaderData, - useLocation, - useRouteError, - } from "react-router-dom"; - - export function loader({ request }) { - let errorType = new URL(request.url).searchParams.get('type'); - if (errorType === 'response') { - throw new Response('Loader Response', { status: 418 }); - } else if (errorType === 'error') { - throw new Error('Loader Error'); - } - return "CHILD LOADER"; - } - - export default function Component() {; - let data = useLoaderData(); - if (new URLSearchParams(useLocation().search).get('type') === "render") { - throw new Error("Render Error"); - } - return

{data}

; - } - - export function ErrorBoundary() { - let error = useRouteError(); - return isRouteErrorResponse(error) ? -

{error.status + ' ' + error.data}

: -

{error.message}

; - } - `, - - "app/routes/parent.child-without-boundary.tsx": js` - import { useLoaderData, useLocation } from "react-router-dom"; - - export function loader({ request }) { - let errorType = new URL(request.url).searchParams.get('type'); - if (errorType === 'response') { - throw new Response('Loader Response', { status: 418 }); - } else if (errorType === 'error') { - throw new Error('Loader Error'); - } - return "CHILD LOADER"; - } - - export default function Component() {; - let data = useLoaderData(); - if (new URLSearchParams(useLocation().search).get('type') === "render") { - throw new Error("Render Error"); - } - return

{data}

; - } - `, - }, - }); - - appFixture = await createAppFixture(fixture, ServerMode.Development); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test.beforeEach(({ page }) => { - oldConsoleError = console.error; - console.error = () => {}; - }); - - test.afterEach(() => { - console.error = oldConsoleError; - }); - - test.describe("without JavaScript", () => { - test.use({ javaScriptEnabled: false }); - runBoundaryTests(); - }); - - test.describe("with JavaScript", () => { - test.use({ javaScriptEnabled: true }); - runBoundaryTests(); - - test("Network errors that never reach the Remix server", async ({ - page, - }) => { - // Cause a ?_data request to trigger an HTTP error that never reaches the - // Remix server, and ensure we properly handle it at the ErrorBoundary - await page.route(/\/parent\/child-with-boundary\.data$/, (route) => { - route.fulfill({ status: 500, body: "CDN Error!" }); - }); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent"); - await app.clickLink("/parent/child-with-boundary"); - await waitForAndAssert(page, app, "#child-error", "CDN Error"); - }); - }); - - function runBoundaryTests() { - test("No errors", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent"); - await app.clickLink("/parent/child-with-boundary"); - await waitForAndAssert(page, app, "#child-data", "CHILD LOADER"); - }); - - test("Throwing a Response to own boundary", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent"); - await app.clickLink("/parent/child-with-boundary?type=response"); - await waitForAndAssert( - page, - app, - "#child-error-response", - "418 Loader Response" - ); - }); - - test("Throwing an Error to own boundary", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent"); - await app.clickLink("/parent/child-with-boundary?type=error"); - await waitForAndAssert(page, app, "#child-error", "Loader Error"); - }); - - test("Throwing a render error to own boundary", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent"); - await app.clickLink("/parent/child-with-boundary?type=render"); - await waitForAndAssert(page, app, "#child-error", "Render Error"); - }); - - test("Throwing a Response to parent boundary", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent"); - await app.clickLink("/parent/child-without-boundary?type=response"); - await waitForAndAssert( - page, - app, - "#parent-error-response", - "418 Loader Response" - ); - }); - - test("Throwing an Error to parent boundary", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent"); - await app.clickLink("/parent/child-without-boundary?type=error"); - await waitForAndAssert(page, app, "#parent-error", "Loader Error"); - }); - - test("Throwing a render error to parent boundary", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent"); - await app.clickLink("/parent/child-without-boundary?type=render"); - await waitForAndAssert(page, app, "#parent-error", "Render Error"); - }); - } - }); -}); - // Shorthand util to wait for an element to appear before asserting it async function waitForAndAssert( page: Page, diff --git a/integration/error-data-request-test.ts b/integration/error-data-request-test.ts index 0ec1c1464f..7e86572431 100644 --- a/integration/error-data-request-test.ts +++ b/integration/error-data-request-test.ts @@ -8,35 +8,36 @@ import { } from "./helpers/create-fixture.js"; import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; -test.describe.skip("ErrorBoundary", () => { +test.describe("ErrorBoundary", () => { let fixture: Fixture; let appFixture: AppFixture; let _consoleError: any; - let errorLogs: any[] = []; + let errorLogs: any[]; test.beforeAll(async () => { _consoleError = console.error; console.error = (v) => errorLogs.push(v); fixture = await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` import { Links, Meta, Outlet, Scripts } from "react-router-dom"; - export default function Root() { - return ( - - - - - - - - - - - ); - } + export default function Root() { + return ( + + + + + + + + + + + ); + } `, "app/routes/_index.tsx": js` @@ -110,38 +111,48 @@ test.describe.skip("ErrorBoundary", () => { expect(error.message).toEqual(message); } - test("returns a 400 x-remix-error on a data fetch to a path with no loader", async () => { - let response = await fixture.requestData("/", "routes/_index"); - expect(response.status).toBe(400); - expect(response.headers.get("X-Remix-Error")).toBe("yes"); - expect(await response.text()).toMatch("Unexpected Server Error"); - expect(errorLogs[0]).toBeInstanceOf(Error); - assertLoggedErrorInstance( - 'You made a GET request to "/" but did not provide a `loader` for route "routes/_index", so there is no way to handle the request.' + test("returns a 200 empty response on a data fetch to a path with no loaders", async () => { + let { status, headers, data } = await fixture.requestSingleFetchData( + "/_root.data" ); + expect(status).toBe(200); + expect(headers.has("X-Remix-Error")).toBe(false); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: null, + }, + }); }); - test("returns a 405 x-remix-error on a data fetch POST to a path with no action", async () => { - let response = await fixture.requestData("/?index", "routes/_index", { - method: "POST", + test("returns a 405 on a data fetch POST to a path with no action", async () => { + let { status, headers, data } = await fixture.requestSingleFetchData( + "/_root.data?index", + { + method: "POST", + } + ); + expect(status).toBe(405); + expect(headers.has("X-Remix-Error")).toBe(false); + expect(data).toEqual({ + error: new ErrorResponseImpl( + 405, + "Method Not Allowed", + 'Error: You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' + ), }); - expect(response.status).toBe(405); - expect(response.headers.get("X-Remix-Error")).toBe("yes"); - expect(await response.text()).toMatch("Unexpected Server Error"); assertLoggedErrorInstance( 'You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' ); }); - test("returns a 405 x-remix-error on a data fetch with a bad method", async () => { + test("returns a 405 on a data fetch with a bad method", async () => { try { - await fixture.requestData( - "/loader-return-json", - "routes/loader-return-json", - { - method: "TRACE", - } - ); + await fixture.requestSingleFetchData("/loader-return-json.data", { + method: "TRACE", + }); expect(false).toBe(true); } catch (e) { expect((e as Error).message).toMatch( @@ -150,210 +161,21 @@ test.describe.skip("ErrorBoundary", () => { } }); - test("returns a 403 x-remix-error on a data fetch GET to a bad path", async () => { - // just headers content-type mismatch but differs from POST below - let response = await fixture.requestData("/", "routes/loader-return-json"); - expect(response.status).toBe(403); - expect(response.headers.get("X-Remix-Error")).toBe("yes"); - expect(await response.text()).toMatch("Unexpected Server Error"); - assertLoggedErrorInstance( - 'Route "routes/loader-return-json" does not match URL "/"' - ); - }); - - test("returns a 403 x-remix-error on a data fetch POST to a bad path", async () => { - let response = await fixture.requestData("/", "routes/loader-return-json", { - method: "POST", - }); - expect(response.status).toBe(403); - expect(response.headers.get("X-Remix-Error")).toBe("yes"); - expect(await response.text()).toMatch("Unexpected Server Error"); - assertLoggedErrorInstance( - 'Route "routes/loader-return-json" does not match URL "/"' + test("returns a 404 on a data fetch to a path with no matches", async () => { + let { status, headers, data } = await fixture.requestSingleFetchData( + "/i/match/nothing.data" ); - }); - - test("returns a 404 x-remix-error on a data fetch to a path with no matches", async () => { - let response = await fixture.requestData("/i/match/nothing", "routes/junk"); - expect(response.status).toBe(404); - expect(response.headers.get("X-Remix-Error")).toBe("yes"); - expect(await response.text()).toMatch("Unexpected Server Error"); - assertLoggedErrorInstance('No route matches URL "/i/match/nothing"'); - }); -}); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("ErrorBoundary", () => { - let fixture: Fixture; - let appFixture: AppFixture; - let _consoleError: any; - let errorLogs: any[]; - - test.beforeAll(async () => { - _consoleError = console.error; - console.error = (v) => errorLogs.push(v); - - fixture = await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "react-router-dom"; - - export default function Root() { - return ( - - - - - - - - - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { Link, Form } from "react-router-dom"; - - export default function () { - return

Index

- } - `, - - [`app/routes/loader-throw-error.jsx`]: js` - export async function loader() { - throw Error("BLARGH"); - } - - export default function () { - return

Hello

- } - `, - - [`app/routes/loader-return-json.jsx`]: js` - import { json } from "@react-router/server-runtime"; - - export async function loader() { - return json({ ok: true }); - } - - export default function () { - return

Hello

- } - `, - - [`app/routes/action-throw-error.jsx`]: js` - export async function action() { - throw Error("YOOOOOOOO WHAT ARE YOU DOING"); - } - - export default function () { - return

Goodbye

; - } - `, - - [`app/routes/action-return-json.jsx`]: js` - import { json } from "@react-router/server-runtime"; - - export async function action() { - return json({ ok: true }); - } - - export default function () { - return

Hi!

- } - `, - }, - }); - appFixture = await createAppFixture(fixture); - }); - - test.beforeEach(async () => { - errorLogs = []; - }); - - test.afterAll(() => { - console.error = _consoleError; - appFixture.close(); - }); - - function assertLoggedErrorInstance(message: string) { - let error = errorLogs[0] as Error; - expect(error).toBeInstanceOf(Error); - expect(error.message).toEqual(message); - } - - test("returns a 200 empty response on a data fetch to a path with no loaders", async () => { - let { status, headers, data } = await fixture.requestSingleFetchData( - "/_root.data" - ); - expect(status).toBe(200); - expect(headers.has("X-Remix-Error")).toBe(false); - expect(data).toEqual({ - root: { - data: null, - }, - "routes/_index": { - data: null, - }, - }); - }); - - test("returns a 405 on a data fetch POST to a path with no action", async () => { - let { status, headers, data } = await fixture.requestSingleFetchData( - "/_root.data?index", - { - method: "POST", - } - ); - expect(status).toBe(405); - expect(headers.has("X-Remix-Error")).toBe(false); - expect(data).toEqual({ + expect(status).toBe(404); + expect(headers.has("X-Remix-Error")).toBe(false); + expect(data).toEqual({ + root: { error: new ErrorResponseImpl( - 405, - "Method Not Allowed", - 'Error: You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' + 404, + "Not Found", + 'Error: No route matches URL "/i/match/nothing"' ), - }); - assertLoggedErrorInstance( - 'You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' - ); - }); - - test("returns a 405 on a data fetch with a bad method", async () => { - try { - await fixture.requestSingleFetchData("/loader-return-json.data", { - method: "TRACE", - }); - expect(false).toBe(true); - } catch (e) { - expect((e as Error).message).toMatch( - "'TRACE' HTTP method is unsupported." - ); - } - }); - - test("returns a 404 on a data fetch to a path with no matches", async () => { - let { status, headers, data } = await fixture.requestSingleFetchData( - "/i/match/nothing.data" - ); - expect(status).toBe(404); - expect(headers.has("X-Remix-Error")).toBe(false); - expect(data).toEqual({ - root: { - error: new ErrorResponseImpl( - 404, - "Not Found", - 'Error: No route matches URL "/i/match/nothing"' - ), - }, - }); - assertLoggedErrorInstance('No route matches URL "/i/match/nothing"'); + }, }); + assertLoggedErrorInstance('No route matches URL "/i/match/nothing"'); }); }); diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 13ba7f622a..0d590ed9b1 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -138,7 +138,7 @@ const routeFiles = { `, }; -test.describe.skip("Error Sanitization", () => { +test.describe("Error Sanitization", () => { let fixture: Fixture; let oldConsoleError: () => void; let errorLogs: any[] = []; @@ -157,6 +157,7 @@ test.describe.skip("Error Sanitization", () => { test.beforeAll(async () => { fixture = await createFixture( { + singleFetch: true, files: routeFiles, }, ServerMode.Production @@ -178,10 +179,12 @@ test.describe.skip("Error Sanitization", () => { expect(html).toMatch("Index Error"); expect(html).not.toMatch("LOADER"); expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value expect(html).toMatch( - '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' ); - expect(html).not.toMatch(/stack/i); + expect(html).not.toMatch(/ at /i); expect(errorLogs.length).toBe(1); expect(errorLogs[0][0].message).toMatch("Loader Error"); expect(errorLogs[0][0].stack).toMatch(" at "); @@ -192,10 +195,12 @@ test.describe.skip("Error Sanitization", () => { let html = await response.text(); expect(html).toMatch("Index Error"); expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value expect(html).toMatch( - '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' ); - expect(html).not.toMatch(/stack/i); + expect(html).not.toMatch(/ at /i); expect(errorLogs.length).toBe(1); expect(errorLogs[0][0].message).toMatch("Render Error"); expect(errorLogs[0][0].stack).toMatch(" at "); @@ -217,47 +222,56 @@ test.describe.skip("Error Sanitization", () => { let html = await response.text(); expect(html).toMatch("Defer Error"); expect(html).not.toMatch("RESOLVED"); - expect(html).toMatch('{"message":"Unexpected Server Error"}'); - // Defer errors are not not part of the JSON blob but rather rejected - // against a pending promise and therefore are inlined JS. - expect(html).toMatch("x.stack=undefined;"); + expect(html).toMatch("Unexpected Server Error"); + expect(html).not.toMatch("stack"); // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); }); test("returns data without errors", async () => { - let response = await fixture.requestData("/", "routes/_index"); - let text = await response.text(); - expect(text).toMatch("LOADER"); - expect(text).not.toMatch("MESSAGE:"); - expect(text).not.toMatch(/stack/i); + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); }); test("sanitizes loader errors in data requests", async () => { - let response = await fixture.requestData("/?loader", "routes/_index"); - let text = await response.text(); - expect(text).toBe('{"message":"Unexpected Server Error"}'); + let { data } = await fixture.requestSingleFetchData("/_root.data?loader"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + error: new Error("Unexpected Server Error"), + }, + }); expect(errorLogs.length).toBe(1); expect(errorLogs[0][0].message).toMatch("Loader Error"); expect(errorLogs[0][0].stack).toMatch(" at "); }); test("returns deferred data without errors", async () => { - let response = await fixture.requestData("/defer", "routes/defer"); - let text = await response.text(); - expect(text).toMatch("RESOLVED"); - expect(text).not.toMatch("REJECTED"); - expect(text).not.toMatch(/stack/i); + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); }); test("sanitizes loader errors in deferred data requests", async () => { - let response = await fixture.requestData("/defer?loader", "routes/defer"); - let text = await response.text(); - expect(text).toBe( - '{"lazy":"__deferred_promise:lazy"}\n\n' + - 'error:{"lazy":{"message":"Unexpected Server Error"}}\n\n' - ); + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("Unexpected Server Error"); + expect((e as Error).stack).toBeUndefined(); + } // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); @@ -272,20 +286,20 @@ test.describe.skip("Error Sanitization", () => { expect(errorLogs[0][0].stack).toMatch(" at "); }); - // Note: This is currently inconsistent with document requests - we do not - // serialize ErrorResponse as Errors in document requests and we do send the - // data (i.e., Route "not-a-route" does not match URL "/"). Probably no - // real need to align those now with data requests on the way out - we - // have aligned them in single fetch - test("sanitizes mismatched route errors in data requests", async () => { - let response = await fixture.requestData("/", "not-a-route"); - let text = await response.text(); - expect(text).toBe('{"message":"Unexpected Server Error"}'); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch( - 'Route "not-a-route" does not match URL "/"' - ); - expect(errorLogs[0][0].stack).toMatch(" at "); + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs).toEqual([ + [new Error('No route matches URL "/not-a-route"')], + ]); }); test("does not support hydration of Error subclasses", async ({ page }) => { @@ -308,6 +322,7 @@ test.describe.skip("Error Sanitization", () => { test.beforeAll(async () => { fixture = await createFixture( { + singleFetch: true, files: routeFiles, }, ServerMode.Development @@ -338,9 +353,6 @@ test.describe.skip("Error Sanitization", () => { expect(html).not.toMatch("LOADER"); expect(html).toMatch("

MESSAGE:Loader Error"); expect(html).toMatch("

STACK:Error: Loader Error"); - expect(html).toMatch( - 'errors":{"routes/_index":{"message":"Loader Error","stack":"Error: Loader Error\\n' - ); expect(errorLogs.length).toBe(1); expect(errorLogs[0][0].message).toMatch("Loader Error"); expect(errorLogs[0][0].stack).toMatch(" at "); @@ -352,9 +364,6 @@ test.describe.skip("Error Sanitization", () => { expect(html).toMatch("Index Error"); expect(html).toMatch("

MESSAGE:Render Error"); expect(html).toMatch("

STACK:Error: Render Error"); - expect(html).toMatch( - 'errors":{"routes/_index":{"message":"Render Error","stack":"Error: Render Error\\n' - ); expect(errorLogs.length).toBe(1); expect(errorLogs[0][0].message).toMatch("Render Error"); expect(errorLogs[0][0].stack).toMatch(" at "); @@ -374,47 +383,57 @@ test.describe.skip("Error Sanitization", () => { let html = await response.text(); expect(html).toMatch("Defer Error"); expect(html).not.toMatch("RESOLVED"); - // Defer errors are not not part of the JSON blob but rather rejected - // against a pending promise and therefore are inlined JS. - expect(html).toMatch("x.stack=e.stack;"); + expect(html).toMatch("

REJECTED

"); + expect(html).toMatch("Error: REJECTED\\\\n at "); // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); }); test("returns data without errors", async () => { - let response = await fixture.requestData("/", "routes/_index"); - let text = await response.text(); - expect(text).toMatch("LOADER"); - expect(text).not.toMatch("MESSAGE:"); - expect(text).not.toMatch(/stack/i); + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); }); test("does not sanitize loader errors in data requests", async () => { - let response = await fixture.requestData("/?loader", "routes/_index"); - let text = await response.text(); - expect(text).toMatch( - '{"message":"Loader Error","stack":"Error: Loader Error' - ); + let { data } = await fixture.requestSingleFetchData("/_root.data?loader"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + error: new Error("Loader Error"), + }, + }); expect(errorLogs.length).toBe(1); expect(errorLogs[0][0].message).toMatch("Loader Error"); expect(errorLogs[0][0].stack).toMatch(" at "); }); test("returns deferred data without errors", async () => { - let response = await fixture.requestData("/defer", "routes/defer"); - let text = await response.text(); - expect(text).toMatch("RESOLVED"); - expect(text).not.toMatch("REJECTED"); - expect(text).not.toMatch(/stack/i); + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); }); test("does not sanitize loader errors in deferred data requests", async () => { - let response = await fixture.requestData("/defer?loader", "routes/defer"); - let text = await response.text(); - expect(text).toMatch( - 'error:{"lazy":{"message":"REJECTED","stack":"Error: REJECTED' - ); + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("REJECTED"); + expect((e as Error).stack).not.toBeUndefined(); + } + // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); @@ -430,16 +449,19 @@ test.describe.skip("Error Sanitization", () => { }); test("does not sanitize mismatched route errors in data requests", async () => { - let response = await fixture.requestData("/", "not-a-route"); - let text = await response.text(); - expect(text).toMatch( - '{"message":"Route \\"not-a-route\\" does not match URL \\"/\\"","stack":"Error: Route \\"not-a-route\\" does not match URL \\"/\\"' - ); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch( - 'Route "not-a-route" does not match URL "/"' - ); - expect(errorLogs[0][0].stack).toMatch(" at "); + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs).toEqual([ + [new Error('No route matches URL "/not-a-route"')], + ]); }); test("supports hydration of Error subclasses", async ({ page }) => { @@ -468,27 +490,64 @@ test.describe.skip("Error Sanitization", () => { test.beforeAll(async () => { fixture = await createFixture( { + singleFetch: true, files: { "app/entry.server.tsx": js` - import type { EntryContext } from "@react-router/node"; + import { PassThrough } from "node:stream"; + + import { createReadableStreamFromReadable } from "@react-router/node"; import { RemixServer, isRouteErrorResponse } from "react-router-dom"; - import { renderToString } from "react-dom/server"; + import { renderToPipeableStream } from "react-dom/server"; + + const ABORT_DELAY = 5_000; export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext + request, + responseStatusCode, + responseHeaders, + remixContext ) { - let markup = renderToString( - - ); - - responseHeaders.set("Content-Type", "text/html"); - - return new Response("" + markup, { - status: responseStatusCode, - headers: responseHeaders, + 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) { + reject(error); + }, + onError(error) { + 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, ABORT_DELAY); }); } @@ -532,10 +591,12 @@ test.describe.skip("Error Sanitization", () => { expect(html).toMatch("Index Error"); expect(html).not.toMatch("LOADER"); expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value expect(html).toMatch( - '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' ); - expect(html).not.toMatch(/stack/i); + expect(html).not.toMatch(/ at /i); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?loader"); expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); @@ -548,10 +609,12 @@ test.describe.skip("Error Sanitization", () => { let html = await response.text(); expect(html).toMatch("Index Error"); expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value expect(html).toMatch( - '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' ); - expect(html).not.toMatch(/stack/i); + expect(html).not.toMatch(/ at /i); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?render"); expect(errorLogs[2][0]).toEqual(" Error: Render Error"); @@ -575,30 +638,34 @@ test.describe.skip("Error Sanitization", () => { let html = await response.text(); expect(html).toMatch("Defer Error"); expect(html).not.toMatch("RESOLVED"); - expect(html).toMatch('{"message":"Unexpected Server Error"}'); - // Defer errors are not not part of the JSON blob but rather rejected - // against a pending promise and therefore are inlined JS. - expect(html).toMatch("x.stack=undefined;"); + expect(html).toMatch("Unexpected Server Error"); + expect(html).not.toMatch("stack"); // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); }); test("returns data without errors", async () => { - let response = await fixture.requestData("/", "routes/_index"); - let text = await response.text(); - expect(text).toMatch("LOADER"); - expect(text).not.toMatch("MESSAGE:"); - expect(text).not.toMatch(/stack/i); + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); }); test("sanitizes loader errors in data requests", async () => { - let response = await fixture.requestData("/?loader", "routes/_index"); - let text = await response.text(); - expect(text).toBe('{"message":"Unexpected Server Error"}'); + let { data } = await fixture.requestSingleFetchData("/_root.data?loader"); + expect(data).toEqual({ + root: { data: null }, + "routes/_index": { error: new Error("Unexpected Server Error") }, + }); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); expect(errorLogs[1][0]).toEqual( - " Request: GET test://test/?loader=&_data=routes%2F_index" + " Request: GET test://test/_root.data?loader" ); expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); expect(errorLogs[3][0]).toMatch(" at "); @@ -606,20 +673,21 @@ test.describe.skip("Error Sanitization", () => { }); test("returns deferred data without errors", async () => { - let response = await fixture.requestData("/defer", "routes/defer"); - let text = await response.text(); - expect(text).toMatch("RESOLVED"); - expect(text).not.toMatch("REJECTED"); - expect(text).not.toMatch(/stack/i); + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toBe("RESOLVED"); }); test("sanitizes loader errors in deferred data requests", async () => { - let response = await fixture.requestData("/defer?loader", "routes/defer"); - let text = await response.text(); - expect(text).toBe( - '{"lazy":"__deferred_promise:lazy"}\n\n' + - 'error:{"lazy":{"message":"Unexpected Server Error"}}\n\n' - ); + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("Unexpected Server Error"); + expect((e as Error).stack).toBeUndefined(); + } // defer errors are not logged to the server console since the request // has "succeeded" expect(errorLogs.length).toBe(0); @@ -630,6 +698,7 @@ test.describe.skip("Error Sanitization", () => { let text = await response.text(); expect(text).toBe("Unexpected Server Error"); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); expect(errorLogs[1][0]).toEqual( " Request: GET test://test/resource?loader" ); @@ -638,641 +707,27 @@ test.describe.skip("Error Sanitization", () => { expect(errorLogs.length).toBe(4); }); - test("sanitizes mismatched route errors in data requests", async () => { - let response = await fixture.requestData("/", "not-a-route"); - let text = await response.text(); - expect(text).toBe('{"message":"Unexpected Server Error"}'); + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); expect(errorLogs[1][0]).toEqual( - " Request: GET test://test/?_data=not-a-route" + " Request: GET test://test/not-a-route.data" ); - expect(errorLogs[2][0]).toEqual(" Status: 403 Forbidden"); + expect(errorLogs[2][0]).toEqual(" Status: 404 Not Found"); expect(errorLogs[3][0]).toEqual( - ' Error: Route "not-a-route" does not match URL "/"' + ' Error: No route matches URL "/not-a-route"' ); expect(errorLogs[4][0]).toMatch(" at "); expect(errorLogs.length).toBe(5); }); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("Error Sanitization", () => { - let fixture: Fixture; - let oldConsoleError: () => void; - let errorLogs: any[] = []; - - test.beforeEach(() => { - oldConsoleError = console.error; - errorLogs = []; - console.error = (...args) => errorLogs.push(args); - }); - - test.afterEach(() => { - console.error = oldConsoleError; - }); - - test.describe("serverMode=production", () => { - test.beforeAll(async () => { - fixture = await createFixture( - { - singleFetch: true, - files: routeFiles, - }, - ServerMode.Production - ); - }); - - test("renders document without errors", async () => { - let response = await fixture.requestDocument("/"); - let html = await response.text(); - expect(html).toMatch("Index Route"); - expect(html).toMatch("LOADER"); - expect(html).not.toMatch("MESSAGE:"); - expect(html).not.toMatch(/stack/i); - }); - - test("sanitizes loader errors in document requests", async () => { - let response = await fixture.requestDocument("/?loader"); - let html = await response.text(); - expect(html).toMatch("Index Error"); - expect(html).not.toMatch("LOADER"); - expect(html).toMatch("MESSAGE:Unexpected Server Error"); - // This is the turbo-stream encoding - the fact that stack goes right - // into __type means it has no value - expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' - ); - expect(html).not.toMatch(/ at /i); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch("Loader Error"); - expect(errorLogs[0][0].stack).toMatch(" at "); - }); - - test("sanitizes render errors in document requests", async () => { - let response = await fixture.requestDocument("/?render"); - let html = await response.text(); - expect(html).toMatch("Index Error"); - expect(html).toMatch("MESSAGE:Unexpected Server Error"); - // This is the turbo-stream encoding - the fact that stack goes right - // into __type means it has no value - expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' - ); - expect(html).not.toMatch(/ at /i); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch("Render Error"); - expect(errorLogs[0][0].stack).toMatch(" at "); - }); - - test("renders deferred document without errors", async () => { - let response = await fixture.requestDocument("/defer"); - let html = await response.text(); - expect(html).toMatch("Defer Route"); - expect(html).toMatch("RESOLVED"); - expect(html).not.toMatch("MESSAGE:"); - // Defer errors are not not part of the JSON blob but rather rejected - // against a pending promise and therefore are inlined JS. - expect(html).not.toMatch("x.stack=e.stack;"); - }); - - test("sanitizes defer errors in document requests", async () => { - let response = await fixture.requestDocument("/defer?loader"); - let html = await response.text(); - expect(html).toMatch("Defer Error"); - expect(html).not.toMatch("RESOLVED"); - expect(html).toMatch("Unexpected Server Error"); - expect(html).not.toMatch("stack"); - // defer errors are not logged to the server console since the request - // has "succeeded" - expect(errorLogs.length).toBe(0); - }); - - test("returns data without errors", async () => { - let { data } = await fixture.requestSingleFetchData("/_root.data"); - expect(data).toEqual({ - root: { - data: null, - }, - "routes/_index": { - data: "LOADER", - }, - }); - }); - - test("sanitizes loader errors in data requests", async () => { - let { data } = await fixture.requestSingleFetchData( - "/_root.data?loader" - ); - expect(data).toEqual({ - root: { - data: null, - }, - "routes/_index": { - error: new Error("Unexpected Server Error"), - }, - }); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch("Loader Error"); - expect(errorLogs[0][0].stack).toMatch(" at "); - }); - - test("returns deferred data without errors", async () => { - let { data } = await fixture.requestSingleFetchData("/defer.data"); - // @ts-expect-error - expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); - }); - - test("sanitizes loader errors in deferred data requests", async () => { - let { data } = await fixture.requestSingleFetchData( - "/defer.data?loader" - ); - try { - // @ts-expect-error - await data["routes/defer"].data.lazy; - expect(true).toBe(false); - } catch (e) { - expect((e as Error).message).toBe("Unexpected Server Error"); - expect((e as Error).stack).toBeUndefined(); - } - // defer errors are not logged to the server console since the request - // has "succeeded" - expect(errorLogs.length).toBe(0); - }); - - test("sanitizes loader errors in resource requests", async () => { - let response = await fixture.requestResource("/resource?loader"); - let text = await response.text(); - expect(text).toBe("Unexpected Server Error"); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch("Loader Error"); - expect(errorLogs[0][0].stack).toMatch(" at "); - }); - - test("does not sanitize mismatched route errors in data requests", async () => { - let { data } = await fixture.requestSingleFetchData( - "/not-a-route.data" - ); - expect(data).toEqual({ - root: { - error: new ErrorResponseImpl( - 404, - "Not Found", - 'Error: No route matches URL "/not-a-route"' - ), - }, - }); - expect(errorLogs).toEqual([ - [new Error('No route matches URL "/not-a-route"')], - ]); - }); - - test("does not support hydration of Error subclasses", async ({ - page, - }) => { - let response = await fixture.requestDocument("/?subclass"); - let html = await response.text(); - expect(html).toMatch("

MESSAGE:Unexpected Server Error"); - expect(html).toMatch("

NAME:Error"); - - // Hydration - let appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/?subclass", true); - html = await app.getHtml(); - expect(html).toMatch("

MESSAGE:Unexpected Server Error"); - expect(html).toMatch("

NAME:Error"); - }); - }); - - test.describe("serverMode=development", () => { - test.beforeAll(async () => { - fixture = await createFixture( - { - singleFetch: true, - files: routeFiles, - }, - ServerMode.Development - ); - }); - let ogEnv = process.env.NODE_ENV; - test.beforeEach(() => { - ogEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "development"; - }); - test.afterEach(() => { - process.env.NODE_ENV = ogEnv; - }); - - test("renders document without errors", async () => { - let response = await fixture.requestDocument("/"); - let html = await response.text(); - expect(html).toMatch("Index Route"); - expect(html).toMatch("LOADER"); - expect(html).not.toMatch("MESSAGE:"); - expect(html).not.toMatch(/stack/i); - }); - - test("does not sanitize loader errors in document requests", async () => { - let response = await fixture.requestDocument("/?loader"); - let html = await response.text(); - expect(html).toMatch("Index Error"); - expect(html).not.toMatch("LOADER"); - expect(html).toMatch("

MESSAGE:Loader Error"); - expect(html).toMatch("

STACK:Error: Loader Error"); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch("Loader Error"); - expect(errorLogs[0][0].stack).toMatch(" at "); - }); - - test("does not sanitize render errors in document requests", async () => { - let response = await fixture.requestDocument("/?render"); - let html = await response.text(); - expect(html).toMatch("Index Error"); - expect(html).toMatch("

MESSAGE:Render Error"); - expect(html).toMatch("

STACK:Error: Render Error"); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch("Render Error"); - expect(errorLogs[0][0].stack).toMatch(" at "); - }); - - test("renders deferred document without errors", async () => { - let response = await fixture.requestDocument("/defer"); - let html = await response.text(); - expect(html).toMatch("Defer Route"); - expect(html).toMatch("RESOLVED"); - expect(html).not.toMatch("MESSAGE:"); - expect(html).not.toMatch(/"stack":/i); - }); - - test("does not sanitize defer errors in document requests", async () => { - let response = await fixture.requestDocument("/defer?loader"); - let html = await response.text(); - expect(html).toMatch("Defer Error"); - expect(html).not.toMatch("RESOLVED"); - expect(html).toMatch("

REJECTED

"); - expect(html).toMatch("Error: REJECTED\\\\n at "); - // defer errors are not logged to the server console since the request - // has "succeeded" - expect(errorLogs.length).toBe(0); - }); - - test("returns data without errors", async () => { - let { data } = await fixture.requestSingleFetchData("/_root.data"); - expect(data).toEqual({ - root: { - data: null, - }, - "routes/_index": { - data: "LOADER", - }, - }); - }); - - test("does not sanitize loader errors in data requests", async () => { - let { data } = await fixture.requestSingleFetchData( - "/_root.data?loader" - ); - expect(data).toEqual({ - root: { - data: null, - }, - "routes/_index": { - error: new Error("Loader Error"), - }, - }); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch("Loader Error"); - expect(errorLogs[0][0].stack).toMatch(" at "); - }); - - test("returns deferred data without errors", async () => { - let { data } = await fixture.requestSingleFetchData("/defer.data"); - // @ts-expect-error - expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); - }); - - test("does not sanitize loader errors in deferred data requests", async () => { - let { data } = await fixture.requestSingleFetchData( - "/defer.data?loader" - ); - try { - // @ts-expect-error - await data["routes/defer"].data.lazy; - expect(true).toBe(false); - } catch (e) { - expect((e as Error).message).toBe("REJECTED"); - expect((e as Error).stack).not.toBeUndefined(); - } - - // defer errors are not logged to the server console since the request - // has "succeeded" - expect(errorLogs.length).toBe(0); - }); - - test("does not sanitize loader errors in resource requests", async () => { - let response = await fixture.requestResource("/resource?loader"); - let text = await response.text(); - expect(text).toBe("Unexpected Server Error\n\nError: Loader Error"); - expect(errorLogs.length).toBe(1); - expect(errorLogs[0][0].message).toMatch("Loader Error"); - expect(errorLogs[0][0].stack).toMatch(" at "); - }); - - test("does not sanitize mismatched route errors in data requests", async () => { - let { data } = await fixture.requestSingleFetchData( - "/not-a-route.data" - ); - expect(data).toEqual({ - root: { - error: new ErrorResponseImpl( - 404, - "Not Found", - 'Error: No route matches URL "/not-a-route"' - ), - }, - }); - expect(errorLogs).toEqual([ - [new Error('No route matches URL "/not-a-route"')], - ]); - }); - - test("supports hydration of Error subclasses", async ({ page }) => { - let response = await fixture.requestDocument("/?subclass"); - let html = await response.text(); - expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); - expect(html).toMatch("

NAME:ReferenceError"); - expect(html).toMatch( - "

STACK:ReferenceError: thisisnotathing is not defined" - ); - - // Hydration - let appFixture = await createAppFixture( - fixture, - ServerMode.Development - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/?subclass", true); - html = await app.getHtml(); - expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); - expect(html).toMatch("

NAME:ReferenceError"); - expect(html).toMatch( - "STACK:ReferenceError: thisisnotathing is not defined" - ); - }); - }); - - test.describe("serverMode=production (user-provided handleError)", () => { - test.beforeAll(async () => { - fixture = await createFixture( - { - singleFetch: true, - files: { - "app/entry.server.tsx": js` - import { PassThrough } from "node:stream"; - - import { createReadableStreamFromReadable } from "@react-router/node"; - import { RemixServer, isRouteErrorResponse } from "react-router-dom"; - import { renderToPipeableStream } from "react-dom/server"; - - const ABORT_DELAY = 5_000; - - export default function handleRequest( - request, - responseStatusCode, - responseHeaders, - remixContext - ) { - 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) { - reject(error); - }, - onError(error) { - 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, ABORT_DELAY); - }); - } - - export function handleError( - error: unknown, - { request }: { request: Request }, - ) { - console.error("App Specific Error Logging:"); - console.error(" Request: " + request.method + " " + request.url); - if (isRouteErrorResponse(error)) { - console.error(" Status: " + error.status + " " + error.statusText); - console.error(" Error: " + error.error.message); - console.error(" Stack: " + error.error.stack); - } else if (error instanceof Error) { - console.error(" Error: " + error.message); - console.error(" Stack: " + error.stack); - } else { - console.error("Dunno what this is"); - } - } - `, - ...routeFiles, - }, - }, - ServerMode.Production - ); - }); - - test("renders document without errors", async () => { - let response = await fixture.requestDocument("/"); - let html = await response.text(); - expect(html).toMatch("Index Route"); - expect(html).toMatch("LOADER"); - expect(html).not.toMatch("MESSAGE:"); - expect(html).not.toMatch(/stack/i); - }); - - test("sanitizes loader errors in document requests", async () => { - let response = await fixture.requestDocument("/?loader"); - let html = await response.text(); - expect(html).toMatch("Index Error"); - expect(html).not.toMatch("LOADER"); - expect(html).toMatch("MESSAGE:Unexpected Server Error"); - // This is the turbo-stream encoding - the fact that stack goes right - // into __type means it has no value - expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' - ); - expect(html).not.toMatch(/ at /i); - expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); - expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?loader"); - expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); - expect(errorLogs[3][0]).toMatch(" at "); - expect(errorLogs.length).toBe(4); - }); - - test("sanitizes render errors in document requests", async () => { - let response = await fixture.requestDocument("/?render"); - let html = await response.text(); - expect(html).toMatch("Index Error"); - expect(html).toMatch("MESSAGE:Unexpected Server Error"); - // This is the turbo-stream encoding - the fact that stack goes right - // into __type means it has no value - expect(html).toMatch( - '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' - ); - expect(html).not.toMatch(/ at /i); - expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); - expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?render"); - expect(errorLogs[2][0]).toEqual(" Error: Render Error"); - expect(errorLogs[3][0]).toMatch(" at "); - expect(errorLogs.length).toBe(4); - }); - - test("renders deferred document without errors", async () => { - let response = await fixture.requestDocument("/defer"); - let html = await response.text(); - expect(html).toMatch("Defer Route"); - expect(html).toMatch("RESOLVED"); - expect(html).not.toMatch("MESSAGE:"); - // Defer errors are not not part of the JSON blob but rather rejected - // against a pending promise and therefore are inlined JS. - expect(html).not.toMatch("x.stack=e.stack;"); - }); - - test("sanitizes defer errors in document requests", async () => { - let response = await fixture.requestDocument("/defer?loader"); - let html = await response.text(); - expect(html).toMatch("Defer Error"); - expect(html).not.toMatch("RESOLVED"); - expect(html).toMatch("Unexpected Server Error"); - expect(html).not.toMatch("stack"); - // defer errors are not logged to the server console since the request - // has "succeeded" - expect(errorLogs.length).toBe(0); - }); - - test("returns data without errors", async () => { - let { data } = await fixture.requestSingleFetchData("/_root.data"); - expect(data).toEqual({ - root: { - data: null, - }, - "routes/_index": { - data: "LOADER", - }, - }); - }); - - test("sanitizes loader errors in data requests", async () => { - let { data } = await fixture.requestSingleFetchData( - "/_root.data?loader" - ); - expect(data).toEqual({ - root: { data: null }, - "routes/_index": { error: new Error("Unexpected Server Error") }, - }); - expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); - expect(errorLogs[1][0]).toEqual( - " Request: GET test://test/_root.data?loader" - ); - expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); - expect(errorLogs[3][0]).toMatch(" at "); - expect(errorLogs.length).toBe(4); - }); - - test("returns deferred data without errors", async () => { - let { data } = await fixture.requestSingleFetchData("/defer.data"); - // @ts-expect-error - expect(await data["routes/defer"].data.lazy).toBe("RESOLVED"); - }); - - test("sanitizes loader errors in deferred data requests", async () => { - let { data } = await fixture.requestSingleFetchData( - "/defer.data?loader" - ); - try { - // @ts-expect-error - await data["routes/defer"].data.lazy; - expect(true).toBe(false); - } catch (e) { - expect((e as Error).message).toBe("Unexpected Server Error"); - expect((e as Error).stack).toBeUndefined(); - } - // defer errors are not logged to the server console since the request - // has "succeeded" - expect(errorLogs.length).toBe(0); - }); - - test("sanitizes loader errors in resource requests", async () => { - let response = await fixture.requestResource("/resource?loader"); - let text = await response.text(); - expect(text).toBe("Unexpected Server Error"); - expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); - expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); - expect(errorLogs[1][0]).toEqual( - " Request: GET test://test/resource?loader" - ); - expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); - expect(errorLogs[3][0]).toMatch(" at "); - expect(errorLogs.length).toBe(4); - }); - - test("does not sanitize mismatched route errors in data requests", async () => { - let { data } = await fixture.requestSingleFetchData( - "/not-a-route.data" - ); - expect(data).toEqual({ - root: { - error: new ErrorResponseImpl( - 404, - "Not Found", - 'Error: No route matches URL "/not-a-route"' - ), - }, - }); - expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); - expect(errorLogs[1][0]).toEqual( - " Request: GET test://test/not-a-route.data" - ); - expect(errorLogs[2][0]).toEqual(" Status: 404 Not Found"); - expect(errorLogs[3][0]).toEqual( - ' Error: No route matches URL "/not-a-route"' - ); - expect(errorLogs[4][0]).toMatch(" at "); - expect(errorLogs.length).toBe(5); - }); - }); - }); -}); diff --git a/integration/fetcher-layout-test.ts b/integration/fetcher-layout-test.ts index b6ecb00020..b8ed4abe5e 100644 --- a/integration/fetcher-layout-test.ts +++ b/integration/fetcher-layout-test.ts @@ -11,549 +11,273 @@ import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; let fixture: Fixture; let appFixture: AppFixture; -test.describe.skip("multi fetch", () => { - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/layout-action.tsx": js` - import { json } from "@react-router/node"; - import { Outlet, useFetcher, useFormAction } from "react-router-dom"; - - export let action = ({ params }) => json("layout action data"); - - export default function ActionLayout() { - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.submit({}, { method: "post", action }); - }; - - return ( -

-

Layout

- - {!!fetcher.data &&

{fetcher.data}

} - -
- ); - } - `, - - "app/routes/layout-action._index.tsx": js` - import { json } from "@react-router/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "react-router-dom"; - - export let loader = ({ params }) => json("index data"); - - export let action = ({ params }) => json("index action data"); - - export default function ActionLayoutIndex() { - let data = useLoaderData(); - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.submit({}, { method: "post", action }) - }; - - return ( - <> -

{data}

- - {!!fetcher.data &&

{fetcher.data}

} - - ); - } - `, - - "app/routes/layout-action.$param.tsx": js` - import { json } from "@react-router/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "react-router-dom"; - - export let loader = ({ params }) => json(params.param); - - export let action = ({ params }) => json("param action data"); - - export default function ActionLayoutChild() { - let data = useLoaderData(); - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.submit({}, { method: "post", action }) - }; - - return ( - <> -

{data}

- - {!!fetcher.data &&

{fetcher.data}

} - - ); - } - `, - - "app/routes/layout-loader.tsx": js` - import { json } from "@react-router/node"; - import { Outlet, useFetcher, useFormAction } from "react-router-dom"; - - export let loader = () => json("layout loader data"); - - export default function LoaderLayout() { - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.load(action); - }; - - return ( -
-

Layout

- - {!!fetcher.data &&

{fetcher.data}

} - -
- ); - } - `, - - "app/routes/layout-loader._index.tsx": js` - import { json } from "@react-router/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "react-router-dom"; - - export let loader = ({ params }) => json("index data"); - - export default function ActionLayoutIndex() { - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.load(action); - }; - - return ( - <> - - {!!fetcher.data &&

{fetcher.data}

} - - ); - } - `, - - "app/routes/layout-loader.$param.tsx": js` - import { json } from "@react-router/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "react-router-dom"; - - export let loader = ({ params }) => json(params.param); - - export default function ActionLayoutChild() { - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.load(action); - }; - - return ( - <> - - {!!fetcher.data &&

{fetcher.data}

} - - ); - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); +test.beforeAll(async () => { + fixture = await createFixture({ + singleFetch: true, + files: { + "app/routes/layout-action.tsx": js` + import { json } from "@react-router/node"; + import { Outlet, useFetcher, useFormAction } from "react-router-dom"; + + export let action = ({ params }) => json("layout action data"); + + export default function ActionLayout() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.submit({}, { method: "post", action }); + }; + + return ( +
+

Layout

+ + {!!fetcher.data &&

{fetcher.data}

} + +
+ ); + } + `, + + "app/routes/layout-action._index.tsx": js` + import { json } from "@react-router/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router-dom"; + + export let loader = ({ params }) => json("index data"); + + export let action = ({ params }) => json("index action data"); + + export default function ActionLayoutIndex() { + let data = useLoaderData(); + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.submit({}, { method: "post", action }) + }; + + return ( + <> +

{data}

+ + {!!fetcher.data &&

{fetcher.data}

} + + ); + } + `, + + "app/routes/layout-action.$param.tsx": js` + import { json } from "@react-router/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router-dom"; + + export let loader = ({ params }) => json(params.param); + + export let action = ({ params }) => json("param action data"); + + export default function ActionLayoutChild() { + let data = useLoaderData(); + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.submit({}, { method: "post", action }) + }; + + return ( + <> +

{data}

+ + {!!fetcher.data &&

{fetcher.data}

} + + ); + } + `, + + "app/routes/layout-loader.tsx": js` + import { json } from "@react-router/node"; + import { Outlet, useFetcher, useFormAction } from "react-router-dom"; + + export let loader = () => json("layout loader data"); + + export default function LoaderLayout() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.load(action); + }; + + return ( +
+

Layout

+ + {!!fetcher.data &&

{fetcher.data}

} + +
+ ); + } + `, + + "app/routes/layout-loader._index.tsx": js` + import { json } from "@react-router/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router-dom"; + + export let loader = ({ params }) => json("index data"); + + export default function ActionLayoutIndex() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.load(action); + }; + + return ( + <> + + {!!fetcher.data &&

{fetcher.data}

} + + ); + } + `, + + "app/routes/layout-loader.$param.tsx": js` + import { json } from "@react-router/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router-dom"; + + export let loader = ({ params }) => json(params.param); + + export default function ActionLayoutChild() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.load(action); + }; + + return ( + <> + + {!!fetcher.data &&

{fetcher.data}

} + + ); + } + `, + }, }); - test.afterAll(() => { - appFixture.close(); - }); - - test("fetcher calls layout route action when at index route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-action"); - await app.clickElement("#layout-fetcher"); - await page.waitForSelector("#layout-fetcher-data"); - let dataElement = await app.getElement("#layout-fetcher-data"); - expect(dataElement.text()).toBe("layout action data"); - dataElement = await app.getElement("#child-data"); - expect(dataElement.text()).toBe("index data"); - }); - - test("fetcher calls layout route loader when at index route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-loader"); - await app.clickElement("#layout-fetcher"); - await page.waitForSelector("#layout-fetcher-data"); - let dataElement = await app.getElement("#layout-fetcher-data"); - expect(dataElement.text()).toBe("layout loader data"); - }); - - test("fetcher calls index route action when at index route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-action"); - await app.clickElement("#index-fetcher"); - await page.waitForSelector("#index-fetcher-data"); - let dataElement = await app.getElement("#index-fetcher-data"); - expect(dataElement.text()).toBe("index action data"); - dataElement = await app.getElement("#child-data"); - expect(dataElement.text()).toBe("index data"); - }); - - test("fetcher calls index route loader when at index route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-loader"); - await app.clickElement("#index-fetcher"); - await page.waitForSelector("#index-fetcher-data"); - let dataElement = await app.getElement("#index-fetcher-data"); - expect(dataElement.text()).toBe("index data"); - }); - - test("fetcher calls layout route action when at paramaterized route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-action/foo"); - await app.clickElement("#layout-fetcher"); - await page.waitForSelector("#layout-fetcher-data"); - let dataElement = await app.getElement("#layout-fetcher-data"); - expect(dataElement.text()).toBe("layout action data"); - dataElement = await app.getElement("#child-data"); - expect(dataElement.text()).toBe("foo"); - }); - - test("fetcher calls layout route loader when at parameterized route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-loader/foo"); - await app.clickElement("#layout-fetcher"); - await page.waitForSelector("#layout-fetcher-data"); - let dataElement = await app.getElement("#layout-fetcher-data"); - expect(dataElement.text()).toBe("layout loader data"); - }); - - test("fetcher calls parameterized route route action", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-action/foo"); - await app.clickElement("#param-fetcher"); - await page.waitForSelector("#param-fetcher-data"); - let dataElement = await app.getElement("#param-fetcher-data"); - expect(dataElement.text()).toBe("param action data"); - dataElement = await app.getElement("#child-data"); - expect(dataElement.text()).toBe("foo"); - }); - - test("fetcher calls parameterized route route loader", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-loader/foo"); - await app.clickElement("#param-fetcher"); - await page.waitForSelector("#param-fetcher-data"); - let dataElement = await app.getElement("#param-fetcher-data"); - expect(dataElement.text()).toBe("foo"); - }); + appFixture = await createAppFixture(fixture); }); -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/routes/layout-action.tsx": js` - import { json } from "@react-router/node"; - import { Outlet, useFetcher, useFormAction } from "react-router-dom"; - - export let action = ({ params }) => json("layout action data"); - - export default function ActionLayout() { - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.submit({}, { method: "post", action }); - }; - - return ( -
-

Layout

- - {!!fetcher.data &&

{fetcher.data}

} - -
- ); - } - `, - - "app/routes/layout-action._index.tsx": js` - import { json } from "@react-router/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "react-router-dom"; - - export let loader = ({ params }) => json("index data"); - - export let action = ({ params }) => json("index action data"); - - export default function ActionLayoutIndex() { - let data = useLoaderData(); - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.submit({}, { method: "post", action }) - }; - - return ( - <> -

{data}

- - {!!fetcher.data &&

{fetcher.data}

} - - ); - } - `, - - "app/routes/layout-action.$param.tsx": js` - import { json } from "@react-router/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "react-router-dom"; - - export let loader = ({ params }) => json(params.param); - - export let action = ({ params }) => json("param action data"); - - export default function ActionLayoutChild() { - let data = useLoaderData(); - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.submit({}, { method: "post", action }) - }; - - return ( - <> -

{data}

- - {!!fetcher.data &&

{fetcher.data}

} - - ); - } - `, - - "app/routes/layout-loader.tsx": js` - import { json } from "@react-router/node"; - import { Outlet, useFetcher, useFormAction } from "react-router-dom"; - - export let loader = () => json("layout loader data"); - - export default function LoaderLayout() { - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.load(action); - }; - - return ( -
-

Layout

- - {!!fetcher.data &&

{fetcher.data}

} - -
- ); - } - `, - - "app/routes/layout-loader._index.tsx": js` - import { json } from "@react-router/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "react-router-dom"; - - export let loader = ({ params }) => json("index data"); - - export default function ActionLayoutIndex() { - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.load(action); - }; - - return ( - <> - - {!!fetcher.data &&

{fetcher.data}

} - - ); - } - `, - - "app/routes/layout-loader.$param.tsx": js` - import { json } from "@react-router/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "react-router-dom"; - - export let loader = ({ params }) => json(params.param); - - export default function ActionLayoutChild() { - let fetcher = useFetcher(); - let action = useFormAction(); - - let invokeFetcher = () => { - fetcher.load(action); - }; - - return ( - <> - - {!!fetcher.data &&

{fetcher.data}

} - - ); - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); +test.afterAll(() => { + appFixture.close(); +}); - test("fetcher calls layout route action when at index route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-action"); - await app.clickElement("#layout-fetcher"); - await page.waitForSelector("#layout-fetcher-data"); - let dataElement = await app.getElement("#layout-fetcher-data"); - expect(dataElement.text()).toBe("layout action data"); - dataElement = await app.getElement("#child-data"); - expect(dataElement.text()).toBe("index data"); - }); +test("fetcher calls layout route action when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("index data"); +}); - test("fetcher calls layout route loader when at index route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-loader"); - await app.clickElement("#layout-fetcher"); - await page.waitForSelector("#layout-fetcher-data"); - let dataElement = await app.getElement("#layout-fetcher-data"); - expect(dataElement.text()).toBe("layout loader data"); - }); +test("fetcher calls layout route loader when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout loader data"); +}); - test("fetcher calls index route action when at index route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-action"); - await app.clickElement("#index-fetcher"); - await page.waitForSelector("#index-fetcher-data"); - let dataElement = await app.getElement("#index-fetcher-data"); - expect(dataElement.text()).toBe("index action data"); - dataElement = await app.getElement("#child-data"); - expect(dataElement.text()).toBe("index data"); - }); +test("fetcher calls index route action when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action"); + await app.clickElement("#index-fetcher"); + await page.waitForSelector("#index-fetcher-data"); + let dataElement = await app.getElement("#index-fetcher-data"); + expect(dataElement.text()).toBe("index action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("index data"); +}); - test("fetcher calls index route loader when at index route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-loader"); - await app.clickElement("#index-fetcher"); - await page.waitForSelector("#index-fetcher-data"); - let dataElement = await app.getElement("#index-fetcher-data"); - expect(dataElement.text()).toBe("index data"); - }); +test("fetcher calls index route loader when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader"); + await app.clickElement("#index-fetcher"); + await page.waitForSelector("#index-fetcher-data"); + let dataElement = await app.getElement("#index-fetcher-data"); + expect(dataElement.text()).toBe("index data"); +}); - test("fetcher calls layout route action when at paramaterized route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-action/foo"); - await app.clickElement("#layout-fetcher"); - await page.waitForSelector("#layout-fetcher-data"); - let dataElement = await app.getElement("#layout-fetcher-data"); - expect(dataElement.text()).toBe("layout action data"); - dataElement = await app.getElement("#child-data"); - expect(dataElement.text()).toBe("foo"); - }); +test("fetcher calls layout route action when at paramaterized route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action/foo"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("foo"); +}); - test("fetcher calls layout route loader when at parameterized route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-loader/foo"); - await app.clickElement("#layout-fetcher"); - await page.waitForSelector("#layout-fetcher-data"); - let dataElement = await app.getElement("#layout-fetcher-data"); - expect(dataElement.text()).toBe("layout loader data"); - }); +test("fetcher calls layout route loader when at parameterized route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader/foo"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout loader data"); +}); - test("fetcher calls parameterized route route action", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-action/foo"); - await app.clickElement("#param-fetcher"); - await page.waitForSelector("#param-fetcher-data"); - let dataElement = await app.getElement("#param-fetcher-data"); - expect(dataElement.text()).toBe("param action data"); - dataElement = await app.getElement("#child-data"); - expect(dataElement.text()).toBe("foo"); - }); +test("fetcher calls parameterized route route action", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action/foo"); + await app.clickElement("#param-fetcher"); + await page.waitForSelector("#param-fetcher-data"); + let dataElement = await app.getElement("#param-fetcher-data"); + expect(dataElement.text()).toBe("param action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("foo"); +}); - test("fetcher calls parameterized route route loader", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/layout-loader/foo"); - await app.clickElement("#param-fetcher"); - await page.waitForSelector("#param-fetcher-data"); - let dataElement = await app.getElement("#param-fetcher-data"); - expect(dataElement.text()).toBe("foo"); - }); +test("fetcher calls parameterized route route loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader/foo"); + await app.clickElement("#param-fetcher"); + await page.waitForSelector("#param-fetcher-data"); + let dataElement = await app.getElement("#param-fetcher-data"); + expect(dataElement.text()).toBe("foo"); }); diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index d5aac8a4e0..be7a164196 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -8,7 +8,7 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe.skip("useFetcher", () => { +test.describe("useFetcher", () => { let fixture: Fixture; let appFixture: AppFixture; @@ -21,11 +21,12 @@ test.describe.skip("useFetcher", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/routes/resource-route-action-only.ts": js` import { json } from "@react-router/node"; export function action() { - return json("${CHEESESTEAK}"); + return new Response("${CHEESESTEAK}"); } `, @@ -53,10 +54,10 @@ test.describe.skip("useFetcher", () => { "app/routes/resource-route.tsx": js` export function loader() { - return "${LUNCH}"; + return new Response("${LUNCH}"); } export function action() { - return "${CHEESESTEAK}"; + return new Response("${CHEESESTEAK}"); } `, @@ -93,11 +94,11 @@ test.describe.skip("useFetcher", () => { import { Outlet } from "react-router-dom"; export function action() { - return "${PARENT_LAYOUT_ACTION}"; + return new Response("${PARENT_LAYOUT_ACTION}"); }; export function loader() { - return "${PARENT_LAYOUT_LOADER}"; + return new Response("${PARENT_LAYOUT_LOADER}"); }; export default function Parent() { @@ -109,11 +110,11 @@ test.describe.skip("useFetcher", () => { import { useFetcher } from "react-router-dom"; export function action() { - return "${PARENT_INDEX_ACTION}"; + return new Response("${PARENT_INDEX_ACTION}"); }; export function loader() { - return "${PARENT_INDEX_LOADER}"; + return new Response("${PARENT_INDEX_LOADER}"); }; export default function ParentIndex() { @@ -427,12 +428,13 @@ test.describe.skip("useFetcher", () => { }); }); -test.describe.skip("fetcher aborts and adjacent forms", () => { +test.describe("fetcher aborts and adjacent forms", () => { let fixture: Fixture; let appFixture: AppFixture; test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/routes/_index.tsx": js` import * as React from "react"; @@ -527,531 +529,3 @@ test.describe.skip("fetcher aborts and adjacent forms", () => { await page.waitForSelector("#idle", { timeout: 2000 }); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("useFetcher", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - let CHEESESTEAK = "CHEESESTEAK"; - let LUNCH = "LUNCH"; - let PARENT_LAYOUT_LOADER = "parent layout loader"; - let PARENT_LAYOUT_ACTION = "parent layout action"; - let PARENT_INDEX_LOADER = "parent index loader"; - let PARENT_INDEX_ACTION = "parent index action"; - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/routes/resource-route-action-only.ts": js` - import { json } from "@react-router/node"; - export function action() { - return new Response("${CHEESESTEAK}"); - } - `, - - "app/routes/fetcher-action-only-call.tsx": js` - import { useFetcher } from "react-router-dom"; - - export default function FetcherActionOnlyCall() { - let fetcher = useFetcher(); - - let executeFetcher = () => { - fetcher.submit(new URLSearchParams(), { - method: 'post', - action: '/resource-route-action-only', - }); - }; - - return ( - <> - - {fetcher.data &&
{fetcher.data}
} - - ); - } - `, - - "app/routes/resource-route.tsx": js` - export function loader() { - return new Response("${LUNCH}"); - } - export function action() { - return new Response("${CHEESESTEAK}"); - } - `, - - "app/routes/_index.tsx": js` - import { useFetcher } from "react-router-dom"; - export default function Index() { - let fetcher = useFetcher(); - return ( - <> - - - - - - -
{fetcher.data}
- - ); - } - `, - - "app/routes/parent.tsx": js` - import { Outlet } from "react-router-dom"; - - export function action() { - return new Response("${PARENT_LAYOUT_ACTION}"); - }; - - export function loader() { - return new Response("${PARENT_LAYOUT_LOADER}"); - }; - - export default function Parent() { - return ; - } - `, - - "app/routes/parent._index.tsx": js` - import { useFetcher } from "react-router-dom"; - - export function action() { - return new Response("${PARENT_INDEX_ACTION}"); - }; - - export function loader() { - return new Response("${PARENT_INDEX_LOADER}"); - }; - - export default function ParentIndex() { - let fetcher = useFetcher(); - - return ( - <> -
{fetcher.data}
- - - - - - - - - ); - } - `, - - "app/routes/fetcher-echo.tsx": js` - import { json } from "@react-router/node"; - import { useFetcher } from "react-router-dom"; - - export async function action({ request }) { - await new Promise(r => setTimeout(r, 1000)); - let contentType = request.headers.get('Content-Type'); - let value; - if (contentType.includes('application/json')) { - let json = await request.json(); - value = json === null ? json : json.value; - } else if (contentType.includes('text/plain')) { - value = await request.text(); - } else { - value = (await request.formData()).get('value'); - } - return json({ data: "ACTION (" + contentType + ") " + value }) - } - - export async function loader({ request }) { - await new Promise(r => setTimeout(r, 1000)); - let value = new URL(request.url).searchParams.get('value'); - return json({ data: "LOADER " + value }) - } - - export default function Index() { - let fetcherValues = []; - if (typeof window !== 'undefined') { - if (!window.fetcherValues) { - window.fetcherValues = []; - } - fetcherValues = window.fetcherValues - } - - let fetcher = useFetcher(); - - let currentValue = fetcher.state + '/' + fetcher.data?.data; - if (fetcherValues[fetcherValues.length - 1] !== currentValue) { - fetcherValues.push(currentValue) - } - - return ( - <> - - - - - - - - - {fetcher.state === 'idle' ?

IDLE

: null} -
{JSON.stringify(fetcherValues)}
- - ); - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test.describe("No JavaScript", () => { - test.use({ javaScriptEnabled: false }); - - test("Form can hit a loader", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - await Promise.all([ - page.waitForNavigation(), - app.clickSubmitButton("/resource-route", { - wait: false, - method: "get", - }), - ]); - // Check full HTML here - Chromium/Firefox/Webkit seem to render this in - // a
 but Edge puts it in some weird code editor markup:
-        // 
-        //   
-        expect(await app.getHtml()).toContain(LUNCH);
-      });
-
-      test("Form can hit an action", async ({ page }) => {
-        let app = new PlaywrightFixture(appFixture, page);
-        await app.goto("/");
-        await Promise.all([
-          page.waitForNavigation({ waitUntil: "load" }),
-          app.clickSubmitButton("/resource-route", {
-            wait: false,
-            method: "post",
-          }),
-        ]);
-        // Check full HTML here - Chromium/Firefox/Webkit seem to render this in
-        // a 
 but Edge puts it in some weird code editor markup:
-        // 
-        //   
-        expect(await app.getHtml()).toContain(CHEESESTEAK);
-      });
-    });
-
-    test("load can hit a loader", async ({ page }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-      await app.goto("/");
-      await app.clickElement("#fetcher-load");
-      await page.waitForSelector(`pre:has-text("${LUNCH}")`);
-    });
-
-    test("submit can hit an action", async ({ page }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-      await app.goto("/");
-      await app.clickElement("#fetcher-submit");
-      await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
-    });
-
-    test("submit can hit an action with json", async ({ page }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-      await app.goto("/fetcher-echo", true);
-      await page.fill("#fetcher-input", "input value");
-      await app.clickElement("#fetcher-submit-json");
-      await page.waitForSelector(`#fetcher-idle`);
-      expect(await app.getHtml()).toMatch(
-        'ACTION (application/json) input value"'
-      );
-    });
-
-    test("submit can hit an action with null json", async ({ page }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-      await app.goto("/fetcher-echo", true);
-      await app.clickElement("#fetcher-submit-json-null");
-      await new Promise((r) => setTimeout(r, 1000));
-      await page.waitForSelector(`#fetcher-idle`);
-      expect(await app.getHtml()).toMatch('ACTION (application/json) null"');
-    });
-
-    test("submit can hit an action with text", async ({ page }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-      await app.goto("/fetcher-echo", true);
-      await page.fill("#fetcher-input", "input value");
-      await app.clickElement("#fetcher-submit-text");
-      await page.waitForSelector(`#fetcher-idle`);
-      expect(await app.getHtml()).toMatch(
-        'ACTION (text/plain;charset=UTF-8) input value"'
-      );
-    });
-
-    test("submit can hit an action with empty text", async ({ page }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-      await app.goto("/fetcher-echo", true);
-      await app.clickElement("#fetcher-submit-text-empty");
-      await new Promise((r) => setTimeout(r, 1000));
-      await page.waitForSelector(`#fetcher-idle`);
-      expect(await app.getHtml()).toMatch(
-        'ACTION (text/plain;charset=UTF-8) "'
-      );
-    });
-
-    test("submit can hit an action only route", async ({ page }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-      await app.goto("/fetcher-action-only-call");
-      await app.clickElement("#fetcher-submit");
-      await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
-    });
-
-    test("fetchers handle ?index param correctly", async ({ page }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-      await app.goto("/parent");
-
-      await app.clickElement("#load-parent");
-      await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
-
-      await app.clickElement("#load-index");
-      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
-
-      // fetcher.submit({}) defaults to GET for the current Route
-      await app.clickElement("#submit-empty");
-      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
-
-      await app.clickElement("#submit-parent-get");
-      await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
-
-      await app.clickElement("#submit-index-get");
-      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
-
-      await app.clickElement("#submit-parent-post");
-      await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_ACTION}")`);
-
-      await app.clickElement("#submit-index-post");
-      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_ACTION}")`);
-    });
-
-    test("fetcher.load persists data through reloads", async ({ page }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-
-      await app.goto("/fetcher-echo", true);
-      expect(await app.getHtml("pre")).toMatch(
-        JSON.stringify(["idle/undefined"])
-      );
-
-      await page.fill("#fetcher-input", "1");
-      await app.clickElement("#fetcher-load");
-      await page.waitForSelector("#fetcher-idle");
-      expect(await app.getHtml("pre")).toMatch(
-        JSON.stringify(["idle/undefined", "loading/undefined", "idle/LOADER 1"])
-      );
-
-      await page.fill("#fetcher-input", "2");
-      await app.clickElement("#fetcher-load");
-      await page.waitForSelector("#fetcher-idle");
-      expect(await app.getHtml("pre")).toMatch(
-        JSON.stringify([
-          "idle/undefined",
-          "loading/undefined",
-          "idle/LOADER 1",
-          "loading/LOADER 1", // Preserves old data during reload
-          "idle/LOADER 2",
-        ])
-      );
-    });
-
-    test("fetcher.submit persists data through resubmissions", async ({
-      page,
-    }) => {
-      let app = new PlaywrightFixture(appFixture, page);
-
-      await app.goto("/fetcher-echo", true);
-      expect(await app.getHtml("pre")).toMatch(
-        JSON.stringify(["idle/undefined"])
-      );
-
-      await page.fill("#fetcher-input", "1");
-      await app.clickElement("#fetcher-submit");
-      await page.waitForSelector("#fetcher-idle");
-      expect(await app.getHtml("pre")).toMatch(
-        JSON.stringify([
-          "idle/undefined",
-          "submitting/undefined",
-          "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
-          "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
-        ])
-      );
-
-      await page.fill("#fetcher-input", "2");
-      await app.clickElement("#fetcher-submit");
-      await page.waitForSelector("#fetcher-idle");
-      expect(await app.getHtml("pre")).toMatch(
-        JSON.stringify([
-          "idle/undefined",
-          "submitting/undefined",
-          "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
-          "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
-          // Preserves old data during resubmissions
-          "submitting/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
-          "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
-          "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
-        ])
-      );
-    });
-  });
-
-  test.describe("fetcher aborts and adjacent forms", () => {
-    let fixture: Fixture;
-    let appFixture: AppFixture;
-
-    test.beforeAll(async () => {
-      fixture = await createFixture({
-        singleFetch: true,
-        files: {
-          "app/routes/_index.tsx": js`
-            import * as React from "react";
-            import {
-              Form,
-              useFetcher,
-              useLoaderData,
-              useNavigation
-            } from "react-router-dom";
-
-            export async function loader({ request }) {
-              // 1 second timeout on data
-              await new Promise((r) => setTimeout(r, 1000));
-              return { foo: 'bar' };
-            }
-
-            export default function Index() {
-              const [open, setOpen] = React.useState(true);
-              const { data } = useLoaderData();
-              const navigation = useNavigation();
-
-              return (
-                
- {navigation.state === 'idle' &&
Idle
} -
- -
- - - {open && setOpen(false)} />} -
- ); - } - - function Child({ onClose }) { - const fetcher = useFetcher(); - - return ( - - - - - ); - } - `, - - "app/routes/api.tsx": js` - export async function loader() { - await new Promise((resolve) => setTimeout(resolve, 500)); - return { message: 'Hello world!' } - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("Unmounting a fetcher does not cancel the request of an adjacent form", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - // Works as expected before the fetcher is loaded - - // submit the main form and unmount the fetcher form - await app.clickElement("#submit-and-close"); - // Wait for our navigation state to be "Idle" - await page.waitForSelector("#idle", { timeout: 2000 }); - - // Breaks after the fetcher is loaded - - // re-mount the fetcher form - await app.clickElement("#open"); - // submit the fetcher form - await app.clickElement("#submit-fetcher"); - // submit the main form and unmount the fetcher form - await app.clickElement("#submit-and-close"); - // Wait for navigation state to be "Idle" - await page.waitForSelector("#idle", { timeout: 2000 }); - }); - }); -}); diff --git a/integration/file-uploads-test.ts b/integration/file-uploads-test.ts index dcb0a78bd8..0a045407bc 100644 --- a/integration/file-uploads-test.ts +++ b/integration/file-uploads-test.ts @@ -11,153 +11,15 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe.skip("file-uploads", () => { +test.describe("file-uploads", () => { let fixture: Fixture; let appFixture: AppFixture; test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/fileUploadHandler.ts": js` - import * as path from "node:path"; - import * as url from "node:url"; - import { - unstable_composeUploadHandlers as composeUploadHandlers, - unstable_createFileUploadHandler as createFileUploadHandler, - unstable_createMemoryUploadHandler as createMemoryUploadHandler, - } from "@react-router/node"; - - const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); - export let uploadHandler = composeUploadHandlers( - createFileUploadHandler({ - directory: path.resolve(__dirname, "..", "..", "uploads"), - maxPartSize: 10_000, // 10kb - // you probably want to avoid conflicts in production - // do not set to false or passthrough filename in real - // applications. - avoidFileConflicts: false, - file: ({ filename }) => filename - }), - createMemoryUploadHandler(), - ); - `, - "app/routes/file-upload.tsx": js` - import { - unstable_parseMultipartFormData as parseMultipartFormData, - } from "@react-router/node"; - import { Form, useActionData } from "react-router-dom"; - import { uploadHandler } from "~/fileUploadHandler"; - - export let action = async ({ request }) => { - try { - let formData = await parseMultipartFormData(request, uploadHandler); - - if (formData.get("test") !== "hidden") { - return { errorMessage: "hidden field not in form data" }; - } - - let file = formData.get("file"); - if (typeof file === "string" || !file) { - return { errorMessage: "invalid file type" }; - } - - return { name: file.name, size: file.size }; - } catch (error) { - return { errorMessage: error.message }; - } - }; - - export default function Upload() { - let actionData = useActionData(); - return ( - <> -
- - - - -
- {actionData ?
{JSON.stringify(actionData, null, 2)}
: null} - - ); - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("handles files under upload size limit", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let uploadFile = path.join( - fixture.projectDir, - "toUpload", - "underLimit.txt" - ); - let uploadData = Array(1_000).fill("a").join(""); // 1kb - await fs - .mkdir(path.dirname(uploadFile), { recursive: true }) - .catch(() => {}); - await fs.writeFile(uploadFile, uploadData, "utf8"); - - await app.goto("/file-upload"); - await app.uploadFile("#file", uploadFile); - await app.clickSubmitButton("/file-upload"); - await page.waitForSelector("pre"); - expect(await app.getHtml("pre")).toBe(`
-{
-  "name": "underLimit.txt",
-  "size": 1000
-}
`); - - let written = await fs.readFile( - url.pathToFileURL( - path.join(fixture.projectDir, "uploads/underLimit.txt") - ), - "utf8" - ); - expect(written).toBe(uploadData); - }); - - test("rejects files over upload size limit", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let uploadFile = path.join(fixture.projectDir, "toUpload", "overLimit.txt"); - let uploadData = Array(10_001).fill("a").join(""); // 10.000001KB - await fs - .mkdir(path.dirname(uploadFile), { recursive: true }) - .catch(() => {}); - await fs.writeFile(uploadFile, uploadData, "utf8"); - - await app.goto("/file-upload"); - await app.uploadFile("#file", uploadFile); - await app.clickSubmitButton("/file-upload"); - await page.waitForSelector("pre"); - expect(await app.getHtml("pre")).toBe(`
-{
-  "errorMessage": "Field \\"file\\" exceeded upload size of 10000 bytes."
-}
`); - }); -}); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("file-uploads", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/fileUploadHandler.ts": js` import * as path from "node:path"; import * as url from "node:url"; import { @@ -180,7 +42,7 @@ test.describe("single fetch", () => { createMemoryUploadHandler(), ); `, - "app/routes/file-upload.tsx": js` + "app/routes/file-upload.tsx": js` import { unstable_parseMultipartFormData as parseMultipartFormData, } from "@react-router/node"; @@ -221,71 +83,66 @@ test.describe("single fetch", () => { ); } `, - }, - }); - - appFixture = await createAppFixture(fixture); + }, }); - test.afterAll(() => { - appFixture.close(); - }); + appFixture = await createAppFixture(fixture); + }); - test("handles files under upload size limit", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let uploadFile = path.join( - fixture.projectDir, - "toUpload", - "underLimit.txt" - ); - let uploadData = Array(1_000).fill("a").join(""); // 1kb - await fs - .mkdir(path.dirname(uploadFile), { recursive: true }) - .catch(() => {}); - await fs.writeFile(uploadFile, uploadData, "utf8"); + test.afterAll(() => { + appFixture.close(); + }); - await app.goto("/file-upload"); - await app.uploadFile("#file", uploadFile); - await app.clickSubmitButton("/file-upload"); - await page.waitForSelector("pre"); - expect(await app.getHtml("pre")).toBe(`
+  test("handles files under upload size limit", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    let uploadFile = path.join(
+      fixture.projectDir,
+      "toUpload",
+      "underLimit.txt"
+    );
+    let uploadData = Array(1_000).fill("a").join(""); // 1kb
+    await fs
+      .mkdir(path.dirname(uploadFile), { recursive: true })
+      .catch(() => {});
+    await fs.writeFile(uploadFile, uploadData, "utf8");
+
+    await app.goto("/file-upload");
+    await app.uploadFile("#file", uploadFile);
+    await app.clickSubmitButton("/file-upload");
+    await page.waitForSelector("pre");
+    expect(await app.getHtml("pre")).toBe(`
 {
   "name": "underLimit.txt",
   "size": 1000
 }
`); - let written = await fs.readFile( - url.pathToFileURL( - path.join(fixture.projectDir, "uploads/underLimit.txt") - ), - "utf8" - ); - expect(written).toBe(uploadData); - }); + let written = await fs.readFile( + url.pathToFileURL( + path.join(fixture.projectDir, "uploads/underLimit.txt") + ), + "utf8" + ); + expect(written).toBe(uploadData); + }); - test("rejects files over upload size limit", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let uploadFile = path.join( - fixture.projectDir, - "toUpload", - "overLimit.txt" - ); - let uploadData = Array(10_001).fill("a").join(""); // 10.000001KB - await fs - .mkdir(path.dirname(uploadFile), { recursive: true }) - .catch(() => {}); - await fs.writeFile(uploadFile, uploadData, "utf8"); + test("rejects files over upload size limit", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let uploadFile = path.join(fixture.projectDir, "toUpload", "overLimit.txt"); + let uploadData = Array(10_001).fill("a").join(""); // 10.000001KB + await fs + .mkdir(path.dirname(uploadFile), { recursive: true }) + .catch(() => {}); + await fs.writeFile(uploadFile, uploadData, "utf8"); - await app.goto("/file-upload"); - await app.uploadFile("#file", uploadFile); - await app.clickSubmitButton("/file-upload"); - await page.waitForSelector("pre"); - expect(await app.getHtml("pre")).toBe(`
+    await app.goto("/file-upload");
+    await app.uploadFile("#file", uploadFile);
+    await app.clickSubmitButton("/file-upload");
+    await page.waitForSelector("pre");
+    expect(await app.getHtml("pre")).toBe(`
 {
   "errorMessage": "Field \\"file\\" exceeded upload size of 10000 bytes."
 }
`); - }); }); }); diff --git a/integration/flat-routes-test.ts b/integration/flat-routes-test.ts index 86e65f7d4d..0222304698 100644 --- a/integration/flat-routes-test.ts +++ b/integration/flat-routes-test.ts @@ -24,9 +24,7 @@ test.describe("flat routes", () => { export default defineConfig({ plugins: [reactRouter({ - ignoredRouteFiles: [${JSON.stringify( - `**/${IGNORED_ROUTE}.*` - )}], + ignoredRouteFiles: [${JSON.stringify(`**/${IGNORED_ROUTE}.*`)}], })], }); `, diff --git a/integration/form-data-test.ts b/integration/form-data-test.ts index c9f2b48fa0..b2b6121536 100644 --- a/integration/form-data-test.ts +++ b/integration/form-data-test.ts @@ -5,11 +5,11 @@ import type { Fixture } from "./helpers/create-fixture.js"; let fixture: Fixture; -test.describe.skip("multi fetch", () => { - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/_index.tsx": js` +test.beforeAll(async () => { + fixture = await createFixture({ + singleFetch: true, + files: { + "app/routes/_index.tsx": js` import { json } from "@react-router/node"; export async function action({ request }) { @@ -21,101 +21,41 @@ test.describe.skip("multi fetch", () => { return json("pizza"); } `, - }, - }); - }); - - test("invalid content-type does not crash server", async () => { - let response = await fixture.requestDocument("/", { - method: "post", - headers: { "content-type": "application/json" }, - }); - expect(await response.text()).toMatch("no pizza"); - }); - - test("invalid urlencoded body does not crash server", async () => { - let response = await fixture.requestDocument("/", { - method: "post", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: "$rofl this is totally invalid$", - }); - expect(await response.text()).toMatch("pizza"); - }); - - test("invalid multipart content-type does not crash server", async () => { - let response = await fixture.requestDocument("/", { - method: "post", - headers: { "content-type": "multipart/form-data" }, - body: "$rofl this is totally invalid$", - }); - expect(await response.text()).toMatch("pizza"); - }); - - test("invalid multipart body does not crash server", async () => { - let response = await fixture.requestDocument("/", { - method: "post", - headers: { "content-type": "multipart/form-data; boundary=abc" }, - body: "$rofl this is totally invalid$", - }); - expect(await response.text()).toMatch("pizza"); + }, }); }); -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/routes/_index.tsx": js` - import { json } from "@react-router/node"; - - export async function action({ request }) { - try { - await request.formData() - } catch { - return json("no pizza"); - } - return json("pizza"); - } - `, - }, - }); - }); - - test("invalid content-type does not crash server", async () => { - let response = await fixture.requestDocument("/", { - method: "post", - headers: { "content-type": "application/json" }, - }); - expect(await response.text()).toMatch("no pizza"); +test("invalid content-type does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "application/json" }, }); + expect(await response.text()).toMatch("no pizza"); +}); - test("invalid urlencoded body does not crash server", async () => { - let response = await fixture.requestDocument("/", { - method: "post", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: "$rofl this is totally invalid$", - }); - expect(await response.text()).toMatch("pizza"); +test("invalid urlencoded body does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "$rofl this is totally invalid$", }); + expect(await response.text()).toMatch("pizza"); +}); - test("invalid multipart content-type does not crash server", async () => { - let response = await fixture.requestDocument("/", { - method: "post", - headers: { "content-type": "multipart/form-data" }, - body: "$rofl this is totally invalid$", - }); - expect(await response.text()).toMatch("pizza"); +test("invalid multipart content-type does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "multipart/form-data" }, + body: "$rofl this is totally invalid$", }); + expect(await response.text()).toMatch("pizza"); +}); - test("invalid multipart body does not crash server", async () => { - let response = await fixture.requestDocument("/", { - method: "post", - headers: { "content-type": "multipart/form-data; boundary=abc" }, - body: "$rofl this is totally invalid$", - }); - expect(await response.text()).toMatch("pizza"); +test("invalid multipart body does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "multipart/form-data; boundary=abc" }, + body: "$rofl this is totally invalid$", }); + expect(await response.text()).toMatch("pizza"); }); diff --git a/integration/form-test.ts b/integration/form-test.ts index 8d3b9fa8c7..8f6cb536a4 100644 --- a/integration/form-test.ts +++ b/integration/form-test.ts @@ -8,7 +8,7 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { getElement, PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe.skip("Forms", () => { +test.describe("Forms", () => { let fixture: Fixture; let appFixture: AppFixture; @@ -52,7 +52,7 @@ test.describe.skip("Forms", () => { let SPLAT_ROUTE_TOO_MANY_DOTS_ACTION = "splat-route-too-many-dots"; test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { + await context.route(/\.data$/, async (route) => { await new Promise((resolve) => setTimeout(resolve, 50)); route.continue(); }); @@ -60,439 +60,440 @@ test.describe.skip("Forms", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/routes/get-submission.tsx": js` - import { useLoaderData, Form } from "react-router-dom"; + import { useLoaderData, Form } from "react-router-dom"; - export function loader({ request }) { - let url = new URL(request.url); - return url.searchParams.toString() - } + export function loader({ request }) { + let url = new URL(request.url); + return url.searchParams.toString() + } - export default function() { - let data = useLoaderData(); - return ( - <> -
- - - -
+ export default function() { + let data = useLoaderData(); + return ( + <> +
+ + + +
-
- - -
+
+ + +
+ +
+ + +
-
- -
+ value="${SQUID_INK_HOTDOG}" + >Orphan - - -
- - + + + - -
+ + -
{data}
- - ) - } - `, +
{data}
+ + ) + } + `, "app/routes/about.tsx": js` - export async function action({ request }) { - return json({ submitted: true }); - } - export default function () { - return

About

; - } - `, + export async function action({ request }) { + return json({ submitted: true }); + } + export default function () { + return

About

; + } + `, "app/routes/inbox.tsx": js` - import { Form } from "react-router-dom"; - export default function() { - return ( - <> -
- -
-
- -
-
- -
-
- -
-
- -
- - ) - } - `, + import { Form } from "react-router-dom"; + export default function() { + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) + } + `, "app/routes/blog.tsx": js` - import { Form, Outlet } from "react-router-dom"; - export default function() { - return ( - <> -

Blog

-
- -
-
- -
-
- -
-
- -
-
- -
- - - ) - } - `, + import { Form, Outlet } from "react-router-dom"; + export default function() { + return ( + <> +

Blog

+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + ) + } + `, "app/routes/blog._index.tsx": js` - import { Form } from "react-router-dom"; - export function action() { - return { ok: true }; - } - export default function() { - return ( - <> -
- - -
-
- -
-
- -
-
- -
-
- -
+ import { Form } from "react-router-dom"; + export function action() { + return { ok: true }; + } + export default function() { + return ( + <> +
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
-
- - -
- - ) - } - `, +
+ + +
+ + ) + } + `, "app/routes/blog.$postId.tsx": js` - import { Form } from "react-router-dom"; - export default function() { - return ( - <> -
- -
-
- -
-
- -
-
- -
-
- -
- - ) - } - `, + import { Form } from "react-router-dom"; + export default function() { + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) + } + `, "app/routes/projects.tsx": js` - import { Form, Outlet } from "react-router-dom"; - export default function() { - return ( - <> -

Projects

- - - ) - } - `, + import { Form, Outlet } from "react-router-dom"; + export default function() { + return ( + <> +

Projects

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

All projects

- } - `, + export default function() { + return

All projects

+ } + `, "app/routes/projects.$.tsx": js` - import { Form } from "react-router-dom"; - export default function() { - return ( - <> -
- -
-
- -
-
- -
-
- -
-
- -
- - ) - } - `, + import { Form } from "react-router-dom"; + export default function() { + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) + } + `, "app/routes/stop-propagation.tsx": js` - import { json } from "@react-router/node"; - import { Form, useActionData } from "react-router-dom"; + import { json } from "@react-router/node"; + import { Form, useActionData } from "react-router-dom"; - export async function action({ request }) { - let formData = await request.formData(); - return json(Object.fromEntries(formData)); - } + export async function action({ request }) { + let formData = await request.formData(); + return json(Object.fromEntries(formData)); + } - export default function Index() { - let actionData = useActionData(); - return ( -
event.stopPropagation()}> - {actionData ?
{JSON.stringify(actionData)}
: null} -
- -
-
- ) - } - `, + export default function Index() { + let actionData = useActionData(); + return ( +
event.stopPropagation()}> + {actionData ?
{JSON.stringify(actionData)}
: null} +
+ +
+
+ ) + } + `, "app/routes/form-method.tsx": js` - import { Form, useActionData, useLoaderData, useSearchParams } from "react-router-dom"; - import { json } from "@react-router/node"; + import { Form, useActionData, useLoaderData, useSearchParams } from "react-router-dom"; + import { json } from "@react-router/node"; - export function action({ request }) { - return json(request.method) - } + export function action({ request }) { + return json(request.method) + } - export function loader({ request }) { - return json(request.method) - } + export function loader({ request }) { + return json(request.method) + } - export default function() { - let actionData = useActionData(); - let loaderData = useLoaderData(); - let [searchParams] = useSearchParams(); - let formMethod = searchParams.get('method') || 'GET'; - let submitterFormMethod = searchParams.get('submitterFormMethod') || 'GET'; - return ( - <> -
- - -
- {actionData ?
{actionData}
: null} -
{loaderData}
- - ) - } - `, + export default function() { + let actionData = useActionData(); + let loaderData = useLoaderData(); + let [searchParams] = useSearchParams(); + let formMethod = searchParams.get('method') || 'GET'; + let submitterFormMethod = searchParams.get('submitterFormMethod') || 'GET'; + return ( + <> +
+ + +
+ {actionData ?
{actionData}
: null} +
{loaderData}
+ + ) + } + `, "app/routes/submitter.tsx": js` - import { Form } from "react-router-dom"; - - export default function() { - return ( - <> - -
- - - - - - - - - -
- - ) - } - `, + import { Form } from "react-router-dom"; + + export default function() { + return ( + <> + +
+ + + + + + + + + +
+ + ) + } + `, "app/routes/file-upload.tsx": js` - import { Form, useSearchParams } from "react-router-dom"; - - export default function() { - const [params] = useSearchParams(); - return ( -
- - - -
- {actionData ?

{JSON.stringify(actionData)}

: null} - - ) - } - `, + export async function action({ request }) { + let formData = await request.formData(); + return json({ + text: formData.get('text'), + file: { + name: formData.get('file').name, + size: formData.get('file').size, + }, + fileMultiple: formData.getAll('fileMultiple').map(f => ({ + name: f.name, + size: f.size, + })), + }) + } + + export default function() { + const actionData = useActionData(); + return ( +
+ + + + + {actionData ?

{JSON.stringify(actionData)}

: null} +
+ ) + } + `, // Generic route for outputting url-encoded form data (either from the request body or search params) // // TODO: refactor other tests to use this "app/routes/outputFormData.tsx": js` - import { useActionData, useSearchParams } from "react-router-dom"; - - export async function action({ request }) { - const formData = await request.formData(); - const body = new URLSearchParams(); - for (let [key, value] of formData) { - body.append( - key, - value instanceof File ? await streamToString(value.stream()) : value - ); + import { useActionData, useSearchParams } from "react-router-dom"; + + export async function action({ request }) { + const formData = await request.formData(); + const body = new URLSearchParams(); + for (let [key, value] of formData) { + body.append( + key, + value instanceof File ? await streamToString(value.stream()) : value + ); + } + return body.toString(); } - return body.toString(); - } - export default function OutputFormData() { - const requestBody = useActionData(); - const searchParams = useSearchParams()[0]; - return ; - } - `, + export default function OutputFormData() { + const requestBody = useActionData(); + const searchParams = useSearchParams()[0]; + return ; + } + `, "myfile.txt": "stuff", "app/routes/pathless-layout-parent.tsx": js` - import { json } from '@react-router/server-runtime' - import { Form, Outlet, useActionData } from 'react-router-dom' + import { json } from '@react-router/server-runtime' + import { Form, Outlet, useActionData } from 'react-router-dom' - export async function action({ request }) { - return json({ submitted: true }); - } - export default function () { - let data = useActionData(); - return ( - <> -
-

Pathless Layout Parent

- -
- -

{data?.submitted === true ? 'Submitted - Yes' : 'Submitted - No'}

- - ); - } - `, + export async function action({ request }) { + return json({ submitted: true }); + } + export default function () { + let data = useActionData(); + return ( + <> +
+

Pathless Layout Parent

+ +
+ +

{data?.submitted === true ? 'Submitted - Yes' : 'Submitted - No'}

+ + ); + } + `, "app/routes/pathless-layout-parent._pathless.nested.tsx": js` - import { Outlet } from 'react-router-dom'; - - export default function () { - return ( - <> -

Pathless Layout

- - - ); - } - `, + import { Outlet } from 'react-router-dom'; + + export default function () { + return ( + <> +

Pathless Layout

+ + + ); + } + `, "app/routes/pathless-layout-parent._pathless.nested._index.tsx": js` - export default function () { - return

Pathless Layout Index

- } - `, + export default function () { + return

Pathless Layout Index

+ } + `, }, }); @@ -870,1408 +871,269 @@ test.describe.skip("Forms", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/blog"); let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); - - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); - }); - - test.describe("in a splat route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); - - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/projects?foo=bar"); - }); - - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); - - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); - }); - }); - - let FORM_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; - let NATIVE_FORM_METHODS = ["GET", "POST"]; - - test.describe("uses the Form `method` attribute", () => { - FORM_METHODS.forEach((method) => { - test(`submits with ${method}`, async ({ page, javaScriptEnabled }) => { - test.fail( - !javaScriptEnabled && !NATIVE_FORM_METHODS.includes(method), - `Native
doesn't support method ${method} #4420` - ); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/form-method?method=${method}`, true); - await app.clickElement(`text=Submit`); - if (method !== "GET") { - await page.waitForSelector("#action-method"); - expect(await app.getHtml("pre#action-method")).toBe( - `
${method}
` - ); - } - expect(await app.getHtml("pre#loader-method")).toBe( - `
GET
` - ); - }); - }); - }); - - test.describe("overrides the Form `method` attribute with the submitter's `formMethod` attribute", () => { - // NOTE: HTMLButtonElement only supports get/post as formMethod, which is why we don't test put/patch/delete - NATIVE_FORM_METHODS.forEach((overrideMethod) => { - // ensure the form's method is different from the submitter's - let method = overrideMethod === "GET" ? "POST" : "GET"; - test(`submits with ${overrideMethod} instead of ${method}`, async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto( - `/form-method?method=${method}&submitterFormMethod=${overrideMethod}`, - true - ); - await app.clickElement(`text=Submit with ${overrideMethod}`); - if (overrideMethod !== "GET") { - await page.waitForSelector("#action-method"); - expect(await app.getHtml("pre#action-method")).toBe( - `
${overrideMethod}
` - ); - } - expect(await app.getHtml("pre#loader-method")).toBe( - `
GET
` - ); - }); - }); - }); - - test("submits the submitter's value(s) in tree order in the form data", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/submitter"); - await app.clickElement("text=Add Task"); - expect((await app.getElement("#formData")).val()).toBe( - "tasks=first&tasks=second&tasks=&tasks=last" - ); - - await app.goto("/submitter"); - await app.clickElement("text=No Name"); - expect((await app.getElement("#formData")).val()).toBe( - "tasks=first&tasks=second&tasks=last" - ); - - await app.goto("/submitter"); - await app.clickElement("[alt='Add Task']"); - expect((await app.getElement("#formData")).val()).toMatch( - /^tasks=first&tasks=second&tasks.x=\d+&tasks.y=\d+&tasks=last$/ - ); - - await app.goto("/submitter"); - await app.clickElement("[alt='No Name']"); - expect((await app.getElement("#formData")).val()).toMatch( - /^tasks=first&tasks=second&x=\d+&y=\d+&tasks=last$/ - ); - - await app.goto("/submitter"); - await app.clickElement("text=Outside"); - expect((await app.getElement("#formData")).val()).toBe( - "tasks=outside&tasks=first&tasks=second&tasks=last" - ); - }); - - test("sends file names when submitting via url encoding", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - let myFile = fixture.projectDir + "/myfile.txt"; - - await app.goto("/file-upload"); - await app.uploadFile(`[name=filey]`, myFile); - await app.uploadFile(`[name=filey2]`, myFile, myFile); - await app.clickElement("button"); - await page.waitForSelector("#formData"); - - expect((await app.getElement("#formData")).val()).toBe( - "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" - ); - - await app.goto("/file-upload?method=post"); - await app.uploadFile(`[name=filey]`, myFile); - await app.uploadFile(`[name=filey2]`, myFile, myFile); - await app.clickElement("button"); - await page.waitForSelector("#formData"); - - expect((await app.getElement("#formData")).val()).toBe( - "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" - ); - }); - - test("empty file inputs resolve to File objects on the server", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/empty-file-upload"); - await app.clickSubmitButton("/empty-file-upload"); - await page.waitForSelector("#action-data"); - expect((await app.getElement("#action-data")).text()).toContain( - '{"text":"","file":{"name":"","size":0},"fileMultiple":[{"name":"","size":0}]}' - ); - }); - - test("pathless layout routes are ignored in form actions", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/pathless-layout-parent/nested"); - let html = await app.getHtml(); - expect(html).toMatch("Pathless Layout Parent"); - expect(html).toMatch("Pathless Layout "); - expect(html).toMatch("Pathless Layout Index"); - - let el = getElement(html, `form`); - expect(el.attr("action")).toMatch("/pathless-layout-parent"); - - expect(await app.getHtml()).toMatch("Submitted - No"); - // This submission should ignore the index route and the pathless layout - // route above it and hit the action in routes/pathless-layout-parent.jsx - await app.clickSubmitButton("/pathless-layout-parent"); - await page.waitForSelector("text=Submitted - Yes"); - expect(await app.getHtml()).toMatch("Submitted - Yes"); - }); - } -}); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("Forms", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - let KEYBOARD_INPUT = "KEYBOARD_INPUT"; - let CHECKBOX_BUTTON = "CHECKBOX_BUTTON"; - let ORPHAN_BUTTON = "ORPHAN_BUTTON"; - let FORM_WITH_ACTION_INPUT = "FORM_WITH_ACTION_INPUT"; - let FORM_WITH_ORPHAN = "FORM_WITH_ORPHAN"; - let LUNCH = "LUNCH"; - let CHEESESTEAK = "CHEESESTEAK"; - let LAKSA = "LAKSA"; - let SQUID_INK_HOTDOG = "SQUID_INK_HOTDOG"; - let ACTION = "action"; - let EAT = "EAT"; - - let STATIC_ROUTE_NO_ACTION = "static-route-none"; - let STATIC_ROUTE_ABSOLUTE_ACTION = "static-route-abs"; - let STATIC_ROUTE_CURRENT_ACTION = "static-route-cur"; - let STATIC_ROUTE_PARENT_ACTION = "static-route-parent"; - let STATIC_ROUTE_TOO_MANY_DOTS_ACTION = "static-route-too-many-dots"; - let INDEX_ROUTE_NO_ACTION = "index-route-none"; - let INDEX_ROUTE_NO_ACTION_POST = "index-route-none-post"; - let INDEX_ROUTE_ABSOLUTE_ACTION = "index-route-abs"; - let INDEX_ROUTE_CURRENT_ACTION = "index-route-cur"; - let INDEX_ROUTE_PARENT_ACTION = "index-route-parent"; - let INDEX_ROUTE_TOO_MANY_DOTS_ACTION = "index-route-too-many-dots"; - let DYNAMIC_ROUTE_NO_ACTION = "dynamic-route-none"; - let DYNAMIC_ROUTE_ABSOLUTE_ACTION = "dynamic-route-abs"; - let DYNAMIC_ROUTE_CURRENT_ACTION = "dynamic-route-cur"; - let DYNAMIC_ROUTE_PARENT_ACTION = "dynamic-route-parent"; - let DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION = "dynamic-route-too-many-dots"; - let LAYOUT_ROUTE_NO_ACTION = "layout-route-none"; - let LAYOUT_ROUTE_ABSOLUTE_ACTION = "layout-route-abs"; - let LAYOUT_ROUTE_CURRENT_ACTION = "layout-route-cur"; - let LAYOUT_ROUTE_PARENT_ACTION = "layout-route-parent"; - let LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION = "layout-route-too-many-dots"; - let SPLAT_ROUTE_NO_ACTION = "splat-route-none"; - let SPLAT_ROUTE_ABSOLUTE_ACTION = "splat-route-abs"; - let SPLAT_ROUTE_CURRENT_ACTION = "splat-route-cur"; - let SPLAT_ROUTE_PARENT_ACTION = "splat-route-parent"; - let SPLAT_ROUTE_TOO_MANY_DOTS_ACTION = "splat-route-too-many-dots"; - - test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); - }); - }); - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/routes/get-submission.tsx": js` - import { useLoaderData, Form } from "react-router-dom"; - - export function loader({ request }) { - let url = new URL(request.url); - return url.searchParams.toString() - } - - export default function() { - let data = useLoaderData(); - return ( - <> - - - - -
- -
- - -
- -
- - -
- - - -
- - - - -
- -
{data}
- - ) - } - `, - - "app/routes/about.tsx": js` - export async function action({ request }) { - return json({ submitted: true }); - } - export default function () { - return

About

; - } - `, - - "app/routes/inbox.tsx": js` - import { Form } from "react-router-dom"; - export default function() { - return ( - <> -
- -
-
- -
-
- -
-
- -
-
- -
- - ) - } - `, - - "app/routes/blog.tsx": js` - import { Form, Outlet } from "react-router-dom"; - export default function() { - return ( - <> -

Blog

-
- -
-
- -
-
- -
-
- -
-
- -
- - - ) - } - `, - - "app/routes/blog._index.tsx": js` - import { Form } from "react-router-dom"; - export function action() { - return { ok: true }; - } - export default function() { - return ( - <> -
- - -
-
- -
-
- -
-
- -
-
- -
- -
- - -
- - ) - } - `, - - "app/routes/blog.$postId.tsx": js` - import { Form } from "react-router-dom"; - export default function() { - return ( - <> -
- -
-
- -
-
- -
-
- -
-
- -
- - ) - } - `, - - "app/routes/projects.tsx": js` - import { Form, Outlet } from "react-router-dom"; - export default function() { - return ( - <> -

Projects

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

All projects

- } - `, - - "app/routes/projects.$.tsx": js` - import { Form } from "react-router-dom"; - export default function() { - return ( - <> -
- -
-
- -
-
- -
-
- -
-
- -
- - ) - } - `, - - "app/routes/stop-propagation.tsx": js` - import { json } from "@react-router/node"; - import { Form, useActionData } from "react-router-dom"; - - export async function action({ request }) { - let formData = await request.formData(); - return json(Object.fromEntries(formData)); - } - - export default function Index() { - let actionData = useActionData(); - return ( -
event.stopPropagation()}> - {actionData ?
{JSON.stringify(actionData)}
: null} -
- -
-
- ) - } - `, - - "app/routes/form-method.tsx": js` - import { Form, useActionData, useLoaderData, useSearchParams } from "react-router-dom"; - import { json } from "@react-router/node"; - - export function action({ request }) { - return json(request.method) - } - - export function loader({ request }) { - return json(request.method) - } - - export default function() { - let actionData = useActionData(); - let loaderData = useLoaderData(); - let [searchParams] = useSearchParams(); - let formMethod = searchParams.get('method') || 'GET'; - let submitterFormMethod = searchParams.get('submitterFormMethod') || 'GET'; - return ( - <> -
- - -
- {actionData ?
{actionData}
: null} -
{loaderData}
- - ) - } - `, - - "app/routes/submitter.tsx": js` - import { Form } from "react-router-dom"; - - export default function() { - return ( - <> - -
- - - - - - - - - -
- - ) - } - `, - - "app/routes/file-upload.tsx": js` - import { Form, useSearchParams } from "react-router-dom"; - - export default function() { - const [params] = useSearchParams(); - return ( -
- - - -
- {actionData ?

{JSON.stringify(actionData)}

: null} - - ) - } - `, - - // Generic route for outputting url-encoded form data (either from the request body or search params) - // - // TODO: refactor other tests to use this - "app/routes/outputFormData.tsx": js` - import { useActionData, useSearchParams } from "react-router-dom"; - - export async function action({ request }) { - const formData = await request.formData(); - const body = new URLSearchParams(); - for (let [key, value] of formData) { - body.append( - key, - value instanceof File ? await streamToString(value.stream()) : value - ); - } - return body.toString(); - } - - export default function OutputFormData() { - const requestBody = useActionData(); - const searchParams = useSearchParams()[0]; - return ; - } - `, - - "myfile.txt": "stuff", - - "app/routes/pathless-layout-parent.tsx": js` - import { json } from '@react-router/server-runtime' - import { Form, Outlet, useActionData } from 'react-router-dom' - - export async function action({ request }) { - return json({ submitted: true }); - } - export default function () { - let data = useActionData(); - return ( - <> -
-

Pathless Layout Parent

- -
- -

{data?.submitted === true ? 'Submitted - Yes' : 'Submitted - No'}

- - ); - } - `, - - "app/routes/pathless-layout-parent._pathless.nested.tsx": js` - import { Outlet } from 'react-router-dom'; - - export default function () { - return ( - <> -

Pathless Layout

- - - ); - } - `, - - "app/routes/pathless-layout-parent._pathless.nested._index.tsx": js` - export default function () { - return

Pathless Layout Index

- } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test.describe("without JavaScript", () => { - test.use({ javaScriptEnabled: false }); - - runFormTests(); - }); - - test.describe("with JavaScript", () => { - test.use({ javaScriptEnabled: true }); // explicitly set so we don't have to check against undefined - - runFormTests(); - }); - - function runFormTests() { - test("posts to a loader", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - // this indirectly tests that clicking SVG children in buttons works - await app.goto("/get-submission"); - await app.clickSubmitButton("/get-submission", { wait: true }); - await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); - }); + let el = getElement(html, `#${LAYOUT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); - test("posts to a loader with an ", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await app.clickElement(`#${FORM_WITH_ACTION_INPUT} button`); - await page.waitForSelector(`pre:has-text("${EAT}")`); - }); + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); - test("posts to a loader with button data with click", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await app.clickElement("#buttonWithValue"); - await page.waitForSelector(`pre:has-text("${LAKSA}")`); - }); + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); - test("posts to a loader with button data with keyboard", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await page.focus(`#${KEYBOARD_INPUT}`); - await app.waitForNetworkAfter(async () => { - await page.keyboard.press("Enter"); - // there can be a delay before the request gets kicked off (worse with JS disabled) - await new Promise((resolve) => setTimeout(resolve, 50)); + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); }); - await page.waitForSelector(`pre:has-text("${LAKSA}")`); - }); - test("posts with the correct checkbox data", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await app.clickElement(`#${CHECKBOX_BUTTON}`); - await page.waitForSelector(`pre:has-text("${LAKSA}")`); - await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); }); - test("posts button data from outside the form", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/get-submission"); - await app.clickElement(`#${ORPHAN_BUTTON}`); - await page.waitForSelector(`pre:has-text("${SQUID_INK_HOTDOG}")`); - }); + test.describe("in a splat route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); - test( - "when clicking on a submit button as a descendant of an element that " + - "stops propagation on click, still passes the clicked submit button's " + - "`name` and `value` props to the request payload", - async ({ page }) => { + test("no action resolves to URL including search params", async ({ + page, + }) => { let app = new PlaywrightFixture(appFixture, page); - await app.goto("/stop-propagation"); - await app.clickSubmitButton("/stop-propagation", { wait: true }); - await page.waitForSelector("#action-data"); - expect(await app.getHtml()).toMatch('{"intent":"add"}'); - } - ); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/projects?foo=bar"); + }); - test.describe("
action", () => { - test.describe("in a static route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/inbox"); - }); - - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/inbox?foo=bar"); - }); - - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/inbox"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/inbox"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); - - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/inbox"); - let html = await app.getHtml(); - let el = getElement(html, `#${STATIC_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); }); - test.describe("in a dynamic route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog/abc"); - }); - - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog/abc?foo=bar"); - }); - - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog/abc"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog/abc"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog/abc"); - let html = await app.getHtml(); - let el = getElement(html, `#${DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); }); - test.describe("in an index route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog?index&foo=bar"); - }); - - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); - - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); - - test("handles search params correctly on GET submissions", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - - // Start with a query param - await app.goto("/blog?junk=1"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toBe("/blog?index&junk=1"); - expect(app.page.url()).toMatch(/\/blog\?junk=1$/); - - // On submission, we replace existing parameters (reflected in the - // form action) with the values from the form data. We also do not - // need to preserve the index param in the URL on GET submissions - await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); - html = await app.getHtml(); - el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toBe("/blog?index&foo=1"); - expect(app.page.url()).toMatch(/\/blog\?foo=1$/); - - // Does not append duplicate params on re-submissions - await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); - html = await app.getHtml(); - el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toBe("/blog?index&foo=1"); - expect(app.page.url()).toMatch(/\/blog\?foo=1$/); - }); - - test("handles search params correctly on POST submissions", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - - // Start with a query param - await app.goto("/blog?junk=1"); - let html = await app.getHtml(); - let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); - expect(el.attr("action")).toBe("/blog?index&junk=1"); - expect(app.page.url()).toMatch(/\/blog\?junk=1$/); - - // Form action reflects the current params and change them on submission - await app.clickElement(`#${INDEX_ROUTE_NO_ACTION_POST} button`); - html = await app.getHtml(); - el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); - expect(el.attr("action")).toBe("/blog?index&junk=1"); - await page.waitForURL(/\/blog\?index&junk=1$/); - expect(app.page.url()).toMatch(/\/blog\?index&junk=1$/); - }); + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); }); - test.describe("in a layout route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/blog?foo=bar"); - }); - - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/blog"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); - - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/blog"); - let html = await app.getHtml(); - let el = getElement(html, `#${LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); }); - test.describe("in a splat route", () => { - test("no action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); - - test("no action resolves to URL including search params", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); - expect(el.attr("action")).toMatch("/projects?foo=bar"); - }); - - test("absolute action resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_ABSOLUTE_ACTION}`); - expect(el.attr("action")).toMatch("/about"); - }); - - test("'.' action resolves relative to the closest route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); - - test("'.' excludes search params", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg?foo=bar"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); - - test("'..' action resolves relative to the parent route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_PARENT_ACTION}`); - expect(el.attr("action")).toMatch("/projects"); - }); - - test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/projects/blarg"); - let html = await app.getHtml(); - let el = getElement(html, `#${SPLAT_ROUTE_TOO_MANY_DOTS_ACTION}`); - expect(el.attr("action")).toMatch("/"); - }); + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); }); }); + }); - let FORM_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; - let NATIVE_FORM_METHODS = ["GET", "POST"]; - - test.describe("uses the Form `method` attribute", () => { - FORM_METHODS.forEach((method) => { - test(`submits with ${method}`, async ({ - page, - javaScriptEnabled, - }) => { - test.fail( - !javaScriptEnabled && !NATIVE_FORM_METHODS.includes(method), - `Native doesn't support method ${method} #4420` - ); + let FORM_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + let NATIVE_FORM_METHODS = ["GET", "POST"]; - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/form-method?method=${method}`, true); - await app.clickElement(`text=Submit`); - if (method !== "GET") { - await page.waitForSelector("#action-method"); - expect(await app.getHtml("pre#action-method")).toBe( - `
${method}
` - ); - } - expect(await app.getHtml("pre#loader-method")).toBe( - `
GET
` + test.describe("uses the Form `method` attribute", () => { + FORM_METHODS.forEach((method) => { + test(`submits with ${method}`, async ({ page, javaScriptEnabled }) => { + test.fail( + !javaScriptEnabled && !NATIVE_FORM_METHODS.includes(method), + `Native doesn't support method ${method} #4420` + ); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/form-method?method=${method}`, true); + await app.clickElement(`text=Submit`); + if (method !== "GET") { + await page.waitForSelector("#action-method"); + expect(await app.getHtml("pre#action-method")).toBe( + `
${method}
` ); - }); + } + expect(await app.getHtml("pre#loader-method")).toBe( + `
GET
` + ); }); }); + }); - test.describe("overrides the Form `method` attribute with the submitter's `formMethod` attribute", () => { - // NOTE: HTMLButtonElement only supports get/post as formMethod, which is why we don't test put/patch/delete - NATIVE_FORM_METHODS.forEach((overrideMethod) => { - // ensure the form's method is different from the submitter's - let method = overrideMethod === "GET" ? "POST" : "GET"; - test(`submits with ${overrideMethod} instead of ${method}`, async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto( - `/form-method?method=${method}&submitterFormMethod=${overrideMethod}`, - true - ); - await app.clickElement(`text=Submit with ${overrideMethod}`); - if (overrideMethod !== "GET") { - await page.waitForSelector("#action-method"); - expect(await app.getHtml("pre#action-method")).toBe( - `
${overrideMethod}
` - ); - } - expect(await app.getHtml("pre#loader-method")).toBe( - `
GET
` + test.describe("overrides the Form `method` attribute with the submitter's `formMethod` attribute", () => { + // NOTE: HTMLButtonElement only supports get/post as formMethod, which is why we don't test put/patch/delete + NATIVE_FORM_METHODS.forEach((overrideMethod) => { + // ensure the form's method is different from the submitter's + let method = overrideMethod === "GET" ? "POST" : "GET"; + test(`submits with ${overrideMethod} instead of ${method}`, async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto( + `/form-method?method=${method}&submitterFormMethod=${overrideMethod}`, + true + ); + await app.clickElement(`text=Submit with ${overrideMethod}`); + if (overrideMethod !== "GET") { + await page.waitForSelector("#action-method"); + expect(await app.getHtml("pre#action-method")).toBe( + `
${overrideMethod}
` ); - }); + } + expect(await app.getHtml("pre#loader-method")).toBe( + `
GET
` + ); }); }); + }); - test("submits the submitter's value(s) in tree order in the form data", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); + test("submits the submitter's value(s) in tree order in the form data", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); - await app.goto("/submitter"); - await app.clickElement("text=Add Task"); - expect((await app.getElement("#formData")).val()).toBe( - "tasks=first&tasks=second&tasks=&tasks=last" - ); - - await app.goto("/submitter"); - await app.clickElement("text=No Name"); - expect((await app.getElement("#formData")).val()).toBe( - "tasks=first&tasks=second&tasks=last" - ); - - await app.goto("/submitter"); - await app.clickElement("[alt='Add Task']"); - expect((await app.getElement("#formData")).val()).toMatch( - /^tasks=first&tasks=second&tasks.x=\d+&tasks.y=\d+&tasks=last$/ - ); - - await app.goto("/submitter"); - await app.clickElement("[alt='No Name']"); - expect((await app.getElement("#formData")).val()).toMatch( - /^tasks=first&tasks=second&x=\d+&y=\d+&tasks=last$/ - ); - - await app.goto("/submitter"); - await app.clickElement("text=Outside"); - expect((await app.getElement("#formData")).val()).toBe( - "tasks=outside&tasks=first&tasks=second&tasks=last" - ); - }); + await app.goto("/submitter"); + await app.clickElement("text=Add Task"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=&tasks=last" + ); - test("sends file names when submitting via url encoding", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - let myFile = fixture.projectDir + "/myfile.txt"; - - await app.goto("/file-upload"); - await app.uploadFile(`[name=filey]`, myFile); - await app.uploadFile(`[name=filey2]`, myFile, myFile); - await app.clickElement("button"); - await page.waitForSelector("#formData"); - - expect((await app.getElement("#formData")).val()).toBe( - "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" - ); - - await app.goto("/file-upload?method=post"); - await app.uploadFile(`[name=filey]`, myFile); - await app.uploadFile(`[name=filey2]`, myFile, myFile); - await app.clickElement("button"); - await page.waitForSelector("#formData"); - - expect((await app.getElement("#formData")).val()).toBe( - "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" - ); - }); + await app.goto("/submitter"); + await app.clickElement("text=No Name"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=last" + ); - test("empty file inputs resolve to File objects on the server", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); + await app.goto("/submitter"); + await app.clickElement("[alt='Add Task']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&tasks.x=\d+&tasks.y=\d+&tasks=last$/ + ); - await app.goto("/empty-file-upload"); - await app.clickSubmitButton("/empty-file-upload"); - await page.waitForSelector("#action-data"); - expect((await app.getElement("#action-data")).text()).toContain( - '{"text":"","file":{"name":"","size":0},"fileMultiple":[{"name":"","size":0}]}' - ); - }); + await app.goto("/submitter"); + await app.clickElement("[alt='No Name']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&x=\d+&y=\d+&tasks=last$/ + ); - test("pathless layout routes are ignored in form actions", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/pathless-layout-parent/nested"); - let html = await app.getHtml(); - expect(html).toMatch("Pathless Layout Parent"); - expect(html).toMatch("Pathless Layout "); - expect(html).toMatch("Pathless Layout Index"); - - let el = getElement(html, `form`); - expect(el.attr("action")).toMatch("/pathless-layout-parent"); - - expect(await app.getHtml()).toMatch("Submitted - No"); - // This submission should ignore the index route and the pathless layout - // route above it and hit the action in routes/pathless-layout-parent.jsx - await app.clickSubmitButton("/pathless-layout-parent"); - await page.waitForSelector("text=Submitted - Yes"); - expect(await app.getHtml()).toMatch("Submitted - Yes"); - }); - } - }); + await app.goto("/submitter"); + await app.clickElement("text=Outside"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=outside&tasks=first&tasks=second&tasks=last" + ); + }); + + test("sends file names when submitting via url encoding", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let myFile = fixture.projectDir + "/myfile.txt"; + + await app.goto("/file-upload"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); + await app.clickElement("button"); + await page.waitForSelector("#formData"); + + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" + ); + + await app.goto("/file-upload?method=post"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); + await app.clickElement("button"); + await page.waitForSelector("#formData"); + + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" + ); + }); + + test("empty file inputs resolve to File objects on the server", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/empty-file-upload"); + await app.clickSubmitButton("/empty-file-upload"); + await page.waitForSelector("#action-data"); + expect((await app.getElement("#action-data")).text()).toContain( + '{"text":"","file":{"name":"","size":0},"fileMultiple":[{"name":"","size":0}]}' + ); + }); + + test("pathless layout routes are ignored in form actions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/pathless-layout-parent/nested"); + let html = await app.getHtml(); + expect(html).toMatch("Pathless Layout Parent"); + expect(html).toMatch("Pathless Layout "); + expect(html).toMatch("Pathless Layout Index"); + + let el = getElement(html, `form`); + expect(el.attr("action")).toMatch("/pathless-layout-parent"); + + expect(await app.getHtml()).toMatch("Submitted - No"); + // This submission should ignore the index route and the pathless layout + // route above it and hit the action in routes/pathless-layout-parent.jsx + await app.clickSubmitButton("/pathless-layout-parent"); + await page.waitForSelector("text=Submitted - Yes"); + expect(await app.getHtml()).toMatch("Submitted - Yes"); + }); + } }); diff --git a/integration/headers-test.ts b/integration/headers-test.ts index 260453c168..ec1af0a2fb 100644 --- a/integration/headers-test.ts +++ b/integration/headers-test.ts @@ -4,7 +4,7 @@ import { UNSAFE_ServerMode as ServerMode } from "@react-router/server-runtime"; import { createFixture, js } from "./helpers/create-fixture.js"; import type { Fixture } from "./helpers/create-fixture.js"; -test.describe("headers export", () => { +test.describe.skip("headers export", () => { let ROOT_HEADER_KEY = "X-Test"; let ROOT_HEADER_VALUE = "SUCCESS"; let ACTION_HKEY = "X-Test-Action"; diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 48f0715398..ecff605f95 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -75,9 +75,6 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { build: null, isSpaMode, requestDocument, - requestData: () => { - throw new Error("Cannot requestData in SPA Mode tests"); - }, requestResource: () => { throw new Error("Cannot requestResource in SPA Mode tests"); }, @@ -104,19 +101,6 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { return handler(request); }; - let requestData = async ( - href: string, - routeId: string, - init?: RequestInit - ) => { - init = init || {}; - init.signal = init.signal || new AbortController().signal; - let url = new URL(href, "test://test"); - url.searchParams.set("_data", routeId); - let request = new Request(url.toString(), init); - return handler(request); - }; - let requestResource = async (href: string, init?: RequestInit) => { init = init || {}; init.signal = init.signal || new AbortController().signal; @@ -158,7 +142,6 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { build: app, isSpaMode, requestDocument, - requestData, requestResource, requestSingleFetchData, postDocument, diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts index 8a797d115b..0e8dbf6094 100644 --- a/integration/helpers/playwright-fixture.ts +++ b/integration/helpers/playwright-fixture.ts @@ -159,15 +159,6 @@ export class PlaywrightFixture { } } - /** - * Collects data responses from the network, usually after a link click or - * form submission. This is useful for asserting that specific loaders - * were called (or not). - */ - collectDataResponses() { - return this.collectResponses((url) => url.searchParams.has("_data")); - } - /** * Collects single fetch data responses from the network, usually after a * link click or form submission. This is useful for asserting that specific diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index f8c6773b5d..86c274fe8c 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -41,9 +41,6 @@ export const viteConfig = { }) => { let pluginOptions: VitePluginConfig = { ssr: !args.spaMode, - future: { - unstable_singleFetch: args.singleFetch, - }, }; return dedent` diff --git a/integration/link-test.ts b/integration/link-test.ts index 16313eea32..934abb0dd2 100644 --- a/integration/link-test.ts +++ b/integration/link-test.ts @@ -598,7 +598,7 @@ test.describe("route module link export", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/"); let scripts = await page.$$("script"); - expect(scripts.length).toEqual(2); + expect(scripts.length).toEqual(6); expect(await scripts[0].innerText()).toContain("__remixContext"); let moduleScript = scripts[1]; expect(await moduleScript.getAttribute("type")).toBe("module"); diff --git a/integration/loader-test.ts b/integration/loader-test.ts index 2a16122ebe..3866f84874 100644 --- a/integration/loader-test.ts +++ b/integration/loader-test.ts @@ -8,7 +8,7 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe.skip("loader", () => { +test.describe("loader", () => { let fixture: Fixture; let ROOT_DATA = "ROOT_DATA"; @@ -16,60 +16,55 @@ test.describe.skip("loader", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` - import { json } from "@react-router/node"; - import { Links, Meta, Outlet, Scripts } from "react-router-dom"; + import { json } from "@react-router/node"; + import { Links, Meta, Outlet, Scripts } from "react-router-dom"; - export const loader = () => json("${ROOT_DATA}"); + export const loader = () => json("${ROOT_DATA}"); - export default function Root() { - return ( - - - - - - - - - - - ); - } - `, + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, "app/routes/_index.tsx": js` - import { json } from "@react-router/node"; + import { json } from "@react-router/node"; - export function loader() { - return "${INDEX_DATA}" - } + export function loader() { + return "${INDEX_DATA}" + } - export default function Index() { - return
- } - `, + export default function Index() { + return
+ } + `, }, }); }); - test("returns responses for a specific route", async () => { - let [root, index] = await Promise.all([ - fixture.requestData("/", "root"), - fixture.requestData("/", "routes/_index"), - ]); - - expect(root.headers.get("Content-Type")).toBe( - "application/json; charset=utf-8" - ); - - expect(await root.json()).toBe(ROOT_DATA); - expect(await index.json()).toBe(INDEX_DATA); + test("returns responses for single fetch routes", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { data: ROOT_DATA }, + "routes/_index": { data: INDEX_DATA }, + }); }); }); -test.describe.skip("loader in an app", () => { +test.describe("loader in an app", () => { let appFixture: AppFixture; let HOME_PAGE_TEXT = "hello world"; @@ -79,6 +74,7 @@ test.describe.skip("loader in an app", () => { test.beforeAll(async () => { appFixture = await createAppFixture( await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` import { Outlet } from 'react-router-dom' @@ -137,133 +133,3 @@ test.describe.skip("loader in an app", () => { expect((await res.json()).message).toBe(FETCH_TARGET_TEXT); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("loader", () => { - let fixture: Fixture; - - let ROOT_DATA = "ROOT_DATA"; - let INDEX_DATA = "INDEX_DATA"; - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { json } from "@react-router/node"; - import { Links, Meta, Outlet, Scripts } from "react-router-dom"; - - export const loader = () => json("${ROOT_DATA}"); - - export default function Root() { - return ( - - - - - - - - - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { json } from "@react-router/node"; - - export function loader() { - return "${INDEX_DATA}" - } - - export default function Index() { - return
- } - `, - }, - }); - }); - - test("returns responses for single fetch routes", async () => { - let { data } = await fixture.requestSingleFetchData("/_root.data"); - expect(data).toEqual({ - root: { data: ROOT_DATA }, - "routes/_index": { data: INDEX_DATA }, - }); - }); - }); - - test.describe("loader in an app", () => { - let appFixture: AppFixture; - - let HOME_PAGE_TEXT = "hello world"; - let REDIRECT_TARGET_TEXT = "redirect target"; - let FETCH_TARGET_TEXT = "fetch target"; - - test.beforeAll(async () => { - appFixture = await createAppFixture( - await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { Outlet } from 'react-router-dom' - - export default function Root() { - return ( - - - ${HOME_PAGE_TEXT} - - - - ); - } - `, - "app/routes/redirect.tsx": js` - import { redirect } from "@react-router/node"; - export const loader = () => redirect("/redirect-target"); - export default () =>
Yo
- `, - "app/routes/redirect-target.tsx": js` - export default () =>
${REDIRECT_TARGET_TEXT}
- `, - "app/routes/fetch.tsx": js` - export function loader({ request }) { - return fetch(new URL(request.url).origin + '/fetch-target'); - } - `, - - "app/routes/fetch-target.tsx": js` - import { json } from "@react-router/node"; - - export function loader() { - return json({ message: "${FETCH_TARGET_TEXT}" }) - } - `, - }, - }) - ); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("sends a redirect", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/redirect"); - expect(await app.getHtml()).toMatch(HOME_PAGE_TEXT); - expect(await app.getHtml()).toMatch(REDIRECT_TARGET_TEXT); - }); - - test("handles raw fetch responses", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - let res = await app.goto(`/fetch`); - expect((await res.json()).message).toBe(FETCH_TARGET_TEXT); - }); - }); -}); diff --git a/integration/multiple-cookies-test.ts b/integration/multiple-cookies-test.ts index 95601cca0d..bf22fa50e6 100644 --- a/integration/multiple-cookies-test.ts +++ b/integration/multiple-cookies-test.ts @@ -68,7 +68,9 @@ test.describe("pathless layout routes", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/"); // do this after the first request so that it doesnt appear in our next assertions - let responses = app.collectResponses((url) => url.pathname === "/"); + let responses = app.collectResponses( + (url) => url.pathname === "/_root.data" + ); await page.click("button[type=submit]"); await page.waitForSelector(`[data-testid="action-success"]`); let setCookies = await responses[0].headerValues("set-cookie"); diff --git a/integration/navigation-state-test.ts b/integration/navigation-state-test.ts index 9a79d59809..3cc96a2395 100644 --- a/integration/navigation-state-test.ts +++ b/integration/navigation-state-test.ts @@ -22,12 +22,12 @@ const IDLE_STATE = { state: "idle", }; -test.describe.skip("navigation states", () => { +test.describe("navigation states", () => { let fixture: Fixture; let appFixture: AppFixture; test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { + await context.route(/\.data$/, async (route) => { await new Promise((resolve) => setTimeout(resolve, 50)); route.continue(); }); @@ -35,168 +35,169 @@ test.describe.skip("navigation states", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` - import { useMemo, useRef } from "react"; - import { Outlet, Scripts, useNavigation } from "react-router-dom"; - export default function() { - const navigation = useNavigation(); - const navigationsRef = useRef(); - const navigations = useMemo(() => { - const savedNavigations = navigationsRef.current || []; - savedNavigations.push(navigation); - navigationsRef.current = savedNavigations; - return savedNavigations; - }, [navigation]); - return ( - - Test - - - {navigation.state != "idle" && ( -

Loading...

- )} -

- - {JSON.stringify(navigations, null, 2)} - -

- - - - ); - } - `, + import { useMemo, useRef } from "react"; + import { Outlet, Scripts, useNavigation } from "react-router-dom"; + export default function() { + const navigation = useNavigation(); + const navigationsRef = useRef(); + const navigations = useMemo(() => { + const savedNavigations = navigationsRef.current || []; + savedNavigations.push(navigation); + navigationsRef.current = savedNavigations; + return savedNavigations; + }, [navigation]); + return ( + + Test + + + {navigation.state != "idle" && ( +

Loading...

+ )} +

+ + {JSON.stringify(navigations, null, 2)} + +

+ + + + ); + } + `, "app/routes/_index.tsx": js` - import { Form, Link, useFetcher } from "react-router-dom"; - export function loader() { return null; } - export default function() { - const fetcher = useFetcher(); - return ( -
    -
  • - - ${STATES.NORMAL_LOAD} - -
  • -
  • - - ${STATES.LOADING_REDIRECT} - -
  • -
  • - - -
  • - -
  • -
    - -
    -
  • -
  • -
    - -
    -
  • -
  • -
    - -
    -
  • -
  • - - - -
  • -
- ); - } - `, + import { Form, Link, useFetcher } from "react-router-dom"; + export function loader() { return null; } + export default function() { + const fetcher = useFetcher(); + return ( +
    +
  • + + ${STATES.NORMAL_LOAD} + +
  • +
  • + + ${STATES.LOADING_REDIRECT} + +
  • +
  • +
    + +
    +
  • +
  • +
    + +
    +
  • +
  • +
    + +
    +
  • +
  • +
    + +
    +
  • +
  • + + + +
  • +
+ ); + } + `, [`app/routes/${STATES.NORMAL_LOAD}.jsx`]: js` - export default function() { - return ( -

- ${STATES.NORMAL_LOAD} -

- ); - } - `, + export default function() { + return ( +

+ ${STATES.NORMAL_LOAD} +

+ ); + } + `, [`app/routes/${STATES.LOADING_REDIRECT}.jsx`]: js` - import { redirect } from "@react-router/node"; - export function loader() { - return redirect("/?redirected"); - } - export default function() { - return ( -

- ${STATES.LOADING_REDIRECT} -

- ); - } - `, + import { redirect } from "@react-router/node"; + export function loader() { + return redirect("/?redirected"); + } + export default function() { + return ( +

+ ${STATES.LOADING_REDIRECT} +

+ ); + } + `, [`app/routes/${STATES.SUBMITTING_LOADER}.jsx`]: js` - export default function() { - return ( -

- ${STATES.SUBMITTING_LOADER} -

- ); - } - `, + export default function() { + return ( +

+ ${STATES.SUBMITTING_LOADER} +

+ ); + } + `, [`app/routes/${STATES.SUBMITTING_LOADER_REDIRECT}.jsx`]: js` - import { redirect } from "@react-router/node"; - export function loader() { - return redirect("/?redirected"); - } - export default function() { - return ( -

- ${STATES.SUBMITTING_LOADER_REDIRECT} -

- ); - } - `, + import { redirect } from "@react-router/node"; + export function loader() { + return redirect("/?redirected"); + } + export default function() { + return ( +

+ ${STATES.SUBMITTING_LOADER_REDIRECT} +

+ ); + } + `, [`app/routes/${STATES.SUBMITTING_ACTION}.jsx`]: js` - export function loader() { return null; } - export function action() { return null; } - export default function() { - return ( -

- ${STATES.SUBMITTING_ACTION} -

- ); - } - `, + export function loader() { return null; } + export function action() { return null; } + export default function() { + return ( +

+ ${STATES.SUBMITTING_ACTION} +

+ ); + } + `, [`app/routes/${STATES.SUBMITTING_ACTION_REDIRECT}.jsx`]: js` - import { redirect } from "@react-router/node"; - export function action() { - return redirect("/?redirected"); - } - export default function() { - return ( -

- ${STATES.SUBMITTING_ACTION_REDIRECT} -

- ); - } - `, + import { redirect } from "@react-router/node"; + export function action() { + return redirect("/?redirected"); + } + export default function() { + return ( +

+ ${STATES.SUBMITTING_ACTION_REDIRECT} +

+ ); + } + `, [`app/routes/${STATES.FETCHER_REDIRECT}.jsx`]: js` - import { redirect } from "@react-router/node"; - export function action() { - return redirect("/?redirected"); - } - `, + import { redirect } from "@react-router/node"; + export function action() { + return redirect("/?redirected"); + } + `, }, }); @@ -465,452 +466,3 @@ test.describe.skip("navigation states", () => { ]); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("navigation states", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); - }); - }); - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { useMemo, useRef } from "react"; - import { Outlet, Scripts, useNavigation } from "react-router-dom"; - export default function() { - const navigation = useNavigation(); - const navigationsRef = useRef(); - const navigations = useMemo(() => { - const savedNavigations = navigationsRef.current || []; - savedNavigations.push(navigation); - navigationsRef.current = savedNavigations; - return savedNavigations; - }, [navigation]); - return ( - - Test - - - {navigation.state != "idle" && ( -

Loading...

- )} -

- - {JSON.stringify(navigations, null, 2)} - -

- - - - ); - } - `, - "app/routes/_index.tsx": js` - import { Form, Link, useFetcher } from "react-router-dom"; - export function loader() { return null; } - export default function() { - const fetcher = useFetcher(); - return ( -
    -
  • - - ${STATES.NORMAL_LOAD} - -
  • -
  • - - ${STATES.LOADING_REDIRECT} - -
  • -
  • -
    - -
    -
  • -
  • -
    - -
    -
  • -
  • -
    - -
    -
  • -
  • -
    - -
    -
  • -
  • - - - -
  • -
- ); - } - `, - [`app/routes/${STATES.NORMAL_LOAD}.jsx`]: js` - export default function() { - return ( -

- ${STATES.NORMAL_LOAD} -

- ); - } - `, - [`app/routes/${STATES.LOADING_REDIRECT}.jsx`]: js` - import { redirect } from "@react-router/node"; - export function loader() { - return redirect("/?redirected"); - } - export default function() { - return ( -

- ${STATES.LOADING_REDIRECT} -

- ); - } - `, - [`app/routes/${STATES.SUBMITTING_LOADER}.jsx`]: js` - export default function() { - return ( -

- ${STATES.SUBMITTING_LOADER} -

- ); - } - `, - [`app/routes/${STATES.SUBMITTING_LOADER_REDIRECT}.jsx`]: js` - import { redirect } from "@react-router/node"; - export function loader() { - return redirect("/?redirected"); - } - export default function() { - return ( -

- ${STATES.SUBMITTING_LOADER_REDIRECT} -

- ); - } - `, - [`app/routes/${STATES.SUBMITTING_ACTION}.jsx`]: js` - export function loader() { return null; } - export function action() { return null; } - export default function() { - return ( -

- ${STATES.SUBMITTING_ACTION} -

- ); - } - `, - [`app/routes/${STATES.SUBMITTING_ACTION_REDIRECT}.jsx`]: js` - import { redirect } from "@react-router/node"; - export function action() { - return redirect("/?redirected"); - } - export default function() { - return ( -

- ${STATES.SUBMITTING_ACTION_REDIRECT} -

- ); - } - `, - [`app/routes/${STATES.FETCHER_REDIRECT}.jsx`]: js` - import { redirect } from "@react-router/node"; - export function action() { - return redirect("/?redirected"); - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("normal load (Loading)", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await app.clickLink(`/${STATES.NORMAL_LOAD}`); - await page.waitForSelector(`#${STATES.NORMAL_LOAD}`); - await page.waitForSelector("#loading-indicator", { state: "hidden" }); - - let navigationsCode = await app.getElement("#navigations"); - let navigationsJson = navigationsCode.text(); - let navigations = JSON.parse(navigationsJson); - expect(navigations).toEqual([ - IDLE_STATE, - { - state: "loading", - location: { - pathname: `/${STATES.NORMAL_LOAD}`, - search: "", - hash: "", - state: null, - key: expect.any(String), - }, - }, - IDLE_STATE, - ]); - }); - - test("normal redirect (LoadingRedirect)", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await app.clickLink(`/${STATES.LOADING_REDIRECT}`); - await page.waitForURL(/\?redirected/); - await page.waitForSelector("#loading-indicator", { state: "hidden" }); - let navigationsCode = await app.getElement("#navigations"); - let navigationsJson = navigationsCode.text(); - let navigations = JSON.parse(navigationsJson); - expect(navigations).toEqual([ - IDLE_STATE, - { - state: "loading", - location: { - pathname: `/${STATES.LOADING_REDIRECT}`, - search: "", - hash: "", - state: null, - key: expect.any(String), - }, - }, - { - state: "loading", - location: { - pathname: "/", - search: "?redirected", - hash: "", - state: { - _isRedirect: true, - }, - key: expect.any(String), - }, - }, - IDLE_STATE, - ]); - }); - - test("loader submission (SubmittingLoader)", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await app.clickSubmitButton(`/${STATES.SUBMITTING_LOADER}`); - await page.waitForSelector(`#${STATES.SUBMITTING_LOADER}`); - await page.waitForSelector("#loading-indicator", { state: "hidden" }); - let navigationsCode = await app.getElement("#navigations"); - let navigationsJson = navigationsCode.text(); - let navigations = JSON.parse(navigationsJson); - expect(navigations).toEqual([ - IDLE_STATE, - { - state: "loading", - location: { - pathname: `/${STATES.SUBMITTING_LOADER}`, - search: "?key=value", - hash: "", - state: null, - key: expect.any(String), - }, - formMethod: "GET", - formAction: `/${STATES.SUBMITTING_LOADER}`, - formEncType: "application/x-www-form-urlencoded", - formData: expect.any(Object), - }, - IDLE_STATE, - ]); - }); - - test("loader submission redirect (LoadingLoaderSubmissionRedirect)", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await app.clickSubmitButton(`/${STATES.SUBMITTING_LOADER_REDIRECT}`); - await page.waitForURL(/\?redirected/); - await page.waitForSelector("#loading-indicator", { state: "hidden" }); - let navigationsCode = await app.getElement("#navigations"); - let navigationsJson = navigationsCode.text(); - let navigations = JSON.parse(navigationsJson); - expect(navigations).toEqual([ - IDLE_STATE, - { - state: "loading", - location: { - pathname: `/${STATES.SUBMITTING_LOADER_REDIRECT}`, - search: "", - hash: "", - state: null, - key: expect.any(String), - }, - formMethod: "GET", - formAction: `/${STATES.SUBMITTING_LOADER_REDIRECT}`, - formEncType: "application/x-www-form-urlencoded", - formData: expect.any(Object), - }, - { - state: "loading", - location: { - pathname: "/", - search: "?redirected", - hash: "", - state: { - _isRedirect: true, - }, - key: expect.any(String), - }, - formMethod: "GET", - formAction: `/${STATES.SUBMITTING_LOADER_REDIRECT}`, - formEncType: "application/x-www-form-urlencoded", - formData: expect.any(Object), - }, - IDLE_STATE, - ]); - }); - - test("action submission (SubmittingAction)", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await app.clickSubmitButton(`/${STATES.SUBMITTING_ACTION}`); - await page.waitForSelector(`#${STATES.SUBMITTING_ACTION}`); - await page.waitForSelector("#loading-indicator", { state: "hidden" }); - let navigationsCode = await app.getElement("#navigations"); - let navigationsJson = navigationsCode.text(); - let navigations = JSON.parse(navigationsJson); - expect(navigations).toEqual([ - IDLE_STATE, - { - state: "submitting", - location: { - pathname: `/${STATES.SUBMITTING_ACTION}`, - search: "", - hash: "", - state: null, - key: expect.any(String), - }, - formMethod: "POST", - formAction: `/${STATES.SUBMITTING_ACTION}`, - formEncType: "application/x-www-form-urlencoded", - formData: expect.any(Object), - }, - { - state: "loading", - location: { - pathname: `/${STATES.SUBMITTING_ACTION}`, - search: "", - hash: "", - state: null, - key: expect.any(String), - }, - formMethod: "POST", - formAction: `/${STATES.SUBMITTING_ACTION}`, - formEncType: "application/x-www-form-urlencoded", - formData: expect.any(Object), - }, - IDLE_STATE, - ]); - }); - - test("action submission redirect (LoadingActionRedirect)", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await app.clickSubmitButton(`/${STATES.SUBMITTING_ACTION_REDIRECT}`); - await page.waitForURL(/\?redirected/); - await page.waitForSelector("#loading-indicator", { state: "hidden" }); - let navigationsCode = await app.getElement("#navigations"); - let navigationsJson = navigationsCode.text(); - let navigations = JSON.parse(navigationsJson); - expect(navigations).toEqual([ - IDLE_STATE, - { - state: "submitting", - location: { - pathname: `/${STATES.SUBMITTING_ACTION_REDIRECT}`, - search: "", - hash: "", - state: null, - key: expect.any(String), - }, - formMethod: "POST", - formAction: `/${STATES.SUBMITTING_ACTION_REDIRECT}`, - formEncType: "application/x-www-form-urlencoded", - formData: expect.any(Object), - }, - { - state: "loading", - location: { - pathname: "/", - search: "?redirected", - hash: "", - state: { - _isRedirect: true, - }, - key: expect.any(String), - }, - formMethod: "POST", - formAction: `/${STATES.SUBMITTING_ACTION_REDIRECT}`, - formEncType: "application/x-www-form-urlencoded", - formData: expect.any(Object), - }, - IDLE_STATE, - ]); - }); - - test("fetcher action submission redirect (LoadingFetchActionRedirect)", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - await app.clickSubmitButton(`/${STATES.FETCHER_REDIRECT}`); - await page.waitForURL(/\?redirected/); - await page.waitForSelector("#loading-indicator", { state: "hidden" }); - let navigationsCode = await app.getElement("#navigations"); - let navigationsJson = navigationsCode.text(); - let navigations = JSON.parse(navigationsJson); - expect(navigations).toEqual([ - IDLE_STATE, - { - state: "loading", - location: { - pathname: "/", - search: "?redirected", - hash: "", - state: { - _isRedirect: true, - }, - key: expect.any(String), - }, - }, - IDLE_STATE, - ]); - }); - }); -}); diff --git a/integration/redirects-test.ts b/integration/redirects-test.ts index 6af6ea4e53..74fd9d7486 100644 --- a/integration/redirects-test.ts +++ b/integration/redirects-test.ts @@ -8,155 +8,15 @@ import { import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe.skip("redirects", () => { +test.describe("redirects", () => { let fixture: Fixture; let appFixture: AppFixture; test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/routes/absolute.tsx": js` - import * as React from 'react'; - import { Outlet } from "react-router-dom"; - - export default function Component() { - let [count, setCount] = React.useState(0); - return ( - <> - - - - ); - } - `, - - "app/routes/absolute._index.tsx": js` - import { redirect } from "@react-router/node"; - import { Form } from "react-router-dom"; - - export async function action({ request }) { - return redirect(new URL(request.url).origin + "/absolute/landing"); - }; - - export default function Component() { - return ( -
- -
- ); - } - `, - - "app/routes/absolute.landing.tsx": js` - export default function Component() { - return

Landing

- } - `, - - "app/routes/loader.external.ts": js` - import { redirect } from "@react-router/node"; - export const loader = () => { - return redirect("https://remix.run/"); - } - `, - - "app/routes/redirect-document.tsx": js` - import * as React from "react"; - import { Outlet } from "react-router-dom"; - - export default function Component() { - let [count, setCount] = React.useState(0); - let countText = 'Count:' + count; - return ( - <> - - - - ); - } - `, - - "app/routes/redirect-document._index.tsx": js` - import { Link } from "react-router-dom"; - - export default function Component() { - return Link - } - `, - - "app/routes/redirect-document.a.tsx": js` - import { redirectDocument } from "@react-router/node"; - export const loader = () => redirectDocument("/redirect-document/b"); - `, - - "app/routes/redirect-document.b.tsx": js` - export default function Component() { - return

Hello B!

- } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("redirects to external URLs", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - - await app.waitForNetworkAfter(() => app.goto("/loader/external")); - expect(app.page.url()).toBe("https://remix.run/"); - }); - - test("redirects to absolute URLs in the app with a SPA navigation", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/absolute`, true); - await app.clickElement("#increment"); - expect(await app.getHtml("#increment")).toMatch("Count:1"); - await app.waitForNetworkAfter(() => - app.clickSubmitButton("/absolute?index") - ); - await page.waitForSelector(`h1:has-text("Landing")`); - // No hard reload - expect(await app.getHtml("#increment")).toMatch("Count:1"); - }); - - test("supports hard redirects within the app via reloadDocument", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/redirect-document", true); - expect(await app.getHtml("button")).toMatch("Count:0"); - await app.clickElement("button"); - expect(await app.getHtml("button")).toMatch("Count:1"); - await app.waitForNetworkAfter(() => app.clickLink("/redirect-document/a")); - await page.waitForSelector('h1:has-text("Hello B!")'); - // Hard reload resets client side react state - expect(await app.getHtml("button")).toMatch("Count:0"); - }); -}); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("redirects", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/routes/absolute.tsx": js` import * as React from 'react'; import { Outlet } from "react-router-dom"; @@ -175,7 +35,7 @@ test.describe("single fetch", () => { } `, - "app/routes/absolute._index.tsx": js` + "app/routes/absolute._index.tsx": js` import { redirect } from "@react-router/node"; import { Form } from "react-router-dom"; @@ -192,20 +52,20 @@ test.describe("single fetch", () => { } `, - "app/routes/absolute.landing.tsx": js` + "app/routes/absolute.landing.tsx": js` export default function Component() { return

Landing

} `, - "app/routes/loader.external.ts": js` + "app/routes/loader.external.ts": js` import { redirect } from "@react-router/node"; export const loader = () => { return redirect("https://remix.run/"); } `, - "app/routes/redirect-document.tsx": js` + "app/routes/redirect-document.tsx": js` import * as React from "react"; import { Outlet } from "react-router-dom"; @@ -221,7 +81,7 @@ test.describe("single fetch", () => { } `, - "app/routes/redirect-document._index.tsx": js` + "app/routes/redirect-document._index.tsx": js` import { Link } from "react-router-dom"; export default function Component() { @@ -229,62 +89,59 @@ test.describe("single fetch", () => { } `, - "app/routes/redirect-document.a.tsx": js` + "app/routes/redirect-document.a.tsx": js` import { redirectDocument } from "@react-router/node"; export const loader = () => redirectDocument("/redirect-document/b"); `, - "app/routes/redirect-document.b.tsx": js` + "app/routes/redirect-document.b.tsx": js` export default function Component() { return

Hello B!

} `, - }, - }); - - appFixture = await createAppFixture(fixture); + }, }); - test.afterAll(() => { - appFixture.close(); - }); + appFixture = await createAppFixture(fixture); + }); - test("redirects to external URLs", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); + test.afterAll(() => { + appFixture.close(); + }); - await app.waitForNetworkAfter(() => app.goto("/loader/external")); - expect(app.page.url()).toBe("https://remix.run/"); - }); + test("redirects to external URLs", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); - test("redirects to absolute URLs in the app with a SPA navigation", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/absolute`, true); - await app.clickElement("#increment"); - expect(await app.getHtml("#increment")).toMatch("Count:1"); - await app.waitForNetworkAfter(() => - app.clickSubmitButton("/absolute?index") - ); - await page.waitForSelector(`h1:has-text("Landing")`); - // No hard reload - expect(await app.getHtml("#increment")).toMatch("Count:1"); - }); + await app.waitForNetworkAfter(() => app.goto("/loader/external")); + expect(app.page.url()).toBe("https://remix.run/"); + }); - test("supports hard redirects within the app via reloadDocument", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/redirect-document", true); - expect(await app.getHtml("button")).toMatch("Count:0"); - await app.clickElement("button"); - expect(await app.getHtml("button")).toMatch("Count:1"); - await app.waitForNetworkAfter(() => - app.clickLink("/redirect-document/a") - ); - await page.waitForSelector('h1:has-text("Hello B!")'); - // Hard reload resets client side react state - expect(await app.getHtml("button")).toMatch("Count:0"); - }); + test("redirects to absolute URLs in the app with a SPA navigation", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/absolute`, true); + await app.clickElement("#increment"); + expect(await app.getHtml("#increment")).toMatch("Count:1"); + await app.waitForNetworkAfter(() => + app.clickSubmitButton("/absolute?index") + ); + await page.waitForSelector(`h1:has-text("Landing")`); + // No hard reload + expect(await app.getHtml("#increment")).toMatch("Count:1"); + }); + + test("supports hard redirects within the app via reloadDocument", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect-document", true); + expect(await app.getHtml("button")).toMatch("Count:0"); + await app.clickElement("button"); + expect(await app.getHtml("button")).toMatch("Count:1"); + await app.waitForNetworkAfter(() => app.clickLink("/redirect-document/a")); + await page.waitForSelector('h1:has-text("Hello B!")'); + // Hard reload resets client side react state + expect(await app.getHtml("button")).toMatch("Count:0"); }); }); diff --git a/integration/remix-serve-test.ts b/integration/remix-serve-test.ts index 3dddce531a..af5394dfc5 100644 --- a/integration/remix-serve-test.ts +++ b/integration/remix-serve-test.ts @@ -12,7 +12,7 @@ let fixture: Fixture; let appFixture: AppFixture; test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { + await context.route(/\.data$/, async (route) => { await new Promise((resolve) => setTimeout(resolve, 50)); route.continue(); }); diff --git a/integration/resource-routes-test.ts b/integration/resource-routes-test.ts index 41e2f1ad51..cd64b9b131 100644 --- a/integration/resource-routes-test.ts +++ b/integration/resource-routes-test.ts @@ -208,7 +208,9 @@ test.describe("loader in an app", async () => { expect(await res.text()).toEqual("Partial"); }); - test("should handle objects returned from resource routes", async ({ + // TODO: This test should work once we bring over the changes from + // https://github.com/remix-run/remix/pull/9349 to the v7 branch + test.skip("should handle objects returned from resource routes", async ({ page, }) => { let app = new PlaywrightFixture(appFixture, page); diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts index e8a35e1ced..9a6da99463 100644 --- a/integration/revalidate-test.ts +++ b/integration/revalidate-test.ts @@ -8,150 +8,151 @@ import { import type { AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe.skip("Revalidation", () => { +test.describe("Revalidation", () => { let appFixture: AppFixture; test.beforeAll(async () => { appFixture = await createAppFixture( await createFixture({ + singleFetch: true, files: { "app/root.tsx": js` - import { Link, Outlet, Scripts, useNavigation } from "react-router-dom"; - - export default function Component() { - let navigation = useNavigation(); - return ( - - - - - - - - - ); - } - `, + import { Link, Outlet, Scripts, useNavigation } from "react-router-dom"; + + export default function Component() { + let navigation = useNavigation(); + return ( + + + + + + + + + ); + } + `, "app/routes/parent.tsx": js` - import { json } from "@react-router/node"; - import { Outlet, useLoaderData } from "react-router-dom"; - - export async function loader({ request }) { - let header = request.headers.get('Cookie') || ''; - let cookie = header - .split(';') - .map(c => c.trim()) - .find(c => c.startsWith('parent=')) - let strValue = (cookie || 'parent=0').split("=")[1]; - let value = parseInt(strValue, 10) + 1; - return json({ value }, { - headers: { - "Set-Cookie": "parent=" + value, - } - }) - }; + import { json } from "@react-router/node"; + import { Outlet, useLoaderData } from "react-router-dom"; - export function shouldRevalidate({ nextUrl, formData }) { - if (nextUrl.searchParams.get('revalidate')?.split(',')?.includes('parent')) { - return true; + export async function loader({ request }) { + let header = request.headers.get('Cookie') || ''; + let cookie = header + .split(';') + .map(c => c.trim()) + .find(c => c.startsWith('parent=')) + let strValue = (cookie || 'parent=0').split("=")[1]; + let value = parseInt(strValue, 10) + 1; + return json({ value }, { + headers: { + "Set-Cookie": "parent=" + value, + } + }) + }; + + export function shouldRevalidate({ nextUrl, formData }) { + if (nextUrl.searchParams.get('revalidate')?.split(',')?.includes('parent')) { + return true; + } + if (formData?.getAll('revalidate')?.includes('parent')) { + return true; + } + return false } - if (formData?.getAll('revalidate')?.includes('parent')) { - return true; + + export default function Component() { + let data = useLoaderData(); + return ( + <> +

{'Value:' + data.value}

+ + + ); } - return false - } - - export default function Component() { - let data = useLoaderData(); - return ( - <> -

{'Value:' + data.value}

- - - ); - } - `, + `, "app/routes/parent.child.tsx": js` - import { json } from "@react-router/node"; - import { Form, useLoaderData, useRevalidator } from "react-router-dom"; - - export async function action() { - return json({ action: 'data' }) - } - - export async function loader({ request }) { - let header = request.headers.get('Cookie') || ''; - let cookie = header - .split(';') - .map(c => c.trim()) - .find(c => c.startsWith('child=')) - let strValue = (cookie || 'child=0').split("=")[1]; - let value = parseInt(strValue, 10) + 1; - return json({ value }, { - headers: { - "Set-Cookie": "child=" + value, - } - }) - }; + import { json } from "@react-router/node"; + import { Form, useLoaderData, useRevalidator } from "react-router-dom"; - export function shouldRevalidate({ nextUrl, formData }) { - let revalidate = (nextUrl.searchParams.get('revalidate') || '').split(',') - if (revalidate.includes('child')) { - return true; + export async function action() { + return json({ action: 'data' }) } - if (formData?.getAll('revalidate')?.includes('child')) { - return true; + + export async function loader({ request }) { + let header = request.headers.get('Cookie') || ''; + let cookie = header + .split(';') + .map(c => c.trim()) + .find(c => c.startsWith('child=')) + let strValue = (cookie || 'child=0').split("=")[1]; + let value = parseInt(strValue, 10) + 1; + return json({ value }, { + headers: { + "Set-Cookie": "child=" + value, + } + }) + }; + + export function shouldRevalidate({ nextUrl, formData }) { + let revalidate = (nextUrl.searchParams.get('revalidate') || '').split(',') + if (revalidate.includes('child')) { + return true; + } + if (formData?.getAll('revalidate')?.includes('child')) { + return true; + } + return false + } + + export default function Component() { + let data = useLoaderData(); + let revalidator = useRevalidator(); + return ( + <> +

{'Value:' + data.value}

+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ {revalidator.state === 'idle' ? +

Revalidation idle

: +

Revalidation busy

} + + + ); } - return false - } - - export default function Component() { - let data = useLoaderData(); - let revalidator = useRevalidator(); - return ( - <> -

{'Value:' + data.value}

-
- - -
-
- - -
-
- - -
-
- - - -
- {revalidator.state === 'idle' ? -

Revalidation idle

: -

Revalidation busy

} - - - ); - } - `, + `, }, }) ); @@ -293,294 +294,3 @@ test.describe.skip("Revalidation", () => { expect(await app.getHtml("#child-data")).toMatch("Value:5"); }); }); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("Revalidation", () => { - let appFixture: AppFixture; - - test.beforeAll(async () => { - appFixture = await createAppFixture( - await createFixture({ - singleFetch: true, - files: { - "app/root.tsx": js` - import { Link, Outlet, Scripts, useNavigation } from "react-router-dom"; - - export default function Component() { - let navigation = useNavigation(); - return ( - - - - - - - - - ); - } - `, - - "app/routes/parent.tsx": js` - import { json } from "@react-router/node"; - import { Outlet, useLoaderData } from "react-router-dom"; - - export async function loader({ request }) { - let header = request.headers.get('Cookie') || ''; - let cookie = header - .split(';') - .map(c => c.trim()) - .find(c => c.startsWith('parent=')) - let strValue = (cookie || 'parent=0').split("=")[1]; - let value = parseInt(strValue, 10) + 1; - return json({ value }, { - headers: { - "Set-Cookie": "parent=" + value, - } - }) - }; - - export function shouldRevalidate({ nextUrl, formData }) { - if (nextUrl.searchParams.get('revalidate')?.split(',')?.includes('parent')) { - return true; - } - if (formData?.getAll('revalidate')?.includes('parent')) { - return true; - } - return false - } - - export default function Component() { - let data = useLoaderData(); - return ( - <> -

{'Value:' + data.value}

- - - ); - } - `, - - "app/routes/parent.child.tsx": js` - import { json } from "@react-router/node"; - import { Form, useLoaderData, useRevalidator } from "react-router-dom"; - - export async function action() { - return json({ action: 'data' }) - } - - export async function loader({ request }) { - let header = request.headers.get('Cookie') || ''; - let cookie = header - .split(';') - .map(c => c.trim()) - .find(c => c.startsWith('child=')) - let strValue = (cookie || 'child=0').split("=")[1]; - let value = parseInt(strValue, 10) + 1; - return json({ value }, { - headers: { - "Set-Cookie": "child=" + value, - } - }) - }; - - export function shouldRevalidate({ nextUrl, formData }) { - let revalidate = (nextUrl.searchParams.get('revalidate') || '').split(',') - if (revalidate.includes('child')) { - return true; - } - if (formData?.getAll('revalidate')?.includes('child')) { - return true; - } - return false - } - - export default function Component() { - let data = useLoaderData(); - let revalidator = useRevalidator(); - return ( - <> -

{'Value:' + data.value}

-
- - -
-
- - -
-
- - -
-
- - - -
- {revalidator.state === 'idle' ? -

Revalidation idle

: -

Revalidation busy

} - - - ); - } - `, - }, - }) - ); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("Revalidates according to shouldRevalidate (loading navigations)", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - - // Should call parent (first load) - await app.clickLink("/parent"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - - // Should call child (first load) but not parent (no param) - await app.clickLink("/parent/child"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); - - // Should call neither - await app.clickLink("/parent/child"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); - - // Should call both - await app.clickLink("/parent/child?revalidate=parent,child"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:2"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); - - // Should call parent only - await app.clickLink("/parent/child?revalidate=parent"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); - - // Should call child only - await app.clickLink("/parent/child?revalidate=child"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); - }); - - test("Revalidates according to shouldRevalidate (submission navigations)", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - - // Should call both (first load) - await app.clickLink("/parent/child"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); - - // Should call neither - await app.clickElement("#submit-neither"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); - - // Should call both - await app.clickElement("#submit-both"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:2"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); - - // Should call parent only - await app.clickElement("#submit-parent"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); - - // Should call child only - await app.clickElement("#submit-child"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); - }); - - test("Revalidates on demand with useRevalidator", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - - // Should call both (first load) - await app.clickLink("/parent/child"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); - - // Should call neither on manual revalidate (no params) - await app.clickElement("#revalidate"); - await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); - - // Should call both - await app.clickLink("/parent/child?revalidate=parent,child"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:2"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); - - // Should call both on manual revalidate - await app.clickElement("#revalidate"); - await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); - - // Should call parent only - await app.clickLink("/parent/child?revalidate=parent"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:4"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); - - // Should call parent only on manual revalidate - await app.clickElement("#revalidate"); - await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:5"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); - - // Should call child only - await app.clickLink("/parent/child?revalidate=child"); - await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:5"); - expect(await app.getHtml("#child-data")).toMatch("Value:4"); - - // Should call child only on manual revalidate - await app.clickElement("#revalidate"); - await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:5"); - expect(await app.getHtml("#child-data")).toMatch("Value:5"); - }); - }); -}); diff --git a/integration/server-entry-test.ts b/integration/server-entry-test.ts index 5e59330652..54788a38c2 100644 --- a/integration/server-entry-test.ts +++ b/integration/server-entry-test.ts @@ -37,7 +37,7 @@ test.describe("Custom Server Entry", () => { }); test("can manipulate a data response", async () => { - let response = await fixture.requestData("/", "routes/_index"); + let response = await fixture.requestSingleFetchData("/.data"); expect(response.headers.get(DATA_HEADER_NAME)).toBe(DATA_HEADER_VALUE); }); }); diff --git a/integration/set-cookie-revalidation-test.ts b/integration/set-cookie-revalidation-test.ts index a9bbd01bc5..bffce7f2a2 100644 --- a/integration/set-cookie-revalidation-test.ts +++ b/integration/set-cookie-revalidation-test.ts @@ -13,137 +13,12 @@ let appFixture: AppFixture; let BANNER_MESSAGE = "you do not have permission to view /protected"; -test.describe.skip("set-cookie revalidation", () => { +test.describe("set-cookie revalidation", () => { test.beforeAll(async () => { fixture = await createFixture({ + singleFetch: true, files: { "app/session.server.ts": js` - import { createCookieSessionStorage } from "@react-router/node"; - - export let MESSAGE_KEY = "message"; - - export let sessionStorage = createCookieSessionStorage({ - cookie: { - httpOnly: true, - path: "/", - sameSite: "lax", - secrets: ["cookie-secret"], - } - }) - `, - - "app/root.tsx": js` - import { json } from "@react-router/node"; - import { - Links, - Meta, - Outlet, - Scripts, - useLoaderData, - } from "react-router-dom"; - - import { sessionStorage, MESSAGE_KEY } from "~/session.server"; - - export const loader = async ({ request }) => { - let session = await sessionStorage.getSession(request.headers.get("Cookie")); - let message = session.get(MESSAGE_KEY) || null; - - return json(message, { - headers: { - "Set-Cookie": await sessionStorage.commitSession(session), - }, - }); - }; - - export default function Root() { - const message = useLoaderData(); - - return ( - - - - - - - {!!message &&

{message}

} - - - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { Link } from "react-router-dom"; - - export default function Index() { - return ( -

- protected -

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

login

; - } - `, - - "app/routes/protected.tsx": js` - import { redirect } from "@react-router/node"; - - import { sessionStorage, MESSAGE_KEY } from "~/session.server"; - - export let loader = async ({ request }) => { - let session = await sessionStorage.getSession(request.headers.get("Cookie")); - - session.flash(MESSAGE_KEY, "${BANNER_MESSAGE}"); - - return redirect("/login", { - headers: { - "Set-Cookie": await sessionStorage.commitSession(session), - }, - }); - }; - - export default function Protected() { - return

protected

; - } - `, - }, - }); - - // This creates an interactive app using playwright. - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("should revalidate when cookie is set on redirect from loader", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/protected"); - await page.waitForSelector(`#message:has-text("${BANNER_MESSAGE}")`); - expect(await app.getHtml()).toMatch(BANNER_MESSAGE); - }); -}); - -// Duplicate suite of the tests above running with single fetch enabled -// TODO(v3): remove the above suite of tests and just keep these -test.describe("single fetch", () => { - test.describe("set-cookie revalidation", () => { - test.beforeAll(async () => { - fixture = await createFixture({ - singleFetch: true, - files: { - "app/session.server.ts": js` import { createCookieSessionStorage } from "@react-router/node"; export let MESSAGE_KEY = "message"; @@ -158,7 +33,7 @@ test.describe("single fetch", () => { }) `, - "app/root.tsx": js` + "app/root.tsx": js` import { json } from "@react-router/node"; import { Links, @@ -200,7 +75,7 @@ test.describe("single fetch", () => { } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { Link } from "react-router-dom"; export default function Index() { @@ -212,13 +87,13 @@ test.describe("single fetch", () => { } `, - "app/routes/login.tsx": js` + "app/routes/login.tsx": js` export default function Login() { return

login

; } `, - "app/routes/protected.tsx": js` + "app/routes/protected.tsx": js` import { redirect } from "@react-router/node"; import { sessionStorage, MESSAGE_KEY } from "~/session.server"; @@ -239,25 +114,24 @@ test.describe("single fetch", () => { return

protected

; } `, - }, - }); - - // This creates an interactive app using playwright. - appFixture = await createAppFixture(fixture); + }, }); - test.afterAll(() => { - appFixture.close(); - }); + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + }); - test("should revalidate when cookie is set on redirect from loader", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/protected"); - await page.waitForSelector(`#message:has-text("${BANNER_MESSAGE}")`); - expect(await app.getHtml()).toMatch(BANNER_MESSAGE); - }); + test.afterAll(() => { + appFixture.close(); + }); + + test("should revalidate when cookie is set on redirect from loader", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/protected"); + await page.waitForSelector(`#message:has-text("${BANNER_MESSAGE}")`); + expect(await app.getHtml()).toMatch(BANNER_MESSAGE); }); }); diff --git a/integration/transition-test.ts b/integration/transition-test.ts index 72c2053580..020a48729f 100644 --- a/integration/transition-test.ts +++ b/integration/transition-test.ts @@ -1,5 +1,7 @@ import { test, expect } from "@playwright/test"; +import { UNSAFE_decodeViaTurboStream as decodeViaTurboStream } from "react-router"; + import { createAppFixture, createFixture, @@ -27,6 +29,10 @@ test.describe("rendering", () => { "app/root.tsx": js` import { Links, Meta, Outlet, Scripts } from "react-router-dom"; + export function shouldRevalidate() { + return false; + } + export default function Root() { return ( @@ -66,6 +72,10 @@ test.describe("rendering", () => { return "${PAGE_TEXT}" } + export function shouldRevalidate() { + return false; + } + export default function() { let text = useLoaderData(); return ( @@ -215,15 +225,14 @@ test.describe("rendering", () => { test("calls all loaders for new routes", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/"); - let responses = app.collectDataResponses(); + let responses = app.collectSingleFetchResponses(); + await app.clickLink(`/${PAGE}`); await page.waitForLoadState("networkidle"); - expect( - responses - .map((res) => new URL(res.url()).searchParams.get("_data")) - .sort() - ).toEqual([`routes/${PAGE}`, `routes/${PAGE}._index`].sort()); + expect(responses.map((res) => new URL(res.url()).pathname)).toEqual([ + `/${PAGE}.data`, + ]); await page.waitForSelector(`h2:has-text("${PAGE_TEXT}")`); await page.waitForSelector(`h3:has-text("${PAGE_INDEX_TEXT}")`); @@ -232,13 +241,25 @@ test.describe("rendering", () => { test("calls only loaders for changing routes", async ({ page }) => { let app = new PlaywrightFixture(appFixture, page); await app.goto(`/${PAGE}`); - let responses = app.collectDataResponses(); + let responses = app.collectSingleFetchResponses(); await app.clickLink(`/${PAGE}/${CHILD}`); await page.waitForLoadState("networkidle"); - expect( - responses.map((res) => new URL(res.url()).searchParams.get("_data")) - ).toEqual([`routes/${PAGE}.${CHILD}`]); + expect(responses.map((res) => new URL(res.url()).pathname)).toEqual([ + `/${PAGE}/${CHILD}.data`, + ]); + + let body = new ReadableStream({ + async start(controller) { + let buffer = await responses[0].body(); + controller.enqueue(new Uint8Array(buffer)); + }, + }); + const decoded = await decodeViaTurboStream(body, global); + + expect(Object.keys(decoded.value as Record)).toEqual([ + "routes/page.child", + ]); await page.waitForSelector(`h2:has-text("${PAGE_TEXT}")`); await page.waitForSelector(`h3:has-text("${CHILD_TEXT}")`); @@ -248,19 +269,16 @@ test.describe("rendering", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/"); - let responses = app.collectDataResponses(); + let responses = app.collectSingleFetchResponses(); await app.clickLink(`/${REDIRECT}`); await page.waitForURL(/\/page/); await page.waitForLoadState("networkidle"); - expect( - responses - .map((res) => new URL(res.url()).searchParams.get("_data")) - .sort() - ).toEqual( - [`routes/${REDIRECT}`, `routes/${PAGE}`, `routes/${PAGE}._index`].sort() - ); + expect(responses.map((res) => new URL(res.url()).pathname)).toEqual([ + `/${REDIRECT}.data`, + `/${PAGE}.data`, + ]); await page.waitForSelector(`h2:has-text("${PAGE_TEXT}")`); await page.waitForSelector(`h3:has-text("${PAGE_INDEX_TEXT}")`); @@ -283,13 +301,16 @@ test.describe("rendering", () => { await app.goto(`/${PAGE}`); await app.clickLink(`/${PAGE}/${CHILD}`); - let responses = app.collectDataResponses(); + let responses = app.collectSingleFetchResponses(); await app.goBack(); await page.waitForLoadState("networkidle"); - expect( - responses.map((res) => new URL(res.url()).searchParams.get("_data")) - ).toEqual([`routes/${PAGE}._index`]); + expect(responses.map((res) => new URL(res.url()).pathname)).toEqual([ + `/${PAGE}.data`, + ]); + // expect( + // responses.map((res) => new URL(res.url()).searchParams.get("_data")) + // ).toEqual([`routes/${PAGE}._index`]); await page.waitForSelector(`h2:has-text("${PAGE_TEXT}")`); await page.waitForSelector(`h3:has-text("${PAGE_INDEX_TEXT}")`); diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index 1e4444b483..7cf2d72bd6 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -218,7 +218,7 @@ test.describe("SPA Mode", () => { }, }); let res = await fixture.requestDocument("/"); - expect(await res.text()).toMatch(/^\n/); + expect(await res.text()).toMatch(/^/); }); test("works when combined with a basename", async ({ page }) => { @@ -337,39 +337,93 @@ test.describe("SPA Mode", () => { }); `, "app/entry.server.tsx": js` - import fs from "node:fs"; - import path from "node:path"; - - import type { EntryContext } from "@react-router/node"; - import { RemixServer } from "react-router-dom"; - import { renderToString } from "react-dom/server"; - + import * as fs from "node:fs"; + import * as path from "node:path"; + import { PassThrough } from "node:stream"; + + import type { AppLoadContext, EntryContext } from "@react-router/node"; + import { createReadableStreamFromReadable } from "@react-router/node"; + import { RemixServer } from "react-router"; + import { renderToPipeableStream } from "react-dom/server"; + + const ABORT_DELAY = 5_000; + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext + ) { + return handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); + } + + async function handleBotRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext ) { + const html = await new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }).text() + ); + + 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, ABORT_DELAY); + }); + const shellHtml = fs .readFileSync( path.join(process.cwd(), "app/index.html") ) .toString(); - const appHtml = renderToString( - - ); - - const html = shellHtml.replace( - "", - appHtml - ); - - return new Response(html, { - headers: { "Content-Type": "text/html" }, + const finalHTML = shellHtml.replace("", html); + return new Response(finalHTML, { + headers: responseHeaders, status: responseStatusCode, }); - } + } `, "app/root.tsx": js` import { Outlet, Scripts } from "react-router-dom"; diff --git a/packages/react-router/__tests__/dom/scroll-restoration-test.tsx b/packages/react-router/__tests__/dom/scroll-restoration-test.tsx index 796340f156..8be85e0287 100644 --- a/packages/react-router/__tests__/dom/scroll-restoration-test.tsx +++ b/packages/react-router/__tests__/dom/scroll-restoration-test.tsx @@ -211,7 +211,6 @@ describe(`ScrollRestoration`, () => { future: { v3_fetcherPersist: false, v3_relativeSplatPath: false, - unstable_singleFetch: false, }, routeModules: { root: { default: () => null } }, manifest: { diff --git a/packages/react-router/__tests__/dom/ssr/components-test.tsx b/packages/react-router/__tests__/dom/ssr/components-test.tsx index 39e5611116..210e90479f 100644 --- a/packages/react-router/__tests__/dom/ssr/components-test.tsx +++ b/packages/react-router/__tests__/dom/ssr/components-test.tsx @@ -83,7 +83,7 @@ function itPrefetchesPageLinks< let dataHref = container .querySelector('link[rel="prefetch"][as="fetch"]') ?.getAttribute("href"); - expect(dataHref).toBe("/idk?_data=idk"); + expect(dataHref).toBe("/idk.data"); let moduleHref = container .querySelector('link[rel="modulepreload"]') ?.getAttribute("href"); @@ -216,12 +216,9 @@ describe("", () => { }); describe("", () => { - it("handles empty default export objects from the compiler", () => { + it("handles empty default export objects from the compiler", async () => { window.__remixContext = { url: "/", - state: { - loaderData: {}, - }, future: {}, }; window.__remixRouteModules = { @@ -261,10 +258,24 @@ describe("", () => { url: "", version: "", }; + window.__remixContext!.stream = new ReadableStream({ + start(controller) { + window.__remixContext!.streamController = controller; + }, + }).pipeThrough(new TextEncoderStream()); + window.__remixContext!.streamController.enqueue( + // ts-expect-error + '[{"1":2,"6":4,"7":4},"loaderData",{"3":4,"5":4},"root",null,"empty","actionData","errors"]\n' + ); + window.__remixContext!.streamController.close(); jest.spyOn(console, "error"); + jest.spyOn(console, "warn").mockImplementation(() => {}); - let { container } = render(); + let container; + await act(() => { + container = render().container; + }); expect(console.error).not.toHaveBeenCalled(); expect(container.innerHTML).toMatch("

Root

"); diff --git a/packages/react-router/__tests__/setup.ts b/packages/react-router/__tests__/setup.ts index 6119de6a51..4653281837 100644 --- a/packages/react-router/__tests__/setup.ts +++ b/packages/react-router/__tests__/setup.ts @@ -30,3 +30,13 @@ if (!globalThis.TextEncoder || !globalThis.TextDecoder) { globalThis.TextEncoder = TextEncoder; globalThis.TextDecoder = TextDecoder; } + +if (!globalThis.TextEncoderStream) { + const { TextEncoderStream } = require("node:stream/web"); + globalThis.TextEncoderStream = TextEncoderStream; +} + +if (!globalThis.TransformStream) { + const { TransformStream } = require("node:stream/web"); + globalThis.TransformStream = TransformStream; +} diff --git a/packages/react-router/lib/dom/ssr/browser.tsx b/packages/react-router/lib/dom/ssr/browser.tsx index 0055790676..f649673f8b 100644 --- a/packages/react-router/lib/dom/ssr/browser.tsx +++ b/packages/react-router/lib/dom/ssr/browser.tsx @@ -208,33 +208,32 @@ function createHydratedRouter(): RemixRouter { // TODO: Do some testing to confirm it's OK to skip the hard reload check // now that all route.lazy stuff is wired up - // When single fetch is enabled, we need to suspend until the initial state - // snapshot is decoded into window.__remixContext.state - if (ssrInfo.context.future.unstable_singleFetch) { - let localSsrInfo = ssrInfo; - // Note: `stateDecodingPromise` is not coupled to `router` - we'll reach this - // code potentially many times waiting for our state to arrive, but we'll - // then only get past here and create the `router` one time - if (!ssrInfo.stateDecodingPromise) { - let stream = ssrInfo.context.stream; - invariant(stream, "No stream found for single fetch decoding"); - ssrInfo.context.stream = undefined; - ssrInfo.stateDecodingPromise = decodeViaTurboStream(stream, window) - .then((value) => { - ssrInfo!.context.state = - value.value as typeof localSsrInfo.context.state; - localSsrInfo.stateDecodingPromise!.value = true; - }) - .catch((e) => { - localSsrInfo.stateDecodingPromise!.error = e; - }); - } - if (ssrInfo.stateDecodingPromise.error) { - throw ssrInfo.stateDecodingPromise.error; - } - if (!ssrInfo.stateDecodingPromise.value) { - throw ssrInfo.stateDecodingPromise; - } + // We need to suspend until the initial state snapshot is decoded into + // window.__remixContext.state + + let localSsrInfo = ssrInfo; + // Note: `stateDecodingPromise` is not coupled to `router` - we'll reach this + // code potentially many times waiting for our state to arrive, but we'll + // then only get past here and create the `router` one time + if (!ssrInfo.stateDecodingPromise) { + let stream = ssrInfo.context.stream; + invariant(stream, "No stream found for single fetch decoding"); + ssrInfo.context.stream = undefined; + ssrInfo.stateDecodingPromise = decodeViaTurboStream(stream, window) + .then((value) => { + ssrInfo!.context.state = + value.value as typeof localSsrInfo.context.state; + localSsrInfo.stateDecodingPromise!.value = true; + }) + .catch((e) => { + localSsrInfo.stateDecodingPromise!.error = e; + }); + } + if (ssrInfo.stateDecodingPromise.error) { + throw ssrInfo.stateDecodingPromise.error; + } + if (!ssrInfo.stateDecodingPromise.value) { + throw ssrInfo.stateDecodingPromise; } let routes = createClientRoutes( @@ -308,14 +307,14 @@ function createHydratedRouter(): RemixRouter { v7_prependBasename: true, v7_relativeSplatPath: ssrInfo.context.future.v3_relativeSplatPath, // Single fetch enables this underlying behavior - unstable_skipActionErrorRevalidation: - ssrInfo.context.future.unstable_singleFetch === true, + unstable_skipActionErrorRevalidation: true, }, hydrationData, mapRouteProperties, - unstable_dataStrategy: ssrInfo.context.future.unstable_singleFetch - ? getSingleFetchDataStrategy(ssrInfo.manifest, ssrInfo.routeModules) - : undefined, + unstable_dataStrategy: getSingleFetchDataStrategy( + ssrInfo.manifest, + ssrInfo.routeModules + ), }); ssrInfo.router = router; @@ -407,7 +406,7 @@ export function HydratedRouter() { This fragment is important to ensure we match the JSX structure so that useId values hydrate correctly */} - {ssrInfo.context.future.unstable_singleFetch ? <> : null} + <> ); } diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index d407684f5b..a67915d42b 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -7,15 +7,12 @@ import * as React from "react"; import { matchRoutes, type AgnosticDataRouteMatch, - type UNSAFE_DeferredData as DeferredData, type RouterState, - type TrackedPromise, } from "../../router"; import type { RemixContextObject } from "./entry"; import invariant from "./invariant"; import { - getDataLinkHrefs, getKeyedLinksForMatches, getKeyedPrefetchLinks, getModuleLinkHrefs, @@ -23,7 +20,7 @@ import { isPageLinkDescriptor, } from "./links"; import type { KeyedHtmlLinkDescriptor, PrefetchPageDescriptor } from "./links"; -import { createHtml, escapeHtml } from "./markup"; +import { createHtml } from "./markup"; import type { MetaFunction, MetaDescriptor, @@ -32,8 +29,7 @@ import type { } from "./routeModules"; import { addRevalidationParam, singleFetchUrl } from "./single-fetch"; import { DataRouterContext, DataRouterStateContext } from "../../context"; -import { useAsyncError, useLocation, useNavigation } from "../../hooks"; -import { Await } from "../../components"; +import { useLocation, useNavigation } from "../../hooks"; // TODO: Temporary shim until we figure out the way to handle typings in v7 export type SerializeFrom = D extends () => {} ? Awaited> : D; @@ -316,7 +312,7 @@ function PrefetchPageLinksImpl({ matches: AgnosticDataRouteMatch[]; }) { let location = useLocation(); - let { future, manifest, routeModules } = useRemixContext(); + let { manifest, routeModules } = useRemixContext(); let { matches } = useDataRouterStateContext(); let newMatchesForData = React.useMemo( @@ -345,11 +341,6 @@ function PrefetchPageLinksImpl({ [page, nextMatches, matches, manifest, location] ); - let dataHrefs = React.useMemo( - () => getDataLinkHrefs(page, newMatchesForData, manifest), - [newMatchesForData, page, manifest] - ); - let moduleHrefs = React.useMemo( () => getModuleLinkHrefs(newMatchesForAssets, manifest), [newMatchesForAssets, manifest] @@ -360,12 +351,7 @@ function PrefetchPageLinksImpl({ let keyedPrefetchLinks = useKeyedPrefetchLinks(newMatchesForAssets); let linksToRender: React.ReactNode | React.ReactNode[] | null = null; - if (!future.unstable_singleFetch) { - // Non-single-fetch prefetching - linksToRender = dataHrefs.map((href) => ( - - )); - } else if (newMatchesForData.length > 0) { + if (newMatchesForData.length > 0) { // Single-fetch with routes that require data let url = addRevalidationParam( manifest, @@ -587,15 +573,8 @@ export type ScriptProps = Omit< * @see https://remix.run/components/scripts */ export function Scripts(props: ScriptProps) { - let { - manifest, - serverHandoffString, - abortDelay, - serializeError, - isSpaMode, - future, - renderMeta, - } = useRemixContext(); + let { manifest, serverHandoffString, isSpaMode, renderMeta } = + useRemixContext(); let { router, static: isStatic, staticContext } = useDataRouterContext(); let { matches: routerMatches } = useDataRouterStateContext(); let navigation = useNavigation(); @@ -612,174 +591,18 @@ export function Scripts(props: ScriptProps) { isHydrated = true; }, []); - let serializePreResolvedErrorImp = (key: string, error: unknown) => { - let toSerialize: unknown; - if (serializeError && error instanceof Error) { - toSerialize = serializeError(error); - } else { - toSerialize = error; - } - return `${JSON.stringify(key)}:__remixContext.p(!1, ${escapeHtml( - JSON.stringify(toSerialize) - )})`; - }; - - let serializePreresolvedDataImp = ( - routeId: string, - key: string, - data: unknown - ) => { - let serializedData; - try { - serializedData = JSON.stringify(data); - } catch (error) { - return serializePreResolvedErrorImp(key, error); - } - return `${JSON.stringify(key)}:__remixContext.p(${escapeHtml( - serializedData - )})`; - }; - - let serializeErrorImp = (routeId: string, key: string, error: unknown) => { - let toSerialize: unknown; - if (serializeError && error instanceof Error) { - toSerialize = serializeError(error); - } else { - toSerialize = error; - } - return `__remixContext.r(${JSON.stringify(routeId)}, ${JSON.stringify( - key - )}, !1, ${escapeHtml(JSON.stringify(toSerialize))})`; - }; - - let serializeDataImp = (routeId: string, key: string, data: unknown) => { - let serializedData; - try { - serializedData = JSON.stringify(data); - } catch (error) { - return serializeErrorImp(routeId, key, error); - } - return `__remixContext.r(${JSON.stringify(routeId)}, ${JSON.stringify( - key - )}, ${escapeHtml(serializedData)})`; - }; - - let deferredScripts: any[] = []; let initialScripts = React.useMemo(() => { - let streamScript = future.unstable_singleFetch - ? // prettier-ignore - "window.__remixContext.stream = new ReadableStream({" + - "start(controller){" + - "window.__remixContext.streamController = controller;" + - "}" + - "}).pipeThrough(new TextEncoderStream());" - : ""; + let streamScript = + "window.__remixContext.stream = new ReadableStream({" + + "start(controller){" + + "window.__remixContext.streamController = controller;" + + "}" + + "}).pipeThrough(new TextEncoderStream());"; let contextScript = staticContext ? `window.__remixContext = ${serverHandoffString};${streamScript}` : " "; - // When single fetch is enabled, deferred is handled by turbo-stream - let activeDeferreds = future.unstable_singleFetch - ? undefined - : staticContext?.activeDeferreds; - - // This sets up the __remixContext with utility functions used by the - // deferred scripts. - // - __remixContext.p is a function that takes a resolved value or error and returns a promise. - // This is used for transmitting pre-resolved promises from the server to the client. - // - __remixContext.n is a function that takes a routeID and key to returns a promise for later - // resolution by the subsequently streamed chunks. - // - __remixContext.r is a function that takes a routeID, key and value or error and resolves - // the promise created by __remixContext.n. - // - __remixContext.t is a map or routeId to keys to an object containing `e` and `r` methods - // to resolve or reject the promise created by __remixContext.n. - // - __remixContext.a is the active number of deferred scripts that should be rendered to match - // the SSR tree for hydration on the client. - contextScript += !activeDeferreds - ? "" - : [ - "__remixContext.p = function(v,e,p,x) {", - " if (typeof e !== 'undefined') {", - process.env.NODE_ENV === "development" - ? " x=new Error(e.message);\n x.stack=e.stack;" - : ' x=new Error("Unexpected Server Error");\n x.stack=undefined;', - " p=Promise.reject(x);", - " } else {", - " p=Promise.resolve(v);", - " }", - " return p;", - "};", - "__remixContext.n = function(i,k) {", - " __remixContext.t = __remixContext.t || {};", - " __remixContext.t[i] = __remixContext.t[i] || {};", - " let p = new Promise((r, e) => {__remixContext.t[i][k] = {r:(v)=>{r(v);},e:(v)=>{e(v);}};});", - typeof abortDelay === "number" - ? `setTimeout(() => {if(typeof p._error !== "undefined" || typeof p._data !== "undefined"){return;} __remixContext.t[i][k].e(new Error("Server timeout."))}, ${abortDelay});` - : "", - " return p;", - "};", - "__remixContext.r = function(i,k,v,e,p,x) {", - " p = __remixContext.t[i][k];", - " if (typeof e !== 'undefined') {", - process.env.NODE_ENV === "development" - ? " x=new Error(e.message);\n x.stack=e.stack;" - : ' x=new Error("Unexpected Server Error");\n x.stack=undefined;', - " p.e(x);", - " } else {", - " p.r(v);", - " }", - "};", - ].join("\n") + - Object.entries(activeDeferreds) - .map(([routeId, deferredData]) => { - let pendingKeys = new Set(deferredData.pendingKeys); - let promiseKeyValues = deferredData.deferredKeys - .map((key) => { - if (pendingKeys.has(key)) { - deferredScripts.push( - - ); - - return `${JSON.stringify( - key - )}:__remixContext.n(${JSON.stringify( - routeId - )}, ${JSON.stringify(key)})`; - } else { - let trackedPromise = deferredData.data[key] as TrackedPromise; - if (typeof trackedPromise._error !== "undefined") { - return serializePreResolvedErrorImp( - key, - trackedPromise._error - ); - } else { - return serializePreresolvedDataImp( - routeId, - key, - trackedPromise._data - ); - } - } - }) - .join(",\n"); - return `Object.assign(__remixContext.state.loaderData[${JSON.stringify( - routeId - )}], {${promiseKeyValues}});`; - }) - .join("\n") + - (deferredScripts.length > 0 - ? `__remixContext.a=${deferredScripts.length};` - : ""); - let routeModulesScript = !isStatic ? " " : `${ @@ -826,19 +649,6 @@ import(${JSON.stringify(manifest.entry.module)});`; // eslint-disable-next-line }, []); - if (!isStatic && typeof __remixContext === "object" && __remixContext.a) { - for (let i = 0; i < __remixContext.a; i++) { - deferredScripts.push( - - ); - } - } - // avoid waterfall when importing the next route module let nextMatches = React.useMemo(() => { if (navigation.location) { @@ -889,112 +699,10 @@ import(${JSON.stringify(manifest.entry.module)});`; /> ))} {initialScripts} - {deferredScripts} ); } -function DeferredHydrationScript({ - dataKey, - deferredData, - routeId, - scriptProps, - serializeData, - serializeError, -}: { - dataKey?: string; - deferredData?: DeferredData; - routeId?: string; - scriptProps?: ScriptProps; - serializeData: (routeId: string, key: string, data: unknown) => string; - serializeError: (routeId: string, key: string, error: unknown) => string; -}) { - if (typeof document === "undefined" && deferredData && dataKey && routeId) { - invariant( - deferredData.pendingKeys.includes(dataKey), - `Deferred data for route ${routeId} with key ${dataKey} was not pending but tried to render a script for it.` - ); - } - - return ( - - ) - } - > - {typeof document === "undefined" && deferredData && dataKey && routeId ? ( - - } - children={(data) => { - return ( -