diff --git a/.changeset/single-fetch.md b/.changeset/single-fetch.md new file mode 100644 index 00000000000..57ab4f0222c --- /dev/null +++ b/.changeset/single-fetch.md @@ -0,0 +1,15 @@ +--- +"@remix-run/dev": minor +"@remix-run/react": minor +"@remix-run/server-runtime": minor +"@remix-run/testing": minor +--- + +New `future.unstable_singleFetch` flag + +- Naked objects returned from loaders/actions are no longer automatically converted to JSON responses. They'll be streamed as-is via `turbo-stream` so `Date`'s will become `Date` through `useLoaderData()` +- You can return naked objects with `Promise`'s without needing to use `defer()` - including nested `Promise`'s + - If you need to return a custom status code or custom response headers, you can still use the `defer` utility +- `` is no longer used. Instead, you should `export const streamTimeout` from `entry.server.tsx` and the remix server runtime will use that as the delay to abort the streamed response + - If you export your own streamTimeout, you should decouple that from aborting the react `renderToPipeableStream`. You should always ensure that react is aborted _afer_ the stream is aborted so that abort rejections can be flushed down +- Actions no longer automatically revalidate on 4xx/5xx responses (via RR `future.unstable_skipActionErrorRevalidation` flag) - you can return a 2xx to opt-into revalidation or use `shouldRevalidate` diff --git a/integration/action-test.ts b/integration/action-test.ts index b3861f3a153..b6de9ef020d 100644 --- a/integration/action-test.ts +++ b/integration/action-test.ts @@ -211,3 +211,218 @@ test.describe("actions", () => { 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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/urlencoded.tsx": js` + import { Form, useActionData } from "@remix-run/react"; + + 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 "@remix-run/react"; + + 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 "@remix-run/node"; + import { Form } from "@remix-run/react"; + + 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 "@remix-run/react"; + + 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/catch-boundary-data-test.ts b/integration/catch-boundary-data-test.ts index 0d6d3b88b8c..708f1124e2a 100644 --- a/integration/catch-boundary-data-test.ts +++ b/integration/catch-boundary-data-test.ts @@ -28,14 +28,14 @@ let HAS_BOUNDARY_NESTED_LOADER = "/yes/loader-self-boundary" as const; let ROOT_DATA = "root data"; let LAYOUT_DATA = "root data"; -test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); +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.describe("ErrorBoundary (thrown responses)", () => { test.beforeAll(async () => { fixture = await createFixture({ files: { @@ -242,3 +242,231 @@ test.describe("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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + useMatches, + } from "@remix-run/react"; + + 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 "@remix-run/react"; + 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 "@remix-run/react"; + 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 "@remix-run/react"; + 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 c92611bdf96..1817988b4d6 100644 --- a/integration/catch-boundary-test.ts +++ b/integration/catch-boundary-test.ts @@ -365,3 +365,370 @@ test.describe("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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts, useMatches } from "@remix-run/react"; + + 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 "@remix-run/react"; + export default function() { + return ( +
+ ${NOT_FOUND_HREF} + +
+
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "@remix-run/react"; + 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 "@remix-run/react"; + export function action() { + throw new Response("", { status: 401 }) + } + export default function Index() { + return ( +
+ +
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_LOADER_FILE}.jsx`]: js` + import { useRouteError } from '@remix-run/react'; + 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 "@remix-run/react"; + + export function loader() { + return "PARENT"; + } + + export default function () { + return ( +
+

{useLoaderData()}

+ +
+ ) + } + `, + + "app/routes/action.child-catch.tsx": js` + import { Form, useLoaderData, useRouteError } from "@remix-run/react"; + + 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 5a1046d59ed..2269fd41fcb 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -6,7 +6,7 @@ import { createFixture, js, } from "./helpers/create-fixture.js"; -import type { AppFixture } from "./helpers/create-fixture.js"; +import type { AppFixture, FixtureInit } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; function getFiles({ @@ -145,10 +145,14 @@ test.describe("Client Data", () => { appFixture.close(); }); + function createTestFixture(init: FixtureInit, serverMode?: ServerMode) { + return createFixture(init, serverMode); + } + test.describe("clientLoader - critical route module", () => { test("no client loaders or fallbacks", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -168,7 +172,7 @@ test.describe("Client Data", () => { test("parent.clientLoader/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -188,7 +192,7 @@ test.describe("Client Data", () => { test("parent.clientLoader.hydrate/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: true, @@ -214,7 +218,7 @@ test.describe("Client Data", () => { test("parent.clientLoader/child.clientLoader.hydrate", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -242,7 +246,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: true, @@ -269,7 +273,7 @@ test.describe("Client Data", () => { }); test("handles synchronous client loaders", async ({ page }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -308,7 +312,7 @@ test.describe("Client Data", () => { }); test("handles deferred data through client loaders", async ({ page }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -378,7 +382,7 @@ test.describe("Client Data", () => { test("allows hydration execution without rendering a fallback", async ({ page, }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -407,7 +411,7 @@ test.describe("Client Data", () => { test("HydrateFallback is not rendered if clientLoader.hydrate is not set (w/server loader)", async ({ page, }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -461,7 +465,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -504,7 +508,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -547,7 +551,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -590,7 +594,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -655,7 +659,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -732,7 +736,7 @@ test.describe("Client Data", () => { let _consoleError = console.error; console.error = () => {}; appFixture = await createAppFixture( - await createFixture( + await createTestFixture( { files: { ...getFiles({ @@ -827,13 +831,15 @@ test.describe("Client Data", () => { ); let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); + 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 new Promise((r) => setTimeout(r, 100)); + 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"); @@ -845,7 +851,7 @@ test.describe("Client Data", () => { test.describe("clientLoader - lazy route module", () => { test("no client loaders or fallbacks", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -867,7 +873,7 @@ test.describe("Client Data", () => { test("parent.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -888,7 +894,7 @@ test.describe("Client Data", () => { test("child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -909,7 +915,7 @@ test.describe("Client Data", () => { test("parent.clientLoader/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -932,7 +938,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -976,7 +982,7 @@ test.describe("Client Data", () => { test.describe("clientAction - critical route module", () => { test("child.clientAction", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1010,7 +1016,7 @@ test.describe("Client Data", () => { test("child.clientAction/parent.childLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1052,7 +1058,7 @@ test.describe("Client Data", () => { test("child.clientAction/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1096,7 +1102,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1140,7 +1146,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -1185,7 +1191,7 @@ test.describe("Client Data", () => { test.describe("clientAction - lazy route module", () => { test("child.clientAction", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1221,7 +1227,7 @@ test.describe("Client Data", () => { test("child.clientAction/parent.childLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1265,7 +1271,7 @@ test.describe("Client Data", () => { test("child.clientAction/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1311,7 +1317,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1357,7 +1363,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -1401,3 +1407,1243 @@ test.describe("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, + config: { + future: { + unstable_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 '@remix-run/node' + import { Await, useLoaderData } from '@remix-run/react' + 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 '@remix-run/node'; + import { useLoaderData } from '@remix-run/react'; + 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 '@remix-run/react'; + // 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 '@remix-run/react'; + // 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 '@remix-run/react'; + 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 '@remix-run/node'; + import { useLoaderData, useRevalidator } from '@remix-run/react'; + 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 '@remix-run/node'; + import { useLoaderData, useRevalidator } from '@remix-run/react'; + 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 "@remix-run/react"; + + 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 '@remix-run/react'; + 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 '@remix-run/node'; + import { Form, useRouteError } from '@remix-run/react'; + 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 '@remix-run/node'; + import { Form, useRouteError } from '@remix-run/react'; + 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 141c507eeb9..0923b99447f 100644 --- a/integration/defer-loader-test.ts +++ b/integration/defer-loader-test.ts @@ -11,10 +11,11 @@ import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; let fixture: Fixture; let appFixture: AppFixture; -test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/_index.tsx": js` +test.describe("deferred loaders", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` import { useLoaderData, Link } from "@remix-run/react"; export default function Index() { return ( @@ -26,7 +27,7 @@ test.beforeAll(async () => { } `, - "app/routes/redirect.tsx": js` + "app/routes/redirect.tsx": js` import { defer } from "@remix-run/node"; export function loader() { return defer({food: "pizza"}, { status: 301, headers: { Location: "/?redirected" } }); @@ -34,7 +35,7 @@ test.beforeAll(async () => { export default function Redirect() {return null;} `, - "app/routes/direct-promise-access.tsx": js` + "app/routes/direct-promise-access.tsx": js` import * as React from "react"; import { defer } from "@remix-run/node"; import { useLoaderData, Link, Await } from "@remix-run/react"; @@ -66,32 +67,133 @@ test.beforeAll(async () => { ) } `, - }, + }, + }); + + appFixture = await createAppFixture(fixture); }); - appFixture = await createAppFixture(fixture); -}); + test.afterAll(async () => appFixture.close()); -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 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("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"); + }); }); -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"); +// 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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/_index.tsx": js` + import { useLoaderData, Link } from "@remix-run/react"; + export default function Index() { + return ( +
+ Redirect + Direct Promise Access +
+ ) + } + `, + + "app/routes/redirect.tsx": js` + import { defer } from "@remix-run/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 "@remix-run/node"; + import { useLoaderData, Link, Await } from "@remix-run/react"; + 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 488f744a810..487be7f8b03 100644 --- a/integration/defer-test.ts +++ b/integration/defer-test.ts @@ -33,17 +33,17 @@ declare global { }; } -test.beforeEach(async ({ context }) => { - await context.route(/_data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); - }); -}); - 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({ files: { @@ -793,7 +793,7 @@ test.describe("non-aborted", () => { }) => { let app = new PlaywrightFixture(appFixture, page); let assertConsole = monitorConsole(page); - app.goto("/deferred-manual-resolve"); + app.goto("/deferred-manual-resolve", false); await page.waitForSelector(`#${ROOT_ID}`); await page.waitForSelector(`#${DEFERRED_ID}`); @@ -825,7 +825,7 @@ test.describe("non-aborted", () => { }) => { let app = new PlaywrightFixture(appFixture, page); let assertConsole = monitorConsole(page); - await app.goto("/deferred-manual-resolve"); + await app.goto("/deferred-manual-resolve", false); await page.waitForSelector(`#${ROOT_ID}`); await page.waitForSelector(`#${DEFERRED_ID}`); @@ -977,6 +977,13 @@ 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({ files: { @@ -1301,6 +1308,1308 @@ test.describe("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({ + config: { + future: { + unstable_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 "@remix-run/node"; + import { Links, Meta, Outlet, Scripts, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + import type { AppLoadContext, EntryContext } from "@remix-run/node"; + import { createReadableStreamFromReadable } from "@remix-run/node"; + import { RemixServer } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Links, Meta, Outlet, Scripts, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 "@remix-run/node"; + import { Await, Link, useLoaderData } from "@remix-run/react"; + 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 c01c0f7588d..9d2166baa62 100644 --- a/integration/error-boundary-test.ts +++ b/integration/error-boundary-test.ts @@ -1329,3 +1329,1380 @@ test("Allows back-button out of an error boundary after a hard reload", async ({ 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( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + + export function ErrorBoundary() { + return ( + + + +
+
${ROOT_BOUNDARY_TEXT}
+
+ + + + ) + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + 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 "@remix-run/react"; + 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 "@remix-run/react"; + 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 "@remix-run/react"; + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

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

{useLoaderData()}

+ +
+ ) + } + `, + + "app/routes/action.child-error.tsx": js` + import { Form, useLoaderData, useRouteError } from "@remix-run/react"; + + 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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + + 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 "@remix-run/react"; + + export async function loader() {} + + export default function () { + let data = useLoaderData(); + return ( +
+

{data}

+
+ ) + } + `, + + [`app/routes${NO_ROOT_BOUNDARY_ACTION_RETURN}.jsx`]: js` + import { useActionData } from "@remix-run/react"; + + 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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { Outlet, useLoaderData, useMatches, useRouteError } from "@remix-run/react"; + + 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 "@remix-run/react"; + + 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 "@remix-run/react"; + + 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!

' + ); + 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 "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + + ${errorBoundaryCode} + `, + + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + 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( + { + config: { + future: { + unstable_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( + { + config: { + future: { + unstable_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({ + config: { + future: { + unstable_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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useRouteError } from "@remix-run/react"; + + export default function App() { + return ( + + + + + + + + + + + ); + } + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + + + Oh no! + + + + +

ERROR BOUNDARY

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

INDEX

+ This will error +
+ ); + } + `, + + "app/routes/boom.tsx": js` + import { json } from "@remix-run/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 2d4ba38a3d6..16014bc1fa4 100644 --- a/integration/error-boundary-v2-test.ts +++ b/integration/error-boundary-v2-test.ts @@ -241,6 +241,245 @@ test.describe("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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { + Link, + Outlet, + isRouteErrorResponse, + useLoaderData, + useRouteError, + } from "@remix-run/react"; + + 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 "@remix-run/react"; + + 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 "@remix-run/react"; + + 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 47980a089fd..b19e8cea4f2 100644 --- a/integration/error-data-request-test.ts +++ b/integration/error-data-request-test.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from "@remix-run/router"; import { createAppFixture, @@ -173,3 +174,180 @@ test.describe("ErrorBoundary", () => { 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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + + 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 "@remix-run/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 "@remix-run/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({ + 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.' + ), + }); + 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 () => { + expect(() => + fixture.requestSingleFetchData("/loader-return-json.data", { + method: "TRACE", + }) + ).rejects.toThrowError( + `Failed to construct 'Request': '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"'); + }); + }); +}); diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 63f57fc3a18..40cae6ab043 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from "@remix-run/router"; import { ServerMode } from "../build/node_modules/@remix-run/server-runtime/dist/mode.js"; import type { Fixture } from "./helpers/create-fixture.js"; @@ -263,17 +264,19 @@ test.describe("Error Sanitization", () => { }); test("sanitizes loader errors in resource requests", async () => { - let response = await fixture.requestData( - "/resource?loader", - "routes/resource" - ); + let response = await fixture.requestResource("/resource?loader"); let text = await response.text(); - expect(text).toBe('{"message":"Unexpected Server Error"}'); + 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 "); }); + // 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(); @@ -418,20 +421,15 @@ test.describe("Error Sanitization", () => { }); test("does not sanitize loader errors in resource requests", async () => { - let response = await fixture.requestData( - "/resource?loader", - "routes/resource" - ); + let response = await fixture.requestResource("/resource?loader"); let text = await response.text(); - expect(text).toMatch( - '{"message":"Loader Error","stack":"Error: Loader Error' - ); + 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("sanitizes mismatched route errors in data requests", async () => { + 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( @@ -628,15 +626,12 @@ test.describe("Error Sanitization", () => { }); test("sanitizes loader errors in resource requests", async () => { - let response = await fixture.requestData( - "/resource?loader", - "routes/resource" - ); + let response = await fixture.requestResource("/resource?loader"); let text = await response.text(); - expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(text).toBe("Unexpected Server Error"); expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); expect(errorLogs[1][0]).toEqual( - " Request: GET test://test/resource?loader=&_data=routes%2Fresource" + " Request: GET test://test/resource?loader" ); expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); expect(errorLogs[3][0]).toMatch(" at "); @@ -660,3 +655,636 @@ test.describe("Error Sanitization", () => { }); }); }); + +// 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( + { + config: { + future: { + unstable_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( + { + config: { + future: { + unstable_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( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + + import { createReadableStreamFromReadable } from "@remix-run/node"; + import { RemixServer, isRouteErrorResponse } from "@remix-run/react"; + 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 b36c415709f..cd29f91a791 100644 --- a/integration/fetcher-layout-test.ts +++ b/integration/fetcher-layout-test.ts @@ -11,272 +11,553 @@ import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; let fixture: Fixture; let appFixture: AppFixture; -test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/layout-action.tsx": js` - import { json } from "@remix-run/node"; - import { Outlet, useFetcher, useFormAction } from "@remix-run/react"; - - 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 "@remix-run/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "@remix-run/react"; - - 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 "@remix-run/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "@remix-run/react"; - - 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 "@remix-run/node"; - import { Outlet, useFetcher, useFormAction } from "@remix-run/react"; - - 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 "@remix-run/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "@remix-run/react"; - - 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 "@remix-run/node"; - import { - useFetcher, - useFormAction, - useLoaderData, - } from "@remix-run/react"; - - 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.describe("multi fetch", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/layout-action.tsx": js` + import { json } from "@remix-run/node"; + import { Outlet, useFetcher, useFormAction } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { Outlet, useFetcher, useFormAction } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "@remix-run/react"; + + 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); }); - 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"); +// 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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/layout-action.tsx": js` + import { json } from "@remix-run/node"; + import { Outlet, useFetcher, useFormAction } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { Outlet, useFetcher, useFormAction } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "@remix-run/react"; + + 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("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"); + }); }); diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index 9f33e7411d0..ad7959f6b95 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -527,3 +527,539 @@ test.describe("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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/resource-route-action-only.ts": js` + import { json } from "@remix-run/node"; + export function action() { + return new Response("${CHEESESTEAK}"); + } + `, + + "app/routes/fetcher-action-only-call.tsx": js` + import { useFetcher } from "@remix-run/react"; + + 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 "@remix-run/react"; + export default function Index() { + let fetcher = useFetcher(); + return ( + <> + + + + + + +
{fetcher.data}
+ + ); + } + `, + + "app/routes/parent.tsx": js` + import { Outlet } from "@remix-run/react"; + + 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 "@remix-run/react"; + + 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 "@remix-run/node"; + import { useFetcher } from "@remix-run/react"; + + 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({
+        config: {
+          future: {
+            unstable_singleFetch: true,
+          },
+        },
+        files: {
+          "app/routes/_index.tsx": js`
+            import * as React from "react";
+            import {
+              Form,
+              useFetcher,
+              useLoaderData,
+              useNavigation
+            } from "@remix-run/react";
+
+            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 e72d3b9ba47..ccc95be5e9b 100644 --- a/integration/file-uploads-test.ts +++ b/integration/file-uploads-test.ts @@ -145,3 +145,151 @@ test.describe("file-uploads", () => { >`); }); }); + +// 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({ + config: { + future: { + unstable_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 "@remix-run/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 "@remix-run/node"; + import { Form, useActionData } from "@remix-run/react"; + 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."
+}
`); + }); + }); +}); diff --git a/integration/form-data-test.ts b/integration/form-data-test.ts index 86e0832fcea..d9cd69b7801 100644 --- a/integration/form-data-test.ts +++ b/integration/form-data-test.ts @@ -5,56 +5,121 @@ import type { Fixture } from "./helpers/create-fixture.js"; let fixture: Fixture; -test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/_index.tsx": js` - import { json } from "@remix-run/node"; - - export async function action({ request }) { - try { - await request.formData() - } catch { - return json("no pizza"); +test.describe("multi fetch", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + + export async function action({ request }) { + try { + await request.formData() + } catch { + return json("no pizza"); + } + return json("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" }, + 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"); }); - 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$", + 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"); }); - 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$", + 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"); }); - 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$", +// 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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/_index.tsx": js` + import { json } from "@remix-run/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 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"); }); - expect(await response.text()).toMatch("pizza"); }); diff --git a/integration/form-test.ts b/integration/form-test.ts index f2331ad2987..b7103e332a4 100644 --- a/integration/form-test.ts +++ b/integration/form-test.ts @@ -1136,3 +1136,1146 @@ test.describe("Forms", () => { }); } }); + +// 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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/get-submission.tsx": js` + import { useLoaderData, Form } from "@remix-run/react"; + + 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 "@remix-run/react"; + export default function() { + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) + } + `, + + "app/routes/blog.tsx": js` + import { Form, Outlet } from "@remix-run/react"; + export default function() { + return ( + <> +

Blog

+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + ) + } + `, + + "app/routes/blog._index.tsx": js` + import { Form } from "@remix-run/react"; + export function action() { + return { ok: true }; + } + export default function() { + return ( + <> +
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + +
+ + ) + } + `, + + "app/routes/blog.$postId.tsx": js` + import { Form } from "@remix-run/react"; + export default function() { + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) + } + `, + + "app/routes/projects.tsx": js` + import { Form, Outlet } from "@remix-run/react"; + 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 "@remix-run/react"; + export default function() { + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) + } + `, + + "app/routes/stop-propagation.tsx": js` + import { json } from "@remix-run/node"; + import { Form, useActionData } from "@remix-run/react"; + + 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 "@remix-run/react"; + import { json } from "@remix-run/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 "@remix-run/react"; + + export default function() { + return ( + <> + +
+ + + + + + + + + +
+ + ) + } + `, + + "app/routes/file-upload.tsx": js` + import { Form, useSearchParams } from "@remix-run/react"; + + 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 "@remix-run/react"; + + 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 '@remix-run/server-runtime' + import { Form, Outlet, useActionData } from '@remix-run/react' + + 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 '@remix-run/react'; + + 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}")`); + }); + + 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("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("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)); + }); + 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("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( + "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 }) => { + 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"}'); + } + ); + + 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.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.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.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.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"); + }); + } + }); +}); diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 0b77758945e..78be4f2294f 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -16,6 +16,7 @@ import type { ServerBuild } from "../../build/node_modules/@remix-run/server-run import { createRequestHandler } from "../../build/node_modules/@remix-run/server-runtime/dist/index.js"; import { createRequestHandler as createExpressHandler } from "../../build/node_modules/@remix-run/express/dist/index.js"; import { installGlobals } from "../../build/node_modules/@remix-run/node/dist/index.js"; +import { decodeViaTurboStream } from "../../build/node_modules/@remix-run/react/dist/single-fetch.js"; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const root = path.join(__dirname, "../.."); @@ -83,6 +84,12 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { requestData: () => { throw new Error("Cannot requestData in SPA Mode tests"); }, + requestResource: () => { + throw new Error("Cannot requestResource in SPA Mode tests"); + }, + requestSingleFetchData: () => { + throw new Error("Cannot requestSingleFetchData in SPA Mode tests"); + }, postDocument: () => { throw new Error("Cannot postDocument in SPA Mode tests"); }, @@ -116,6 +123,29 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { return handler(request); }; + let requestResource = async (href: string, init?: RequestInit) => { + init = init || {}; + init.signal = init.signal || new AbortController().signal; + let url = new URL(href, "test://test"); + let request = new Request(url.toString(), init); + return handler(request); + }; + + let requestSingleFetchData = async (href: string, init?: RequestInit) => { + init = init || {}; + init.signal = init.signal || new AbortController().signal; + let url = new URL(href, "test://test"); + let request = new Request(url.toString(), init); + let response = await handler(request); + let decoded = await decodeViaTurboStream(response.body!, global); + return { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: decoded.value, + }; + }; + let postDocument = async (href: string, data: URLSearchParams | FormData) => { return requestDocument(href, { method: "POST", @@ -136,6 +166,8 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { compiler, requestDocument, requestData, + requestResource, + requestSingleFetchData, postDocument, getBrowserAsset, useRemixServe: init.useRemixServe, diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts index 25683743f39..8a797d115ba 100644 --- a/integration/helpers/playwright-fixture.ts +++ b/integration/helpers/playwright-fixture.ts @@ -19,12 +19,21 @@ export class PlaywrightFixture { * Visits the href with a document request. * * @param href The href you want to visit - * @param waitForHydration Will wait for the network to be idle, so - * everything should be loaded and ready to go + * @param waitForHydration Wait for the page to full load/hydrate? + * - `undefined` to wait for the document `load` event + * - `true` wait for the network to be idle, so everything should be loaded + * and ready to go + * - `false` to wait only until the initial doc to be returned and the document + * to start loading (mostly useful for testing deferred responses) */ - async goto(href: string, waitForHydration?: true): Promise { + async goto(href: string, waitForHydration?: boolean): Promise { let response = await this.page.goto(this.app.serverUrl + href, { - waitUntil: waitForHydration ? "networkidle" : undefined, + waitUntil: + waitForHydration === true + ? "networkidle" + : waitForHydration === false + ? "commit" + : "load", }); if (response == null) throw new Error( @@ -156,7 +165,16 @@ export class PlaywrightFixture { * were called (or not). */ collectDataResponses() { - return collectDataResponses(this.page); + 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 + * loaders were called (or not). + */ + collectSingleFetchResponses() { + return this.collectResponses((url) => url.pathname.endsWith(".data")); } /** @@ -164,8 +182,16 @@ export class PlaywrightFixture { * form submission. A filter can be provided to only collect responses * that meet a certain criteria. */ - collectResponses(filter?: UrlFilter) { - return collectResponses(this.page, filter); + collectResponses(filter?: (url: URL) => boolean) { + let responses: Response[] = []; + + this.page.on("response", (res) => { + if (!filter || filter(new URL(res.url()))) { + responses.push(res); + } + }); + + return responses; } /** @@ -327,21 +353,3 @@ async function doAndWait( return result; } - -type UrlFilter = (url: URL) => boolean; - -function collectResponses(page: Page, filter?: UrlFilter): Response[] { - let responses: Response[] = []; - - page.on("response", (res) => { - if (!filter || filter(new URL(res.url()))) { - responses.push(res); - } - }); - - return responses; -} - -function collectDataResponses(page: Page) { - return collectResponses(page, (url) => url.searchParams.has("_data")); -} diff --git a/integration/loader-test.ts b/integration/loader-test.ts index c81201e8404..86e64def002 100644 --- a/integration/loader-test.ts +++ b/integration/loader-test.ts @@ -137,3 +137,141 @@ test.describe("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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export const loader = () => json("${ROOT_DATA}"); + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { json } from "@remix-run/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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Outlet } from '@remix-run/react' + + export default function Root() { + return ( + + + ${HOME_PAGE_TEXT} + + + + ); + } + `, + "app/routes/redirect.tsx": js` + import { redirect } from "@remix-run/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 "@remix-run/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/navigation-state-test.ts b/integration/navigation-state-test.ts index 4253711bba6..01902e4d55b 100644 --- a/integration/navigation-state-test.ts +++ b/integration/navigation-state-test.ts @@ -465,3 +465,456 @@ test.describe("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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { useMemo, useRef } from "react"; + import { Outlet, Scripts, useNavigation } from "@remix-run/react"; + 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 "@remix-run/react"; + 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 "@remix-run/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 "@remix-run/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 "@remix-run/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 "@remix-run/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/package.json b/integration/package.json index a4f32b049d7..a7d0b877a37 100644 --- a/integration/package.json +++ b/integration/package.json @@ -14,6 +14,7 @@ "@remix-run/dev": "workspace:*", "@remix-run/express": "workspace:*", "@remix-run/node": "workspace:*", + "@remix-run/router": "0.0.0-experimental-c7dd3d3a", "@remix-run/server-runtime": "workspace:*", "@types/express": "^4.17.9", "@vanilla-extract/css": "^1.10.0", diff --git a/integration/prefetch-test.ts b/integration/prefetch-test.ts index aaa45ac99f6..8f2dd987e1e 100644 --- a/integration/prefetch-test.ts +++ b/integration/prefetch-test.ts @@ -14,365 +14,440 @@ import type { import type { RemixLinkProps } from "../build/node_modules/@remix-run/react/dist/components.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -// Generate the test app using the given prefetch mode -function fixtureFactory(mode: RemixLinkProps["prefetch"]): FixtureInit { - return { - files: { - "app/root.tsx": js` - import { - Link, - Links, - Meta, - Outlet, - Scripts, - useLoaderData, - } from "@remix-run/react"; - - export default function Root() { - const styles = - 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + - 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; - - return ( - - - - - - - -

Root

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

Index

; - } - `, - - "app/routes/with-loader.tsx": js` - export function loader() { - return { message: 'data from the loader' }; - } - export default function() { - return

With Loader

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

Without Loader

; - } - `, - }, - }; -} - -test.describe("prefetch=none", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("none")); - appFixture = await createAppFixture(fixture); - }); +test.describe("multi fetch", () => { + // Generate the test app using the given prefetch mode + function fixtureFactory(mode: RemixLinkProps["prefetch"]): FixtureInit { + return { + files: { + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "@remix-run/react"; - test.afterAll(() => { - appFixture.close(); - }); + export default function Root() { + const styles = + 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + + 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); + return ( + + + + + + + +

Root

+ + + + + + ); + } + `, - test("does not add prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#nav link").count()).toBe(0); - }); -}); + "app/routes/_index.tsx": js` + export default function() { + return

Index

; + } + `, -test.describe("prefetch=render", () => { - let fixture: Fixture; - let appFixture: AppFixture; + "app/routes/with-loader.tsx": js` + export function loader() { + return { message: 'data from the loader' }; + } + export default function() { + return

With Loader

; + } + `, - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("render")); - appFixture = await createAppFixture(fixture); - }); + "app/routes/without-loader.tsx": js` + export default function() { + return

Without Loader

; + } + `, + }, + }; + } - test.afterAll(() => { - appFixture.close(); - }); + test.describe("prefetch=none", () => { + let fixture: Fixture; + let appFixture: AppFixture; - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("none")); + appFixture = await createAppFixture(fixture); + }); - test("adds prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - // Both data and asset fetch for /with-loader - await page.waitForSelector( - "#nav link[rel='prefetch'][as='fetch'][href='/with-loader?_data=routes%2Fwith-loader']", - { state: "attached" } - ); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/build/routes/with-loader-']", - { state: "attached" } - ); - // Only asset fetch for /without-loader - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/build/routes/without-loader-']", - { state: "attached" } - ); - - // Ensure no other links in the #nav element - expect(await page.locator("#nav link").count()).toBe(3); - }); -}); + test.afterAll(() => { + appFixture.close(); + }); -test.describe("prefetch=intent (hover)", () => { - let fixture: Fixture; - let appFixture: AppFixture; + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("intent")); - appFixture = await createAppFixture(fixture); + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); }); - test.afterAll(() => { - appFixture.close(); - }); + test.describe("prefetch=render", () => { + let fixture: Fixture; + let appFixture: AppFixture; - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("render")); + appFixture = await createAppFixture(fixture); + }); - test("does not add prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#nav link").count()).toBe(0); - }); + test.afterAll(() => { + appFixture.close(); + }); - test("adds prefetch tags on hover", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.hover("a[href='/with-loader']"); - await page.waitForSelector( - "#nav link[rel='prefetch'][as='fetch'][href='/with-loader?_data=routes%2Fwith-loader']", - { state: "attached" } - ); - // Check href prefix due to hashed filenames - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/build/routes/with-loader-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(2); - - await page.hover("a[href='/without-loader']"); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/build/routes/without-loader-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(1); - }); + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); - test("removes prefetch tags after navigating to/from the page", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - // Links added on hover - await page.hover("a[href='/with-loader']"); - await page.waitForSelector("#nav link", { state: "attached" }); - expect(await page.locator("#nav link").count()).toBe(2); - - // Links removed upon navigating to the page - await page.click("a[href='/with-loader']"); - await page.waitForSelector("h2.with-loader", { state: "attached" }); - expect(await page.locator("#nav link").count()).toBe(0); - - // Links stay removed upon navigating away from the page - await page.click("a[href='/without-loader']"); - await page.waitForSelector("h2.without-loader", { state: "attached" }); - expect(await page.locator("#nav link").count()).toBe(0); + test("adds prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + // Both data and asset fetch for /with-loader + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='/with-loader?_data=routes%2Fwith-loader']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/with-loader-']", + { state: "attached" } + ); + // Only asset fetch for /without-loader + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/without-loader-']", + { state: "attached" } + ); + + // Ensure no other links in the #nav element + expect(await page.locator("#nav link").count()).toBe(3); + }); }); -}); -test.describe("prefetch=intent (focus)", () => { - let fixture: Fixture; - let appFixture: AppFixture; + test.describe("prefetch=intent (hover)", () => { + let fixture: Fixture; + let appFixture: AppFixture; - test.beforeAll(async () => { - fixture = await createFixture(fixtureFactory("intent")); - appFixture = await createAppFixture(fixture); - }); + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("intent")); + appFixture = await createAppFixture(fixture); + }); - test.afterAll(() => { - appFixture.close(); - }); + test.afterAll(() => { + appFixture.close(); + }); - test("does not render prefetch tags during SSR", async ({ page }) => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(await page.locator("#nav link").count()).toBe(0); - }); + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); - test("does not add prefetch tags on hydration", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#nav link").count()).toBe(0); - }); + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); - test("adds prefetch tags on focus", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - // This click is needed to transfer focus to the main window, allowing - // subsequent focus events to fire - await page.click("body"); - await page.focus("a[href='/with-loader']"); - await page.waitForSelector( - "#nav link[rel='prefetch'][as='fetch'][href='/with-loader?_data=routes%2Fwith-loader']", - { state: "attached" } - ); - // Check href prefix due to hashed filenames - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/build/routes/with-loader-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(2); - - await page.focus("a[href='/without-loader']"); - await page.waitForSelector( - "#nav link[rel='modulepreload'][href^='/build/routes/without-loader-']", - { state: "attached" } - ); - expect(await page.locator("#nav link").count()).toBe(1); + test("adds prefetch tags on hover", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='/with-loader']"); + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='/with-loader?_data=routes%2Fwith-loader']", + { state: "attached" } + ); + // Check href prefix due to hashed filenames + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/with-loader-']", + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(2); + + await page.hover("a[href='/without-loader']"); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/without-loader-']", + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(1); + }); + + test("removes prefetch tags after navigating to/from the page", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Links added on hover + await page.hover("a[href='/with-loader']"); + await page.waitForSelector("#nav link", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(2); + + // Links removed upon navigating to the page + await page.click("a[href='/with-loader']"); + await page.waitForSelector("h2.with-loader", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(0); + + // Links stay removed upon navigating away from the page + await page.click("a[href='/without-loader']"); + await page.waitForSelector("h2.without-loader", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(0); + }); }); -}); -test.describe("prefetch=viewport", () => { - let fixture: Fixture; - let appFixture: AppFixture; + test.describe("prefetch=intent (focus)", () => { + let fixture: Fixture; + let appFixture: AppFixture; - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/_index.tsx": js` - import { Link } from "@remix-run/react"; - - export default function Component() { - return ( - <> -

Index Page - Scroll Down

-
- Click me! -
- - ); - } - `, + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("intent")); + appFixture = await createAppFixture(fixture); + }); - "app/routes/test.tsx": js` - export function loader() { - return null; - } - export default function Component() { - return

Test Page

; - } - `, - }, + test.afterAll(() => { + appFixture.close(); }); - // This creates an interactive app using puppeteer. - appFixture = await createAppFixture(fixture); - }); + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); - test.afterAll(() => { - appFixture.close(); - }); + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); - test("should prefetch when the link enters the viewport", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - - // No preloads to start - await expect(page.locator("div link")).toHaveCount(0); - - // Preloads render on scroll down - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - - await page.waitForSelector( - "div link[rel='prefetch'][as='fetch'][href='/test?_data=routes%2Ftest']", - { state: "attached" } - ); - await page.waitForSelector( - "div link[rel='modulepreload'][href^='/build/routes/test-']", - { state: "attached" } - ); - - // Preloads removed on scroll up - await page.evaluate(() => window.scrollTo(0, 0)); - await expect(page.locator("div link")).toHaveCount(0); + test("adds prefetch tags on focus", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + // This click is needed to transfer focus to the main window, allowing + // subsequent focus events to fire + await page.click("body"); + await page.focus("a[href='/with-loader']"); + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='/with-loader?_data=routes%2Fwith-loader']", + { state: "attached" } + ); + // Check href prefix due to hashed filenames + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/with-loader-']", + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(2); + + await page.focus("a[href='/without-loader']"); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/without-loader-']", + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(1); + }); }); -}); - -test.describe("other scenarios", () => { - let fixture: Fixture; - let appFixture: AppFixture; - test.afterAll(() => { - appFixture?.close(); - }); + test.describe("prefetch=viewport", () => { + let fixture: Fixture; + let appFixture: AppFixture; - test("does not add prefetch links for stylesheets already in the DOM (active routes)", async ({ - page, - }) => { - fixture = await createFixture({ - files: { - "app/root.tsx": js` - import { Links, Meta, Scripts, useFetcher } from "@remix-run/react"; - import globalCss from "./global.css"; + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; - export function links() { - return [{ rel: "stylesheet", href: globalCss }]; + export default function Component() { + return ( + <> +

Index Page - Scroll Down

+
+ Click me! +
+ + ); } + `, - export async function action() { + "app/routes/test.tsx": js` + export function loader() { return null; } - - export async function loader() { - return null; + export default function Component() { + return

Test Page

; } + `, + }, + }); + + // This creates an interactive app using puppeteer. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should prefetch when the link enters the viewport", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // No preloads to start + await expect(page.locator("div link")).toHaveCount(0); + + // Preloads render on scroll down + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + await page.waitForSelector( + "div link[rel='prefetch'][as='fetch'][href='/test?_data=routes%2Ftest']", + { state: "attached" } + ); + await page.waitForSelector( + "div link[rel='modulepreload'][href^='/build/routes/test-']", + { state: "attached" } + ); + + // Preloads removed on scroll up + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(page.locator("div link")).toHaveCount(0); + }); + }); + + test.describe("other scenarios", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture?.close(); + }); + + test("does not add prefetch links for stylesheets already in the DOM (active routes)", async ({ + page, + }) => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Scripts, useFetcher } from "@remix-run/react"; + import globalCss from "./global.css"; + + export function links() { + return [{ rel: "stylesheet", href: globalCss }]; + } + + export async function action() { + return null; + } + + export async function loader() { + return null; + } + + export default function Root() { + let fetcher = useFetcher(); + + return ( + + + + + + + +

{fetcher.state}

+ + + + ); + } + `, + + "app/global.css": ` + body { + background-color: black; + color: white; + } + `, + + "app/routes/_index.tsx": js` + export default function() { + return

Index

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + let requests: { type: string; url: string }[] = []; + + page.on("request", (req) => { + requests.push({ + type: req.resourceType(), + url: req.url(), + }); + }); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.click("#submit-fetcher"); + await page.waitForSelector("#fetcher-state--idle"); + // We should not send a second request for this root stylesheet that's + // already been rendered in the DOM + let stylesheets = requests.filter( + (r) => r.type === "stylesheet" && /\/global-[a-z0-9]+\.css/i.test(r.url) + ); + expect(stylesheets.length).toBe(1); + }); + + test("dedupes prefetch tags", async ({ page }) => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "@remix-run/react"; export default function Root() { - let fetcher = useFetcher(); + const styles = + 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + + 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; return ( @@ -381,12 +456,14 @@ test.describe("other scenarios", () => { - -

{fetcher.state}

+ +

Root

+ + @@ -394,44 +471,115 @@ test.describe("other scenarios", () => { } `, - "app/global.css": ` - body { + "app/global.css": css` + .global-class { + background-color: gray; + color: black; + } + `, + + "app/local.css": css` + .local-class { background-color: black; color: white; } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` export default function() { return

Index

; } `, - }, - }); - appFixture = await createAppFixture(fixture); - let requests: { type: string; url: string }[] = []; - page.on("request", (req) => { - requests.push({ - type: req.resourceType(), - url: req.url(), + "app/routes/with-nested-links.tsx": js` + import { Outlet } from "@remix-run/react"; + import globalCss from "../global.css"; + + export function links() { + return [ + // Same links as child route but with different key order + { + rel: "stylesheet", + href: globalCss, + }, + { + rel: "preload", + as: "image", + imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", + imageSizes: "9999px", + }, + ]; + } + export default function() { + return ; + } + `, + + "app/routes/with-nested-links.nested.tsx": js` + import globalCss from '../global.css'; + import localCss from '../local.css'; + + export function links() { + return [ + // Same links as parent route but with different key order + { + href: globalCss, + rel: "stylesheet", + }, + { + imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", + imageSizes: "9999px", + rel: "preload", + as: "image", + }, + // Unique links for child route + { + rel: "stylesheet", + href: localCss, + }, + { + rel: "preload", + as: "image", + imageSrcSet: "image-700.jpg 700w, image-1400.jpg 1400w", + imageSizes: "9999px", + }, + ]; + } + export default function() { + return

With Nested Links

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='/with-nested-links/nested']"); + await page.waitForSelector("#nav link[rel='prefetch'][as='style']", { + state: "attached", }); + expect( + await page.locator("#nav link[rel='prefetch'][as='style']").count() + ).toBe(2); + expect( + await page.locator("#nav link[rel='prefetch'][as='image']").count() + ).toBe(2); }); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.click("#submit-fetcher"); - await page.waitForSelector("#fetcher-state--idle"); - // We should not send a second request for this root stylesheet that's - // already been rendered in the DOM - let stylesheets = requests.filter( - (r) => r.type === "stylesheet" && /\/global-[a-z0-9]+\.css/i.test(r.url) - ); - expect(stylesheets.length).toBe(1); }); +}); - test("dedupes prefetch tags", async ({ page }) => { - fixture = await createFixture({ +// 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", () => { + // Generate the test app using the given prefetch mode + function fixtureFactory(mode: RemixLinkProps["prefetch"]): FixtureInit { + return { + config: { + future: { + unstable_singleFetch: true, + }, + }, files: { "app/root.tsx": js` import { @@ -458,8 +606,12 @@ test.describe("other scenarios", () => {

Root

@@ -470,99 +622,526 @@ test.describe("other scenarios", () => { } `, - "app/global.css": css` - .global-class { - background-color: gray; - color: black; - } - `, - - "app/local.css": css` - .local-class { - background-color: black; - color: white; - } - `, - "app/routes/_index.tsx": js` export default function() { return

Index

; } `, - "app/routes/with-nested-links.tsx": js` - import { Outlet } from "@remix-run/react"; - import globalCss from "../global.css"; - - export function links() { - return [ - // Same links as child route but with different key order - { - rel: "stylesheet", - href: globalCss, - }, - { - rel: "preload", - as: "image", - imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", - imageSizes: "9999px", - }, - ]; + "app/routes/with-loader.tsx": js` + export function loader() { + return { message: 'data from the loader' }; } export default function() { - return ; + return

With Loader

; } `, - "app/routes/with-nested-links.nested.tsx": js` - import globalCss from '../global.css'; - import localCss from '../local.css'; - - export function links() { - return [ - // Same links as parent route but with different key order - { - href: globalCss, - rel: "stylesheet", - }, - { - imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", - imageSizes: "9999px", - rel: "preload", - as: "image", - }, - // Unique links for child route - { - rel: "stylesheet", - href: localCss, - }, - { - rel: "preload", - as: "image", - imageSrcSet: "image-700.jpg 700w, image-1400.jpg 1400w", - imageSizes: "9999px", - }, - ]; - } + "app/routes/without-loader.tsx": js` export default function() { - return

With Nested Links

; + return

Without Loader

; } `, }, + }; + } + + test.describe("prefetch=none", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("none")); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); + }); + + test.describe("prefetch=render", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("render")); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("adds prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + // Both data and asset fetch for /with-loader + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='/with-loader.data']", + { state: "attached" } + ); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/with-loader-']", + { state: "attached" } + ); + // Only asset fetch for /without-loader + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/without-loader-']", + { state: "attached" } + ); + + // Ensure no other links in the #nav element + expect(await page.locator("#nav link").count()).toBe(3); + }); + }); + + test.describe("prefetch=intent (hover)", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("intent")); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("adds prefetch tags on hover", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='/with-loader']"); + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='/with-loader.data']", + { state: "attached" } + ); + // Check href prefix due to hashed filenames + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/with-loader-']", + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(2); + + await page.hover("a[href='/without-loader']"); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/without-loader-']", + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(1); + }); + + test("removes prefetch tags after navigating to/from the page", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Links added on hover + await page.hover("a[href='/with-loader']"); + await page.waitForSelector("#nav link", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(2); + + // Links removed upon navigating to the page + await page.click("a[href='/with-loader']"); + await page.waitForSelector("h2.with-loader", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(0); + + // Links stay removed upon navigating away from the page + await page.click("a[href='/without-loader']"); + await page.waitForSelector("h2.without-loader", { state: "attached" }); + expect(await page.locator("#nav link").count()).toBe(0); + }); + }); + + test.describe("prefetch=intent (focus)", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture(fixtureFactory("intent")); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("does not render prefetch tags during SSR", async ({ page }) => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("does not add prefetch tags on hydration", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("#nav link").count()).toBe(0); + }); + + test("adds prefetch tags on focus", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + // This click is needed to transfer focus to the main window, allowing + // subsequent focus events to fire + await page.click("body"); + await page.focus("a[href='/with-loader']"); + await page.waitForSelector( + "#nav link[rel='prefetch'][as='fetch'][href='/with-loader.data']", + { state: "attached" } + ); + // Check href prefix due to hashed filenames + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/with-loader-']", + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(2); + + await page.focus("a[href='/without-loader']"); + await page.waitForSelector( + "#nav link[rel='modulepreload'][href^='/build/routes/without-loader-']", + { state: "attached" } + ); + expect(await page.locator("#nav link").count()).toBe(1); + }); + }); + + test.describe("prefetch=viewport", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + + export default function Component() { + return ( + <> +

Index Page - Scroll Down

+
+ Click me! +
+ + ); + } + `, + + "app/routes/test.tsx": js` + export function loader() { + return null; + } + export default function Component() { + return

Test Page

; + } + `, + }, + }); + + // This creates an interactive app using puppeteer. + appFixture = await createAppFixture(fixture); }); - appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await page.hover("a[href='/with-nested-links/nested']"); - await page.waitForSelector("#nav link[rel='prefetch'][as='style']", { - state: "attached", + test.afterAll(() => { + appFixture.close(); + }); + + test("should prefetch when the link enters the viewport", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // No preloads to start + await expect(page.locator("div link")).toHaveCount(0); + + // Preloads render on scroll down + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + await page.waitForSelector( + "div link[rel='prefetch'][as='fetch'][href='/test.data']", + { state: "attached" } + ); + await page.waitForSelector( + "div link[rel='modulepreload'][href^='/build/routes/test-']", + { state: "attached" } + ); + + // Preloads removed on scroll up + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(page.locator("div link")).toHaveCount(0); + }); + }); + + test.describe("other scenarios", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture?.close(); + }); + + test("does not add prefetch links for stylesheets already in the DOM (active routes)", async ({ + page, + }) => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Scripts, useFetcher } from "@remix-run/react"; + import globalCss from "./global.css"; + + export function links() { + return [{ rel: "stylesheet", href: globalCss }]; + } + + export async function action() { + return null; + } + + export async function loader() { + return null; + } + + export default function Root() { + let fetcher = useFetcher(); + + return ( + + + + + + + +

{fetcher.state}

+ + + + ); + } + `, + + "app/global.css": ` + body { + background-color: black; + color: white; + } + `, + + "app/routes/_index.tsx": js` + export default function() { + return

Index

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + let requests: { type: string; url: string }[] = []; + + page.on("request", (req) => { + requests.push({ + type: req.resourceType(), + url: req.url(), + }); + }); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.click("#submit-fetcher"); + await page.waitForSelector("#fetcher-state--idle"); + // We should not send a second request for this root stylesheet that's + // already been rendered in the DOM + let stylesheets = requests.filter( + (r) => r.type === "stylesheet" && /\/global-[a-z0-9]+\.css/i.test(r.url) + ); + expect(stylesheets.length).toBe(1); + }); + + test("dedupes prefetch tags", async ({ page }) => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "@remix-run/react"; + + export default function Root() { + const styles = + 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + + 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; + + return ( + + + + + + + +

Root

+ + + + + + ); + } + `, + + "app/global.css": css` + .global-class { + background-color: gray; + color: black; + } + `, + + "app/local.css": css` + .local-class { + background-color: black; + color: white; + } + `, + + "app/routes/_index.tsx": js` + export default function() { + return

Index

; + } + `, + + "app/routes/with-nested-links.tsx": js` + import { Outlet } from "@remix-run/react"; + import globalCss from "../global.css"; + + export function links() { + return [ + // Same links as child route but with different key order + { + rel: "stylesheet", + href: globalCss, + }, + { + rel: "preload", + as: "image", + imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", + imageSizes: "9999px", + }, + ]; + } + export default function() { + return ; + } + `, + + "app/routes/with-nested-links.nested.tsx": js` + import globalCss from '../global.css'; + import localCss from '../local.css'; + + export function links() { + return [ + // Same links as parent route but with different key order + { + href: globalCss, + rel: "stylesheet", + }, + { + imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w", + imageSizes: "9999px", + rel: "preload", + as: "image", + }, + // Unique links for child route + { + rel: "stylesheet", + href: localCss, + }, + { + rel: "preload", + as: "image", + imageSrcSet: "image-700.jpg 700w, image-1400.jpg 1400w", + imageSizes: "9999px", + }, + ]; + } + export default function() { + return

With Nested Links

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='/with-nested-links/nested']"); + await page.waitForSelector("#nav link[rel='prefetch'][as='style']", { + state: "attached", + }); + expect( + await page.locator("#nav link[rel='prefetch'][as='style']").count() + ).toBe(2); + expect( + await page.locator("#nav link[rel='prefetch'][as='image']").count() + ).toBe(2); }); - expect( - await page.locator("#nav link[rel='prefetch'][as='style']").count() - ).toBe(2); - expect( - await page.locator("#nav link[rel='prefetch'][as='image']").count() - ).toBe(2); }); }); diff --git a/integration/redirects-test.ts b/integration/redirects-test.ts index 526eaf7ab33..e9723bb8709 100644 --- a/integration/redirects-test.ts +++ b/integration/redirects-test.ts @@ -144,3 +144,151 @@ test.describe("redirects", () => { 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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/absolute.tsx": js` + import * as React from 'react'; + import { Outlet } from "@remix-run/react"; + + export default function Component() { + let [count, setCount] = React.useState(0); + return ( + <> + + + + ); + } + `, + + "app/routes/absolute._index.tsx": js` + import { redirect } from "@remix-run/node"; + import { Form } from "@remix-run/react"; + + 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 "@remix-run/node"; + export const loader = () => { + return redirect("https://remix.run/"); + } + `, + + "app/routes/redirect-document.tsx": js` + import * as React from "react"; + import { Outlet } from "@remix-run/react"; + + 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 "@remix-run/react"; + + export default function Component() { + return Link + } + `, + + "app/routes/redirect-document.a.tsx": js` + import { redirectDocument } from "@remix-run/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"); + }); + }); +}); diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts index b593ce4b41c..92019b472a0 100644 --- a/integration/revalidate-test.ts +++ b/integration/revalidate-test.ts @@ -293,3 +293,298 @@ test.describe("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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts, useNavigation } from "@remix-run/react"; + + export default function Component() { + let navigation = useNavigation(); + return ( + + + + + + + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { json } from "@remix-run/node"; + import { Outlet, useLoaderData } from "@remix-run/react"; + + 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 "@remix-run/node"; + import { Form, useLoaderData, useRevalidator } from "@remix-run/react"; + + 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/set-cookie-revalidation-test.ts b/integration/set-cookie-revalidation-test.ts index 63327a005d5..01f6b2c0567 100644 --- a/integration/set-cookie-revalidation-test.ts +++ b/integration/set-cookie-revalidation-test.ts @@ -13,122 +13,255 @@ let appFixture: AppFixture; let BANNER_MESSAGE = "you do not have permission to view /protected"; -test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/session.server.ts": js` - import { createCookieSessionStorage } from "@remix-run/node"; - - export let MESSAGE_KEY = "message"; - - export let sessionStorage = createCookieSessionStorage({ - cookie: { - httpOnly: true, - path: "/", - sameSite: "lax", - secrets: ["cookie-secret"], +test.describe("set-cookie revalidation", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/session.server.ts": js` + import { createCookieSessionStorage } from "@remix-run/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 "@remix-run/node"; + import { + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "@remix-run/react"; + + 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 "@remix-run/react"; + + export default function Index() { + return ( +

+ protected +

+ ); } - }) - `, - - "app/root.tsx": js` - import { json } from "@remix-run/node"; - import { - Links, - Meta, - Outlet, - Scripts, - useLoaderData, - } from "@remix-run/react"; - - 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 "@remix-run/react"; - - 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 "@remix-run/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

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

login

; + } + `, + + "app/routes/protected.tsx": js` + import { redirect } from "@remix-run/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); }); - // This creates an interactive app using playwright. - appFixture = await createAppFixture(fixture); -}); + test.afterAll(() => { + appFixture.close(); + }); -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); + }); }); -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({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/session.server.ts": js` + import { createCookieSessionStorage } from "@remix-run/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 "@remix-run/node"; + import { + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "@remix-run/react"; + + 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 "@remix-run/react"; + + 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 "@remix-run/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); + }); + }); }); diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts new file mode 100644 index 00000000000..650f721d7dd --- /dev/null +++ b/integration/single-fetch-test.ts @@ -0,0 +1,466 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { ServerMode } from "../build/node_modules/@remix-run/server-runtime/dist/mode.js"; + +const ISO_DATE = "2024-03-12T12:00:00.000Z"; + +const files = { + "app/root.tsx": js` + import { Form, Link, Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export function loader() { + return { + message: "ROOT", + }; + } + + export default function Root() { + return ( + + + + + + + Home
+ Data
+
+ +
+ + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + + "app/routes/data.tsx": js` + import { useActionData, useLoaderData } from "@remix-run/react"; + + export async function action({ request }) { + let formData = await request.formData(); + return { + key: formData.get('key'), + }; + } + + export function loader({ request }) { + if (new URL(request.url).searchParams.has("error")) { + throw new Error("Loader Error"); + } + return { + message: "DATA", + date: new Date("${ISO_DATE}"), + }; + } + + export default function Index() { + let data = useLoaderData(); + let actionData = useActionData(); + return ( + <> +

Data

+

{data.message}

+

{data.date.toISOString()}

+ {actionData ?

{actionData.key}

: null} + + ) + } + `, +}; + +test.describe("single-fetch", () => { + test("loads proper data on single fetch loader requests", async () => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files, + }, + ServerMode.Development + ); + let res = await fixture.requestSingleFetchData("/_root.data"); + expect(res.data).toEqual({ + root: { + data: { + message: "ROOT", + }, + }, + "routes/_index": { + data: null, + }, + }); + + res = await fixture.requestSingleFetchData("/data.data"); + expect(res.data).toEqual({ + root: { + data: { + message: "ROOT", + }, + }, + "routes/data": { + data: { + message: "DATA", + date: new Date(ISO_DATE), + }, + }, + }); + }); + + test("loads proper errors on single fetch loader requests", async () => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files, + }, + ServerMode.Development + ); + + let res = await fixture.requestSingleFetchData("/data.data?error=true"); + expect(res.data).toEqual({ + root: { + data: { + message: "ROOT", + }, + }, + "routes/data": { + error: new Error("Loader Error"), + }, + }); + }); + + test("loads proper data on single fetch action requests", async () => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files, + }, + ServerMode.Development + ); + let postBody = new URLSearchParams(); + postBody.set("key", "value"); + let res = await fixture.requestSingleFetchData("/data.data", { + method: "post", + body: postBody, + }); + expect(res.data).toEqual({ + data: { + key: "value", + }, + }); + }); + + test("loads proper data on document request", async ({ page }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/data"); + expect(await app.getHtml("#heading")).toContain("Data"); + expect(await app.getHtml("#message")).toContain("DATA"); + expect(await app.getHtml("#date")).toContain(ISO_DATE); + }); + + test("loads proper data on client side navigation", async ({ page }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/data"); + await page.waitForSelector("#message"); + expect(await app.getHtml("#heading")).toContain("Data"); + expect(await app.getHtml("#message")).toContain("DATA"); + expect(await app.getHtml("#date")).toContain(ISO_DATE); + }); + + test("loads proper data on client side action navigation", async ({ + page, + }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton("/data"); + await page.waitForSelector("#message"); + expect(await app.getHtml("#heading")).toContain("Data"); + expect(await app.getHtml("#message")).toContain("DATA"); + expect(await app.getHtml("#date")).toContain(ISO_DATE); + expect(await app.getHtml("#action-data")).toContain("value"); + }); + + test("allows fine-grained revalidation", async ({ page }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/no-revalidate.tsx": js` + import { Form, useActionData, useLoaderData, useNavigation } from '@remix-run/react'; + + export async function action({ request }) { + let fd = await request.formData(); + return { shouldRevalidate: fd.get('revalidate') === "yes" } + } + + let count = 0; + export function loader() { + return { count: ++count }; + } + + export default function Comp() { + let navigation = useNavigation(); + let data = useLoaderData(); + let actionData = useActionData(); + return ( +
+ + +

{data.count}

+ {navigation.state === "idle" ?

idle

: null} + {actionData ?

yes

: null} +
+ ); + } + + export function shouldRevalidate({ actionResult }) { + return actionResult.shouldRevalidate === true; + } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/no-revalidate"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="revalidate"][value="yes"]'); + await page.waitForSelector("#action-data"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#data")).toContain("2"); + expect(urls).toEqual([expect.stringMatching(/\/no-revalidate\.data$/)]); + + await page.click('button[name="revalidate"][value="no"]'); + await page.waitForSelector("#action-data"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#data")).toContain("2"); + expect(urls).toEqual([ + expect.stringMatching(/\/no-revalidate\.data$/), + expect.stringMatching(/\/no-revalidate\.data\?_routes=root$/), + ]); + }); + + test("does not revalidate on 4xx/5xx action responses", async ({ page }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/action.tsx": js` + import { Form, Link, useActionData, useLoaderData, useNavigation } from '@remix-run/react'; + + export async function action({ request }) { + let fd = await request.formData(); + if (fd.get('throw') === "5xx") { + throw new Response("Thrown 500", { status: 500 }); + } + if (fd.get('throw') === "4xx") { + throw new Response("Thrown 400", { status: 400 }); + } + if (fd.get('return') === "5xx") { + return new Response("Returned 500", { status: 500 }); + } + if (fd.get('return') === "4xx") { + return new Response("Returned 400", { status: 400 }); + } + return null; + } + + let count = 0; + export function loader() { + return { count: ++count }; + } + + export default function Comp() { + let navigation = useNavigation(); + let data = useLoaderData(); + return ( +
+ + + + +

{data.count}

+ {navigation.state === "idle" ?

idle

: null} +
+ ); + } + + export function ErrorBoundary() { + return ( +
+

Error

+ Back +
+ ); + } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="return"][value="5xx"]'); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="return"][value="4xx"]'); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="throw"][value="5xx"]'); + await page.waitForSelector("#error"); + expect(urls).toEqual([]); + await app.clickLink("/action"); + await page.waitForSelector("#data"); + expect(await app.getHtml("#data")).toContain("2"); + urls = []; + + await page.click('button[name="throw"][value="4xx"]'); + await page.waitForSelector("#error"); + expect(urls).toEqual([]); + }); + + test("returns loader headers through the headers function", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/headers.tsx": js` + export function headers({ loaderHeaders }) { + let headers = new Headers(loaderHeaders); + headers.set('x-headers-function', 'true') + return headers; + } + + export function action({ request }) { + if (new URL(request.url).searchParams.has("error")) { + throw new Response(null, { headers: { "x-action-error": "true" } }); + } + return new Response(null, { headers: { "x-action": "true" } }); + } + + export function loader({ request }) { + if (new URL(request.url).searchParams.has("error")) { + throw new Response(null, { headers: { "x-loader-error": "true" } }); + } + return new Response(null, { headers: { "x-loader": "true" } }); + } + + export default function Comp() { + return null; + } + `, + }, + }); + + let res = await fixture.requestSingleFetchData("/headers.data"); + expect(res.headers.get("x-loader")).toEqual("true"); + expect(res.headers.get("x-headers-function")).toEqual("true"); + + res = await fixture.requestSingleFetchData("/headers.data", { + method: "post", + body: null, + }); + expect(res.headers.get("x-action")).toEqual("true"); + expect(res.headers.get("x-headers-function")).toEqual(null); + + res = await fixture.requestSingleFetchData("/headers.data?error"); + expect(res.headers.get("x-loader-error")).toEqual("true"); + expect(res.headers.get("x-headers-function")).toEqual("true"); + + res = await fixture.requestSingleFetchData("/headers.data?error", { + method: "post", + body: null, + }); + expect(res.headers.get("x-action-error")).toEqual("true"); + expect(res.headers.get("x-headers-function")).toEqual(null); + }); +}); diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index e381287703b..d7d6adc87ac 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -833,7 +833,7 @@ test.describe("SPA Mode", () => { test("hydrates a proper useId value", async ({ page }) => { // SSR'd useId value we can assert against pre- and post-hydration - let USE_ID_VALUE = ":R1:"; + let USE_ID_VALUE = ":R5:"; // Ensure we SSR a proper useId value let res = await fixture.requestDocument("/"); diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index 8da38527760..b5212618150 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -36,6 +36,7 @@ describe("readConfig", () => { "entryServerFile": "entry.server.tsx", "entryServerFilePath": Any, "future": { + "unstable_singleFetch": false, "v3_fetcherPersist": false, "v3_relativeSplatPath": false, "v3_throwAbortReason": false, diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 45a3920caad..ac576095f40 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -37,6 +37,7 @@ interface FutureConfig { v3_fetcherPersist: boolean; v3_relativeSplatPath: boolean; v3_throwAbortReason: boolean; + unstable_singleFetch: boolean; } type NodeBuiltinsPolyfillOptions = Pick< @@ -600,6 +601,7 @@ export async function resolveConfig( v3_fetcherPersist: appConfig.future?.v3_fetcherPersist === true, v3_relativeSplatPath: appConfig.future?.v3_relativeSplatPath === true, v3_throwAbortReason: appConfig.future?.v3_throwAbortReason === true, + unstable_singleFetch: appConfig.future?.unstable_singleFetch === true, }; if (appConfig.future) { diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index c49297da86a..d6c59042c93 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -32,7 +32,7 @@ "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", "@remix-run/node": "workspace:*", - "@remix-run/router": "1.15.3", + "@remix-run/router": "0.0.0-experimental-c7dd3d3a", "@remix-run/server-runtime": "workspace:*", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", diff --git a/packages/remix-react/__tests__/components-test.tsx b/packages/remix-react/__tests__/components-test.tsx index 862893ac609..f0a30a3a1b1 100644 --- a/packages/remix-react/__tests__/components-test.tsx +++ b/packages/remix-react/__tests__/components-test.tsx @@ -115,6 +115,7 @@ function itPrefetchesPageLinks< url: "", version: "", }, + future: {}, }; beforeEach(() => { diff --git a/packages/remix-react/__tests__/deferred-scripts-test.tsx b/packages/remix-react/__tests__/deferred-scripts-test.tsx index e415bffbe72..5b29656446f 100644 --- a/packages/remix-react/__tests__/deferred-scripts-test.tsx +++ b/packages/remix-react/__tests__/deferred-scripts-test.tsx @@ -28,6 +28,12 @@ import "@testing-library/jest-dom/extend-expect"; describe(" with activeDeferreds", () => { it("should pass custom props", () => { let context: EntryContext = { + future: { + v3_throwAbortReason: false, + v3_fetcherPersist: false, + v3_relativeSplatPath: false, + unstable_singleFetch: false, + }, routeModules: { root: { default: () => null } }, manifest: { routes: { diff --git a/packages/remix-react/__tests__/exports-test.tsx b/packages/remix-react/__tests__/exports-test.tsx index ae912a87fb5..9144288dad7 100644 --- a/packages/remix-react/__tests__/exports-test.tsx +++ b/packages/remix-react/__tests__/exports-test.tsx @@ -17,6 +17,7 @@ let nonReExportedKeys = new Set([ "unstable_HistoryRouter", "UNSAFE_DataRouterContext", "UNSAFE_DataRouterStateContext", + "UNSAFE_ErrorResponseImpl", "UNSAFE_FetchersContext", "UNSAFE_LocationContext", "UNSAFE_NavigationContext", diff --git a/packages/remix-react/__tests__/scroll-restoration-test.tsx b/packages/remix-react/__tests__/scroll-restoration-test.tsx index c7b7a72ee0d..cc4ba35622c 100644 --- a/packages/remix-react/__tests__/scroll-restoration-test.tsx +++ b/packages/remix-react/__tests__/scroll-restoration-test.tsx @@ -29,6 +29,11 @@ describe("", () => { }); let context: RemixContextObject = { + future: { + v3_fetcherPersist: false, + v3_relativeSplatPath: false, + unstable_singleFetch: false, + }, routeModules: { root: { default: () => null } }, manifest: { routes: { diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 357edc655d9..c7a8efee7f2 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -1,16 +1,12 @@ -import { - createBrowserHistory, - createRouter, - type HydrationState, - type Router, -} from "@remix-run/router"; +import type { HydrationState, Router } from "@remix-run/router"; +import { createBrowserHistory, createRouter } from "@remix-run/router"; import type { ReactElement } from "react"; import * as React from "react"; import { UNSAFE_mapRouteProperties as mapRouteProperties } from "react-router"; import { matchRoutes, RouterProvider } from "react-router-dom"; import { RemixContext } from "./components"; -import type { EntryContext, FutureConfig } from "./entry"; +import type { AssetsManifest, FutureConfig } from "./entry"; import { RemixErrorBoundary } from "./errorBoundaries"; import { deserializeErrors } from "./errors"; import type { RouteModules } from "./routeModules"; @@ -19,6 +15,11 @@ import { createClientRoutesWithHMRRevalidationOptOut, shouldHydrateRouteLoader, } from "./routes"; +import { + decodeViaTurboStream, + getSingleFetchDataStrategy, +} from "./single-fetch"; +import invariant from "./invariant"; /* eslint-disable prefer-let/prefer-let */ declare global { @@ -29,6 +30,8 @@ declare global { criticalCss?: string; future: FutureConfig; isSpaMode: boolean; + stream: ReadableStream | undefined; + streamController: ReadableStreamDefaultController; // The number of active deferred keys rendered on the server a?: number; dev?: { @@ -38,7 +41,7 @@ declare global { }; var __remixRouter: Router; var __remixRouteModules: RouteModules; - var __remixManifest: EntryContext["manifest"]; + var __remixManifest: AssetsManifest; var __remixRevalidation: number | undefined; var __remixClearCriticalCss: (() => void) | undefined; var $RefreshRuntime$: { @@ -49,6 +52,12 @@ declare global { export interface RemixBrowserProps {} +let stateDecodingPromise: + | (Promise & { + value?: unknown; + error?: unknown; + }) + | undefined; let router: Router; let routerInitialized = false; let hmrAbortController: AbortController | undefined; @@ -75,7 +84,7 @@ if (import.meta && import.meta.hot) { assetsManifest, needsRevalidation, }: { - assetsManifest: EntryContext["manifest"]; + assetsManifest: AssetsManifest; needsRevalidation: Set; }) => { let router = await hmrRouterReadyPromise; @@ -207,6 +216,34 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { return <>; } + // When single fetch is enabled, we need to suspend until the initial state + // snapshot is decoded into window.__remixContext.state + if (window.__remixContext.future.unstable_singleFetch) { + // 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 (!stateDecodingPromise) { + let stream = window.__remixContext.stream; + invariant(stream, "No stream found for single fetch decoding"); + window.__remixContext.stream = undefined; + stateDecodingPromise = decodeViaTurboStream(stream, window) + .then((value) => { + window.__remixContext.state = + value.value as typeof window.__remixContext.state; + stateDecodingPromise!.value = true; + }) + .catch((e) => { + stateDecodingPromise!.error = e; + }); + } + if (stateDecodingPromise.error) { + throw stateDecodingPromise.error; + } + if (!stateDecodingPromise.value) { + throw stateDecodingPromise; + } + } + let routes = createClientRoutes( window.__remixManifest.routes, window.__remixRouteModules, @@ -275,9 +312,18 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { v7_partialHydration: true, v7_prependBasename: true, v7_relativeSplatPath: window.__remixContext.future.v3_relativeSplatPath, + // Single fetch enables this underlying behavior + unstable_skipActionErrorRevalidation: + window.__remixContext.future.unstable_singleFetch === true, }, hydrationData, mapRouteProperties, + unstable_dataStrategy: window.__remixContext.future.unstable_singleFetch + ? getSingleFetchDataStrategy( + window.__remixManifest, + window.__remixRouteModules + ) + : undefined, }); // We can call initialize() immediately if the router doesn't have any @@ -340,22 +386,31 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { // Then we need a stateful location here so the user can back-button navigate // out of there return ( - - - - - + // This fragment is important to ensure we match the JSX + // structure so that useId values hydrate correctly + <> + + + + + + {/* + This fragment is important to ensure we match the JSX + structure so that useId values hydrate correctly + */} + {window.__remixContext.future.unstable_singleFetch ? <> : null} + ); } diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index b93baee5bb7..646ea39d435 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -55,6 +55,7 @@ import type { MetaMatches, RouteHandle, } from "./routeModules"; +import { addRevalidationParam, singleFetchUrl } from "./single-fetch"; function useDataRouterContext() { let context = React.useContext(DataRouterContext); @@ -283,7 +284,7 @@ function getActiveMatches( } if (errors) { - let errorIdx = matches.findIndex((m) => errors[m.route.id]); + let errorIdx = matches.findIndex((m) => errors[m.route.id] !== undefined); return matches.slice(0, errorIdx + 1); } @@ -385,7 +386,7 @@ function PrefetchPageLinksImpl({ matches: AgnosticDataRouteMatch[]; }) { let location = useLocation(); - let { manifest } = useRemixContext(); + let { future, manifest, routeModules } = useRemixContext(); let { matches } = useDataRouterStateContext(); let newMatchesForData = React.useMemo( @@ -428,11 +429,39 @@ function PrefetchPageLinksImpl({ // just the manifest like the other links in here. let keyedPrefetchLinks = useKeyedPrefetchLinks(newMatchesForAssets); + let singleFetchHref: string | undefined; + if (future.unstable_singleFetch && newMatchesForData.length > 0) { + let url = addRevalidationParam( + manifest, + routeModules, + matches.map((m) => m.route), + newMatchesForData.map((m) => m.route), + singleFetchUrl(page) + ); + singleFetchHref = url.pathname + url.search; + } + return ( <> - {dataHrefs.map((href) => ( - - ))} + {singleFetchHref ? ( + + ) : ( + dataHrefs.map((href) => ( + + )) + )} {moduleHrefs.map((href) => ( ))} @@ -622,12 +651,25 @@ export type ScriptProps = Omit< * @see https://remix.run/components/scripts */ export function Scripts(props: ScriptProps) { - let { manifest, serverHandoffString, abortDelay, serializeError, isSpaMode } = - useRemixContext(); + let { + manifest, + serverHandoffString, + abortDelay, + serializeError, + isSpaMode, + future, + renderMeta, + } = useRemixContext(); let { router, static: isStatic, staticContext } = useDataRouterContext(); let { matches: routerMatches } = useDataRouterStateContext(); let navigation = useNavigation(); + // Let know that we hydrated and we should render the single + // fetch streaming scripts + if (renderMeta) { + renderMeta.didRenderScripts = true; + } + let matches = getActiveMatches(routerMatches, null, isSpaMode); React.useEffect(() => { @@ -688,11 +730,24 @@ export function Scripts(props: ScriptProps) { 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 contextScript = staticContext - ? `window.__remixContext = ${serverHandoffString};` + ? `window.__remixContext = ${serverHandoffString};${streamScript}` : " "; - let activeDeferreds = staticContext?.activeDeferreds; + // 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. diff --git a/packages/remix-react/data.ts b/packages/remix-react/data.ts index eb4d4c5f520..644248bd300 100644 --- a/packages/remix-react/data.ts +++ b/packages/remix-react/data.ts @@ -20,7 +20,7 @@ export function isNetworkErrorResponse(response: any): response is Response { // If we reach the Remix server, we can safely identify response types via the // X-Remix-Error/X-Remix-Catch headers. However, if we never reach the Remix // server, and instead receive a 4xx/5xx from somewhere in between (like - // Cloudflare), then we get a false negative n the isErrorResponse check and + // Cloudflare), then we get a false negative in the isErrorResponse check and // we incorrectly assume that the user returns the 4xx/5xx response and // consider it successful. To alleviate this, we add X-Remix-Response to any // non-Error/non-Catch responses coming back from the server. If we don't @@ -73,37 +73,13 @@ export async function fetchData( let url = new URL(request.url); url.searchParams.set("_data", routeId); - let init: RequestInit = { signal: request.signal }; - - if (request.method !== "GET") { - init.method = request.method; - - let contentType = request.headers.get("Content-Type"); - - // Check between word boundaries instead of startsWith() due to the last - // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type - if (contentType && /\bapplication\/json\b/.test(contentType)) { - init.headers = { "Content-Type": contentType }; - init.body = JSON.stringify(await request.json()); - } else if (contentType && /\btext\/plain\b/.test(contentType)) { - init.headers = { "Content-Type": contentType }; - init.body = await request.text(); - } else if ( - contentType && - /\bapplication\/x-www-form-urlencoded\b/.test(contentType) - ) { - init.body = new URLSearchParams(await request.text()); - } else { - init.body = await request.formData(); - } - } - if (retry > 0) { // Retry up to 3 times waiting 50, 250, 1250 ms // between retries for a total of 1550 ms before giving up. await new Promise((resolve) => setTimeout(resolve, 5 ** retry * 10)); } + let init = await createRequestInit(request); let revalidation = window.__remixRevalidation; let response = await fetch(url.href, init).catch((error) => { if ( @@ -134,6 +110,37 @@ export async function fetchData( return response; } +export async function createRequestInit( + request: Request +): Promise { + let init: RequestInit = { signal: request.signal }; + + if (request.method !== "GET") { + init.method = request.method; + + let contentType = request.headers.get("Content-Type"); + + // Check between word boundaries instead of startsWith() due to the last + // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type + if (contentType && /\bapplication\/json\b/.test(contentType)) { + init.headers = { "Content-Type": contentType }; + init.body = JSON.stringify(await request.json()); + } else if (contentType && /\btext\/plain\b/.test(contentType)) { + init.headers = { "Content-Type": contentType }; + init.body = await request.text(); + } else if ( + contentType && + /\bapplication\/x-www-form-urlencoded\b/.test(contentType) + ) { + init.body = new URLSearchParams(await request.text()); + } else { + init.body = await request.formData(); + } + } + + return init; +} + const DEFERRED_VALUE_PLACEHOLDER_PREFIX = "__deferred_promise:"; export async function parseDeferredReadableStream( stream: ReadableStream diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts index a3366cc451d..d1e9c388892 100644 --- a/packages/remix-react/entry.ts +++ b/packages/remix-react/entry.ts @@ -18,17 +18,32 @@ export interface RemixContextObject { isSpaMode: boolean; abortDelay?: number; serializeError?(error: Error): SerializedError; + renderMeta?: { + didRenderScripts?: boolean; + streamCache?: Record< + number, + Promise & { + result?: { + done: boolean; + value: string; + }; + error?: unknown; + } + >; + }; } // Additional React-Router information needed at runtime, but not hydrated // through RemixContext export interface EntryContext extends RemixContextObject { staticHandlerContext: StaticHandlerContext; + serverHandoffStream?: ReadableStream; } export interface FutureConfig { v3_fetcherPersist: boolean; v3_relativeSplatPath: boolean; + unstable_singleFetch: boolean; } export interface AssetsManifest { diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index de6eb051c36..be37161acaa 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -19,10 +19,11 @@ "tsc": "tsc" }, "dependencies": { - "@remix-run/router": "1.15.3", + "@remix-run/router": "0.0.0-experimental-c7dd3d3a", "@remix-run/server-runtime": "workspace:*", - "react-router": "6.22.3", - "react-router-dom": "6.22.3" + "react-router": "0.0.0-experimental-c7dd3d3a", + "react-router-dom": "0.0.0-experimental-c7dd3d3a", + "turbo-stream": "^2.0.0" }, "devDependencies": { "@remix-run/node": "workspace:*", diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index 4f99172bb32..3b792993075 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -249,16 +249,40 @@ export function createClientRoutes( return (routesByParentId[parentId] || []).map((route) => { let routeModule = routeModulesCache[route.id]; - async function fetchServerLoader(request: Request) { - if (!route.hasLoader) return null; - return fetchServerHandler(request, route); + // Fetch data from the server either via single fetch or the standard `?_data` + // request. Unwrap it when called via `serverLoader`/`serverAction` in a + // client handler, otherwise return the raw response for the router to unwrap + async function fetchServerHandlerAndMaybeUnwrap( + request: Request, + unwrap: boolean, + singleFetch: unknown + ) { + if (typeof singleFetch === "function") { + let result = await singleFetch(); + return result; + } + let result = await fetchServerHandler(request, route); + return unwrap ? unwrapServerResponse(result) : result; + } + + function fetchServerLoader( + request: Request, + unwrap: boolean, + singleFetch: unknown + ) { + if (!route.hasLoader) return Promise.resolve(null); + return fetchServerHandlerAndMaybeUnwrap(request, unwrap, singleFetch); } - async function fetchServerAction(request: Request) { + function fetchServerAction( + request: Request, + unwrap: boolean, + singleFetch: unknown + ) { if (!route.hasAction) { throw noActionDefinedError("action", route.id); } - return fetchServerHandler(request, route); + return fetchServerHandlerAndMaybeUnwrap(request, unwrap, singleFetch); } async function prefetchStylesAndCallHandler( @@ -306,7 +330,10 @@ export function createClientRoutes( needsRevalidation == null && (routeModule.clientLoader?.hydrate === true || !route.hasLoader); - dataRoute.loader = async ({ request, params }: LoaderFunctionArgs) => { + dataRoute.loader = async ( + { request, params }: LoaderFunctionArgs, + singleFetch?: unknown + ) => { try { let result = await prefetchStylesAndCallHandler(async () => { invariant( @@ -316,7 +343,7 @@ export function createClientRoutes( if (!routeModule.clientLoader) { if (isSpaMode) return null; // Call the server when no client loader exists - return fetchServerLoader(request); + return fetchServerLoader(request, false, singleFetch); } return routeModule.clientLoader({ @@ -334,9 +361,7 @@ export function createClientRoutes( } // Call the server loader for client-side navigations - let result = await fetchServerLoader(request); - let unwrapped = await unwrapServerResponse(result); - return unwrapped; + return fetchServerLoader(request, true, singleFetch); }, }); }); @@ -355,7 +380,10 @@ export function createClientRoutes( isSpaMode ); - dataRoute.action = ({ request, params }: ActionFunctionArgs) => { + dataRoute.action = ( + { request, params }: ActionFunctionArgs, + singleFetch?: unknown + ) => { return prefetchStylesAndCallHandler(async () => { invariant( routeModule, @@ -365,7 +393,7 @@ export function createClientRoutes( if (isSpaMode) { throw noActionDefinedError("clientAction", route.id); } - return fetchServerAction(request); + return fetchServerAction(request, false, singleFetch); } return routeModule.clientAction({ @@ -373,9 +401,7 @@ export function createClientRoutes( params, async serverAction() { preventInvalidServerHandlerCall("action", route, isSpaMode); - let result = await fetchServerAction(request); - let unwrapped = await unwrapServerResponse(result); - return unwrapped; + return fetchServerAction(request, true, singleFetch); }, }); }); @@ -385,19 +411,25 @@ export function createClientRoutes( // the server loader/action in parallel with the module load so we add // loader/action as static props on the route if (!route.hasClientLoader) { - dataRoute.loader = ({ request }: LoaderFunctionArgs) => + dataRoute.loader = ( + { request }: LoaderFunctionArgs, + singleFetch?: unknown + ) => prefetchStylesAndCallHandler(() => { if (isSpaMode) return Promise.resolve(null); - return fetchServerLoader(request); + return fetchServerLoader(request, false, singleFetch); }); } if (!route.hasClientAction) { - dataRoute.action = ({ request }: ActionFunctionArgs) => + dataRoute.action = ( + { request }: ActionFunctionArgs, + singleFetch?: unknown + ) => prefetchStylesAndCallHandler(() => { if (isSpaMode) { throw noActionDefinedError("clientAction", route.id); } - return fetchServerAction(request); + return fetchServerAction(request, false, singleFetch); }); } @@ -411,28 +443,30 @@ export function createClientRoutes( let lazyRoute: Partial = { ...mod }; if (mod.clientLoader) { let clientLoader = mod.clientLoader; - lazyRoute.loader = (args) => + lazyRoute.loader = ( + args: LoaderFunctionArgs, + singleFetch?: unknown + ) => clientLoader({ ...args, async serverLoader() { preventInvalidServerHandlerCall("loader", route, isSpaMode); - let response = await fetchServerLoader(args.request); - let result = await unwrapServerResponse(response); - return result; + return fetchServerLoader(args.request, true, singleFetch); }, }); } if (mod.clientAction) { let clientAction = mod.clientAction; - lazyRoute.action = (args) => + lazyRoute.action = ( + args: ActionFunctionArgs, + singleFetch?: unknown + ) => clientAction({ ...args, async serverAction() { preventInvalidServerHandlerCall("action", route, isSpaMode); - let response = await fetchServerAction(args.request); - let result = await unwrapServerResponse(response); - return result; + return fetchServerAction(args.request, true, singleFetch); }, }); } diff --git a/packages/remix-react/server.tsx b/packages/remix-react/server.tsx index 08630ec5bed..c5fefed4d86 100644 --- a/packages/remix-react/server.tsx +++ b/packages/remix-react/server.tsx @@ -9,6 +9,7 @@ import { RemixContext } from "./components"; import type { EntryContext } from "./entry"; import { RemixErrorBoundary } from "./errorBoundaries"; import { createServerRoutes, shouldHydrateRouteLoader } from "./routes"; +import { StreamTransfer } from "./single-fetch"; export interface RemixServerProps { context: EntryContext; @@ -71,25 +72,38 @@ export function RemixServer({ }); return ( - - - - - + <> + + + + + + {context.future.unstable_singleFetch && context.serverHandoffStream ? ( + + + + ) : null} + ); } diff --git a/packages/remix-react/single-fetch.tsx b/packages/remix-react/single-fetch.tsx new file mode 100644 index 00000000000..a016bf190c6 --- /dev/null +++ b/packages/remix-react/single-fetch.tsx @@ -0,0 +1,336 @@ +import * as React from "react"; +import type { + unstable_DataStrategyFunction as DataStrategyFunction, + unstable_HandlerResult as HandlerResult, +} from "@remix-run/router"; +import { + UNSAFE_ErrorResponseImpl as ErrorResponseImpl, + redirect, +} from "@remix-run/router"; +import type { + UNSAFE_SingleFetchResult as SingleFetchResult, + UNSAFE_SingleFetchResults as SingleFetchResults, +} from "@remix-run/server-runtime"; +import { UNSAFE_SingleFetchRedirectSymbol as SingleFetchRedirectSymbol } from "@remix-run/server-runtime"; +import type { + DataRouteObject, + unstable_DataStrategyFunctionArgs as DataStrategyFunctionArgs, +} from "react-router-dom"; +import { decode } from "turbo-stream"; + +import { createRequestInit } from "./data"; +import type { AssetsManifest, EntryContext } from "./entry"; +import type { RouteModules } from "./routeModules"; +import invariant from "./invariant"; + +interface StreamTransferProps { + context: EntryContext; + identifier: number; + reader: ReadableStreamDefaultReader; + textDecoder: TextDecoder; +} + +// StreamTransfer recursively renders down chunks of the `serverHandoffStream` +// into the client-side `streamController` +export function StreamTransfer({ + context, + identifier, + reader, + textDecoder, +}: StreamTransferProps) { + // If the user didn't render the component then we don't have to + // bother streaming anything in + if (!context.renderMeta || !context.renderMeta.didRenderScripts) { + return null; + } + + if (!context.renderMeta.streamCache) { + context.renderMeta.streamCache = {}; + } + let { streamCache } = context.renderMeta; + let promise = streamCache[identifier]; + if (!promise) { + promise = streamCache[identifier] = reader + .read() + .then((result) => { + streamCache[identifier].result = { + done: result.done, + value: textDecoder.decode(result.value, { stream: true }), + }; + }) + .catch((e) => { + streamCache[identifier].error = e; + }); + } + + if (promise.error) { + throw promise.error; + } + if (promise.result === undefined) { + throw promise; + } + + let { done, value } = promise.result; + let scriptTag = value ? ( +