diff --git a/.changeset/brave-cars-approve.md b/.changeset/brave-cars-approve.md new file mode 100644 index 00000000000..a8c3700ef6d --- /dev/null +++ b/.changeset/brave-cars-approve.md @@ -0,0 +1,5 @@ +--- +"@remix-run/node": minor +--- + +Use undici as our fetch polyfill going forward. #9106 diff --git a/.changeset/odd-frogs-attend.md b/.changeset/odd-frogs-attend.md new file mode 100644 index 00000000000..18907021cc2 --- /dev/null +++ b/.changeset/odd-frogs-attend.md @@ -0,0 +1,5 @@ +--- +"@remix-run/server-runtime": patch +--- + +handle net new redirects created by handleDataRequest diff --git a/.changeset/shy-buttons-buy.md b/.changeset/shy-buttons-buy.md new file mode 100644 index 00000000000..5ef85720043 --- /dev/null +++ b/.changeset/shy-buttons-buy.md @@ -0,0 +1,6 @@ +--- +"remix": patch +"@remix-run/react": patch +--- + +fix: escape single fetch transfer diff --git a/.changeset/single-fetch-client-loaders.md b/.changeset/single-fetch-client-loaders.md new file mode 100644 index 00000000000..a4933ed4f5d --- /dev/null +++ b/.changeset/single-fetch-client-loaders.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +Update single fetch implementation to avoid over-fetching when clientLoader's exist diff --git a/.changeset/single-fetch-spa-mode.md b/.changeset/single-fetch-spa-mode.md new file mode 100644 index 00000000000..1cab92c1147 --- /dev/null +++ b/.changeset/single-fetch-spa-mode.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Fix SPA mode when single fetch is enabled by using streaming entry.server diff --git a/.changeset/single-fetch.md b/.changeset/single-fetch.md new file mode 100644 index 00000000000..3e7a38c3069 --- /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/.changeset/spicy-ghosts-watch.md b/.changeset/spicy-ghosts-watch.md new file mode 100644 index 00000000000..8aca8ce40b0 --- /dev/null +++ b/.changeset/spicy-ghosts-watch.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Vite: added sourcemap support for transformed routes diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a07dcca2e2e..1744b3cf68d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -49,10 +49,7 @@ jobs: - name: ๐Ÿ•ต๏ธ Check for changes id: version run: | - # get latest commit sha - SHA=$(git rev-parse HEAD) - # get first 7 characters of sha - SHORT_SHA=${SHA::7} + SHORT_SHA=$(git rev-parse --short HEAD) # get latest nightly tag LATEST_NIGHTLY_TAG=$(git tag -l v0.0.0-nightly-\* --sort=-creatordate | head -n 1) diff --git a/.github/workflows/release-experimental.yml b/.github/workflows/release-experimental.yml index a4e7c9e2253..b165cd722f2 100644 --- a/.github/workflows/release-experimental.yml +++ b/.github/workflows/release-experimental.yml @@ -1,32 +1,10 @@ -# Experimental releases are handled a bit differently than standard releases. -# Experimental releases can be branched from anywhere as they are not intended -# for general use, and all packages will be versioned and published with the -# same hash for testing. -# -# This workflow will run when a GitHub release is created from experimental -# version tag. Unlike standard releases created via Changesets, only one tag -# should be created for all packages. -# -# To create a release: -# - Create a new branch for the release: git checkout -b `release-experimental` -# - IMPORTANT: You should always create a new branch so that the version -# changes don't accidentally get merged into `dev` or `main`. The branch -# name must follow the convention of `release-experimental` or -# `release-experimental-[feature]`. -# - Make whatever changes you need and commit them: -# - `git add . && git commit "experimental changes!"` -# - Update version numbers and create a release tag: -# - `pnpm run version:experimental` -# - Push to GitHub: -# - `git push origin --follow-tags` -# - Create a new release for the tag on GitHub to trigger the CI workflow that -# will publish the release to npm +name: ๐Ÿงช Experimental Release -name: ๐Ÿš€ Release (experimental) on: - push: - tags: - - "v0.0.0-experimental*" + workflow_dispatch: + inputs: + branch: + required: true concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -34,16 +12,17 @@ env: CI: true jobs: - release: - name: ๐Ÿง‘โ€๐Ÿ”ฌ Experimental Release - if: | - github.repository == 'remix-run/remix' && - contains(github.ref, 'experimental') + experimental: + name: ๐Ÿงช Experimental Release + if: github.repository == 'remix-run/remix' runs-on: ubuntu-latest steps: - name: โฌ‡๏ธ Checkout repo uses: actions/checkout@v4 with: + ref: ${{ github.event.inputs.branch }} + # checkout using a custom token so that we can push later on + token: ${{ secrets.NIGHTLY_PAT }} fetch-depth: 0 - name: ๐Ÿ“ฆ Setup pnpm @@ -58,6 +37,16 @@ jobs: - name: ๐Ÿ“ฅ Install deps run: pnpm install --frozen-lockfile + - name: โคด๏ธ Update version + run: | + git config --local user.email "hello@remix.run" + git config --local user.name "Remix Run Bot" + SHORT_SHA=$(git rev-parse --short HEAD) + NEXT_VERSION=0.0.0-experimental-${SHORT_SHA} + git checkout -b experimental/${NEXT_VERSION} + pnpm run version ${NEXT_VERSION} --skip-prompt + git push origin --tags + - name: ๐Ÿ— Build run: pnpm build diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cb4e8854109..93116e55178 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -21,12 +21,17 @@ pnpm install # run the build pnpm build -# run the tests -pnpm test -# run the tests for a specific package -pnpm test react -# run the tests in watch mode -pnpm test react --watch +# run the unit tests +pnpm test:primary + +# run the unit tests for a specific package in watch mode +pnpm test:primary packages/remix-react --watch + +# run the playwright integration tests in Chromium +pnpm test:integration --project chromium + +# run specific playwright integration tests in Chromium +pnpm test:integration integration/client-data --project chromium ``` ## Releases @@ -40,7 +45,6 @@ New releases should be created from release branches originating from the `dev` - Create a new release branch with the `release-` prefix - `git checkout -b release-next` - **IMPORTANT:** The `release-` prefix is important, as this is what triggers our GitHub CI workflow that will ultimately publish the release - - Branches named `release-experimental` will not trigger our release workflow, as experimental releases handled differently (outlined below) - Merge `main` into the release branch Changesets will do most of the heavy lifting for our releases. When changes are made to the codebase, an accompanying changeset file should be included to document the change. Those files will dictate how Changesets will version our packages and what shows up in the changelogs. @@ -100,14 +104,19 @@ Hotfix releases follow the same process as standard releases above, but the `rel ### Experimental releases -Experimental releases do not need to be branched off of `dev`. Experimental releases can be branched from anywhere as they are not intended for general use. +Experimental releases use a [manually-triggered Github Actions workflow](./.github/workflows/release-experimental.yml) and can be built from any existing branch. to build and publish an experimental release: + +- Commit your changes to a branch +- Push the branch to github +- Go to the Github Actions UI for the [release-experimental.yml workflow](https://github.com/remix-run/remix/actions/workflows/release-experimental-dispatch.yml) +- Click the `Run workflow` dropdown +- Leave the `Use workflow from` dropdown as `main` +- Enter your feature branch in the `branch` input +- Click the `Run workflow` button + +### Nightly releases -- Create a new branch for the release: `git checkout -b release-experimental` -- Make whatever changes you need and commit them: `git add . && git commit "experimental changes!"` -- Update version numbers and create a release tag: `pnpm version:experimental` -- Push to GitHub: `git push origin --follow-tags` -- Create a new release for the tag on GitHub to trigger the CI workflow that will publish the release to npm - - Make sure you check the "prerelease" checkbox so it is not mistaken for a stable release +Nightly releases happen automatically at midnight PST via a [cron-driven workflow](./.github/workflows/nightly.yml) that is essentially the same as the experimental releases, but also performs some validations after the release. ## Local Development Tips and Tricks @@ -138,83 +147,3 @@ LOCAL_BUILD_DIRECTORY=../my-remix-app pnpm watch ``` Now - any time you make changes in the Remix repository, they will be written out to the appropriate locations in `../my-remix-app/node_modules` and you can restart the `npm run dev` command to pick them up ๐ŸŽ‰. - -### Transition Manager Flows - -The transition manager is a complex and heavily async bit of logic that is foundational to Remix's ability to manage data loading, submission, error handling, and interruptions. Due to the user-driven nature of interruptions we don't quite believe it can be modeled as a finite state machine, however we have modeled some of the happy path flows below for clarity. - -#### Transitions - -_Note: This does not depict error or interruption flows_ - -```mermaid -graph LR - %% transition - idle -->|link clicked| loading/normalLoad - idle -->|form method=get| submitting/loaderSubmission - idle -->|form method=post| submitting/actionSubmission - idle -->|fetcher action redirects| loading/fetchActionRedirect - - subgraph "<Link> transition" - loading/normalLoad -->|loader redirected| loading/normalRedirect - loading/normalRedirect --> loading/normalRedirect - end - loading/normalLoad -->|loaders completed| idle - loading/normalRedirect -->|loaders completed| idle - - subgraph "<Form method=get>" - submitting/loaderSubmission -->|loader redirected| loading/loaderSubmissionRedirect - loading/loaderSubmissionRedirect --> loading/loaderSubmissionRedirect - end - submitting/loaderSubmission -->|loaders completed| idle - loading/loaderSubmissionRedirect -->|loaders completed| idle - - subgraph "<Form method=post>" - submitting/actionSubmission -->|action returned| loading/actionReload - submitting/actionSubmission -->|action redirected| loading/actionRedirect - loading/actionReload -->|loader redirected| loading/actionRedirect - loading/actionRedirect --> loading/actionRedirect - end - loading/actionReload -->|loaders completed| idle - loading/actionRedirect -->|loaders completed| idle - - subgraph "Fetcher action redirect" - loading/fetchActionRedirect --> loading/fetchActionRedirect - end - loading/fetchActionRedirect -->|loaders completed| idle -``` - -#### Fetchers - -_Note: This does not depict error or interruption flows, nor the ability to re-use fetchers once they've reached `idle/done`._ - -```mermaid -graph LR - idle/init -->|"load"| loading/normalLoad - idle/init -->|"submit (get)"| submitting/loaderSubmission - idle/init -->|"submit (post)"| submitting/actionSubmission - - subgraph "Normal Fetch" - loading/normalLoad -.->|loader redirected| T1{{transition}} - end - loading/normalLoad -->|loader completed| idle/done - T1{{transition}} -.-> idle/done - - subgraph "Loader Submission" - submitting/loaderSubmission -.->|"loader redirected"| T2{{transition}} - end - submitting/loaderSubmission -->|loader completed| idle/done - T2{{transition}} -.-> idle/done - - subgraph "Action Submission" - submitting/actionSubmission -->|action completed| loading/actionReload - submitting/actionSubmission -->|action redirected| loading/actionRedirect - loading/actionRedirect -.-> T3{{transition}} - loading/actionReload -.-> |loaders redirected| T3{{transition}} - end - T3{{transition}} -.-> idle/done - loading/actionReload --> |loaders completed| idle/done - - classDef transition fill:lightgreen; - class T1,T2,T3 transition; -``` diff --git a/contributors.yml b/contributors.yml index c21140f40ab..8aa3c50cc05 100644 --- a/contributors.yml +++ b/contributors.yml @@ -248,6 +248,7 @@ - IAmLuisJ - iamzee - ianduvall +- IgnusG - ikarus-akhil - illright - imzshh 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..30ef86e8269 --- /dev/null +++ b/integration/single-fetch-test.ts @@ -0,0 +1,1633 @@ +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
+ /a/b/c
+
+ +
+ + + + + ); + } + `, + + "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", () => { + let oldConsoleError: typeof console.error; + + test.beforeEach(() => { + oldConsoleError = console.error; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + 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 () => { + console.error = () => {}; + + 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("handles headers correctly for loader and action calls", 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); + }); + + test("scopes loader headers to the _routes param if present", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + export function headers({ loaderHeaders }) { + let headers = new Headers(loaderHeaders); + headers.set('x-a-headers', 'true') + return headers; + } + + export function loader({ request }) { + return new Response(null, { headers: { "x-a-loader": "true" } }); + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.tsx": js` + export function headers({ loaderHeaders, parentHeaders }) { + let headers = new Headers(parentHeaders); + loaderHeaders.forEach((value, name) => headers.set(name, value)); + headers.set('x-b-headers', 'true') + return headers; + } + + export function loader({ request }) { + return new Response(null, { headers: { "x-b-loader": "true" } }); + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.c.tsx": js` + export function headers({ loaderHeaders, parentHeaders }) { + let headers = new Headers(parentHeaders); + loaderHeaders.forEach((value, name) => headers.set(name, value)); + headers.set('x-c-headers', 'true') + return headers; + } + + export function loader({ request }) { + return new Response(null, { headers: { "x-c-loader": "true" } }); + } + + export default function Comp() { + return null; + } + `, + }, + }); + + let res = await fixture.requestSingleFetchData("/a/b/c.data"); + expect(res.headers.get("x-a-loader")).toEqual("true"); + expect(res.headers.get("x-a-headers")).toEqual("true"); + expect(res.headers.get("x-b-loader")).toEqual("true"); + expect(res.headers.get("x-b-headers")).toEqual("true"); + expect(res.headers.get("x-c-loader")).toEqual("true"); + expect(res.headers.get("x-c-headers")).toEqual("true"); + + res = await fixture.requestSingleFetchData( + "/a/b/c.data?_routes=routes%2Fa,routes%2Fa.b" + ); + expect(res.headers.get("x-a-loader")).toEqual("true"); + expect(res.headers.get("x-a-headers")).toEqual("true"); + expect(res.headers.get("x-b-loader")).toEqual("true"); + expect(res.headers.get("x-b-headers")).toEqual("true"); + expect(res.headers.get("x-c-loader")).toBeNull(); + expect(res.headers.get("x-c-headers")).toBeNull(); + + res = await fixture.requestSingleFetchData( + "/a/b/c.data?_routes=routes%2Fa" + ); + expect(res.headers.get("x-a-loader")).toEqual("true"); + expect(res.headers.get("x-a-headers")).toEqual("true"); + expect(res.headers.get("x-b-loader")).toBeNull(); + expect(res.headers.get("x-b-headers")).toBeNull(); + expect(res.headers.get("x-c-loader")).toBeNull(); + expect(res.headers.get("x-c-headers")).toBeNull(); + + res = await fixture.requestSingleFetchData( + "/a/b/c.data?_routes=routes%2Fa.b.c" + ); + expect(res.headers.get("x-a-loader")).toBeNull(); + expect(res.headers.get("x-a-headers")).toBeNull(); + expect(res.headers.get("x-b-loader")).toBeNull(); + expect(res.headers.get("x-b-headers")).toBeNull(); + expect(res.headers.get("x-c-loader")).toEqual("true"); + expect(res.headers.get("x-c-headers")).toEqual("true"); + + res = await fixture.requestSingleFetchData( + "/a/b/c.data?_routes=routes%2Fa,routes%2Fa.b.c" + ); + expect(res.headers.get("x-a-loader")).toEqual("true"); + expect(res.headers.get("x-a-loader")).toEqual("true"); + expect(res.headers.get("x-b-headers")).toBeNull(); + expect(res.headers.get("x-b-headers")).toBeNull(); + expect(res.headers.get("x-c-loader")).toEqual("true"); + expect(res.headers.get("x-c-headers")).toEqual("true"); + }); + + test("processes loader redirects", async ({ page }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function loader() { + return redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + + test("processes action redirects", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function action() { + return redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }, + ServerMode.Development + ); + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton("/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + + test("processes redirects from handleDataRequest (after loaders)", async ({ + page, + }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + + import type { EntryContext } from "@remix-run/node"; + import { createReadableStreamFromReadable } from "@remix-run/node"; + import { RemixServer } from "@remix-run/react"; + import { renderToPipeableStream } from "react-dom/server"; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + const { pipe } = renderToPipeableStream( + , + { + onShellReady() { + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + responseHeaders.set("Content-Type", "text/html"); + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + }, + } + ); + }); + } + + export function handleDataRequest(response, { request }) { + if (request.url.endsWith("/data.data")) { + return new Response(null, { + status: 302, + headers: { + Location: "/target", + }, + }); + } + return response; + } + `, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function loader() { + return redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + + test("processes redirects from handleDataRequest (after actions)", async ({ + page, + }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + + import type { EntryContext } from "@remix-run/node"; + import { createReadableStreamFromReadable } from "@remix-run/node"; + import { RemixServer } from "@remix-run/react"; + import { renderToPipeableStream } from "react-dom/server"; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + const { pipe } = renderToPipeableStream( + , + { + onShellReady() { + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + responseHeaders.set("Content-Type", "text/html"); + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + }, + } + ); + }); + } + + export function handleDataRequest(response, { request }) { + if (request.url.endsWith("/data.data")) { + return new Response(null, { + status: 302, + headers: { + Location: "/target", + }, + }); + } + return response; + } + `, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function action() { + return redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton("/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + + test.describe("client loaders", () => { + test("when no routes have client loaders", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "A server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

A

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "B server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

B

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.c.tsx": js` + import { useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "C server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

C

+

{data.message}

+ + ); + } + `, + }, + }, + ServerMode.Development + ); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/a/b/c"); + await page.waitForSelector("#c-data"); + expect(await app.getHtml("#a-data")).toContain("A server loader"); + expect(await app.getHtml("#b-data")).toContain("B server loader"); + expect(await app.getHtml("#c-data")).toContain("C server loader"); + + // No clientLoaders so we can make a single parameter-less fetch + expect(urls).toEqual([expect.stringMatching(/\/a\/b\/c\.data$/)]); + }); + + test("when one route has a client loader", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "A server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

A

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "B server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

B

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.c.tsx": js` + import { useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "C server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (C client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

C

+

{data.message}

+ + ); + } + `, + }, + }, + ServerMode.Development + ); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/a/b/c"); + await page.waitForSelector("#c-data"); + expect(await app.getHtml("#a-data")).toContain("A server loader"); + expect(await app.getHtml("#b-data")).toContain("B server loader"); + expect(await app.getHtml("#c-data")).toContain( + "C server loader (C client loader)" + ); + + // A/B can be loaded together, C needs it's own call due to it's clientLoader + expect(urls.sort()).toEqual([ + expect.stringMatching( + /\/a\/b\/c\.data\?_routes=routes%2Fa%2Croutes%2Fa\.b$/ + ), + expect.stringMatching(/\/a\/b\/c\.data\?_routes=routes%2Fa\.b\.c$/), + ]); + }); + + test("when multiple routes have client loaders", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "A server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

A

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "B server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (B client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

B

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.c.tsx": js` + import { useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "C server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (C client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

C

+

{data.message}

+ + ); + } + `, + }, + }, + ServerMode.Development + ); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/a/b/c"); + await page.waitForSelector("#c-data"); + expect(await app.getHtml("#a-data")).toContain("A server loader"); + expect(await app.getHtml("#b-data")).toContain( + "B server loader (B client loader)" + ); + expect(await app.getHtml("#c-data")).toContain( + "C server loader (C client loader)" + ); + + // B/C have client loaders so they get individual calls, which leaves A + // getting it's own "individual" since it's the last route standing + expect(urls.sort()).toEqual([ + expect.stringMatching(/\/a\/b\/c\.data\?_routes=routes%2Fa$/), + expect.stringMatching(/\/a\/b\/c\.data\?_routes=routes%2Fa\.b$/), + expect.stringMatching(/\/a\/b\/c\.data\?_routes=routes%2Fa\.b\.c$/), + ]); + }); + + test("when all routes have client loaders", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "A server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (A client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

A

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "B server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (B client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

B

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.c.tsx": js` + import { useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "C server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (C client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

C

+

{data.message}

+ + ); + } + `, + }, + }, + ServerMode.Development + ); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/a/b/c"); + await page.waitForSelector("#c-data"); + expect(await app.getHtml("#a-data")).toContain( + "A server loader (A client loader)" + ); + expect(await app.getHtml("#b-data")).toContain( + "B server loader (B client loader)" + ); + expect(await app.getHtml("#c-data")).toContain( + "C server loader (C client loader)" + ); + + // A/B/C all have client loaders so they get individual calls + expect(urls.sort()).toEqual([ + expect.stringMatching(/\/a\/b\/c.data\?_routes=routes%2Fa$/), + expect.stringMatching(/\/a\/b\/c.data\?_routes=routes%2Fa.b$/), + expect.stringMatching(/\/a\/b\/c.data\?_routes=routes%2Fa.b.c$/), + ]); + }); + }); + + test.describe("prefetching", () => { + test("when no routes have client loaders", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + + export default function Index() { + return ( + + ); + } + `, + "app/routes/a.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "A server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

A

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "B server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

B

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.c.tsx": js` + import { useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "C server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

C

+

{data.message}

+ + ); + } + `, + }, + }, + ServerMode.Development + ); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + // No clientLoaders so we can make a single parameter-less fetch + await page.waitForSelector( + "nav link[rel='prefetch'][as='fetch'][href='/a/b/c.data']", + { state: "attached" } + ); + expect(await app.page.locator("nav link[as='fetch']").count()).toEqual(1); + }); + + test("when one route has a client loader", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + + export default function Index() { + return ( + + ); + } + `, + "app/routes/a.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "A server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

A

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "B server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

B

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.c.tsx": js` + import { useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "C server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (C client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

C

+

{data.message}

+ + ); + } + `, + }, + }, + ServerMode.Development + ); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // A/B can be prefetched, C doesn't get prefetched due to its `clientLoader` + await page.waitForSelector( + "nav link[rel='prefetch'][as='fetch'][href='/a/b/c.data?_routes=routes%2Fa%2Croutes%2Fa.b']", + { state: "attached" } + ); + expect(await app.page.locator("nav link[as='fetch']").count()).toEqual(1); + }); + + test("when multiple routes have client loaders", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + + export default function Index() { + return ( + + ); + } + `, + "app/routes/a.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "A server loader" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

A

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "B server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (B client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

B

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.c.tsx": js` + import { useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "C server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (C client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

C

+

{data.message}

+ + ); + } + `, + }, + }, + ServerMode.Development + ); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // Only A can get prefetched, B/C can't due to `clientLoader` + await page.waitForSelector( + "nav link[rel='prefetch'][as='fetch'][href='/a/b/c.data?_routes=routes%2Fa']", + { state: "attached" } + ); + expect(await app.page.locator("nav link[as='fetch']").count()).toEqual(1); + }); + + test("when all routes have client loaders", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + + export default function Index() { + return ( + + ); + } + `, + "app/routes/a.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "A server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (A client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

A

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "B server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (B client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

B

+

{data.message}

+ + + ); + } + `, + "app/routes/a.b.c.tsx": js` + import { useLoaderData } from '@remix-run/react'; + + export function loader() { + return { message: "C server loader" }; + } + + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { message: data.message + " (C client loader)" }; + } + + export default function Comp() { + let data = useLoaderData(); + return ( + <> +

C

+

{data.message}

+ + ); + } + `, + }, + }, + ServerMode.Development + ); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // No prefetching due to clientLoaders + expect(await app.page.locator("nav link[as='fetch']").count()).toEqual(0); + }); + }); +}); diff --git a/integration/vite-build-test.ts b/integration/vite-build-test.ts index 3fec1a9b55b..dd6c7681b63 100644 --- a/integration/vite-build-test.ts +++ b/integration/vite-build-test.ts @@ -1,402 +1,380 @@ import * as path from "node:path"; import { test, expect } from "@playwright/test"; -import shell from "shelljs"; +import getPort from "get-port"; import glob from "glob"; import { - createAppFixture, - createFixture, - js, -} from "./helpers/create-fixture.js"; -import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; -import { PlaywrightFixture, selectHtml } from "./helpers/playwright-fixture.js"; - -test.describe("Vite build", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture({ - compiler: "vite", - files: { - "remix.config.js": js` - throw new Error("Remix should not access remix.config.js when using Vite"); - export default {}; - `, - ".env": ` - ENV_VAR_FROM_DOTENV_FILE=true - `, - "vite.config.ts": js` - import { defineConfig } from "vite"; - import { vitePlugin as remix } from "@remix-run/dev"; - import mdx from "@mdx-js/rollup"; - - export default defineConfig({ - build: { - // force emitting asset files instead of inlined as data-url - assetsInlineLimit: 0, - }, - plugins: [ - mdx(), - remix(), - ], - }); - `, - "app/root.tsx": js` - import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; - - export default function Root() { - return ( - - - - - - -
-

Root

- -
- - - - ); - } - `, - "app/routes/_index.tsx": js` - import { useState, useEffect } from "react"; - import { json } from "@remix-run/node"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return json({ serverOnly1 }) - } - - export const action = () => { - console.log(serverOnly2) - return null - } - - export default function() { - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - return ( - <> -

Index

- {!mounted ?

Loading...

:

Mounted

} - - ); - } - `, - "app/utils.server.ts": js` - export const serverOnly1 = "SERVER_ONLY_1" - export const serverOnly2 = "SERVER_ONLY_2" - `, - "app/routes/resource.ts": js` - import { json } from "@remix-run/node"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return json({ serverOnly1 }) - } - - export const action = () => { - console.log(serverOnly2) - return null - } - `, - "app/routes/mdx.mdx": js` - import { useEffect, useState } from "react"; - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - import { serverOnly1, serverOnly2 } from "../utils.server"; - - export const loader = () => { - return json({ - serverOnly1, - content: "MDX route content from loader", - }) - } - - export const action = () => { - console.log(serverOnly2) - return null - } - - export function MdxComponent() { - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - const { content } = useLoaderData(); - const text = content + (mounted - ? ": mounted" - : ": not mounted"); - return
{text}
- } - - ## MDX Route - - - `, - "app/routes/code-split1.tsx": js` - import { CodeSplitComponent } from "../code-split-component"; - - export default function CodeSplit1Route() { - return
; - } - `, - "app/routes/code-split2.tsx": js` - import { CodeSplitComponent } from "../code-split-component"; - - export default function CodeSplit2Route() { - return
; - } - `, - "app/code-split-component.tsx": js` - import classes from "./code-split.module.css"; - - export function CodeSplitComponent() { - return ok - } - `, - "app/code-split.module.css": js` - .test { - background-color: rgb(255, 170, 0); - } - `, - "app/routes/dotenv.tsx": js` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export const loader = () => { - return json({ - loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE ?? '.env file was NOT loaded, which is a good thing', - }) - } - - export default function DotenvRoute() { - const { loaderContent } = useLoaderData(); - - return
{loaderContent}
; - } - `, - - "app/assets/test.txt": "test", - "app/routes/ssr-only-assets.tsx": js` - import txtUrl from "../assets/test.txt?url"; - import { useLoaderData } from "@remix-run/react" - - export const loader: LoaderFunction = () => { - return { txtUrl }; - }; - - export default function SsrOnlyAssetsRoute() { - const loaderData = useLoaderData(); - return ( -
- txtUrl + createProject, + viteBuild, + viteRemixServe, + viteConfig, + grep, +} from "./helpers/vite.js"; + +let port: number; +let cwd: string; +let stop: () => void; + +const js = String.raw; + +test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "remix.config.js": js` + throw new Error("Remix should not access remix.config.js when using Vite"); + export default {}; + `, + ".env": ` + ENV_VAR_FROM_DOTENV_FILE=true + `, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + import mdx from "@mdx-js/rollup"; + + export default defineConfig({ + ${await viteConfig.server({ port })} + build: { + // force emitting asset files instead of inlined as data-url + assetsInlineLimit: 0, + }, + plugins: [ + mdx(), + remix(), + ], + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+

Root

+
- ); - } - `, - - "app/assets/test.css": ".test{color:red}", - "app/routes/ssr-only-css-url-files.tsx": js` - import cssUrl from "../assets/test.css?url"; - import { useLoaderData } from "@remix-run/react" - - export const loader: LoaderFunction = () => { - return { cssUrl }; - }; - - export default function SsrOnlyCssUrlFilesRoute() { - const loaderData = useLoaderData(); - return ( -
- cssUrl -
- ); - } - `, - - "app/routes/ssr-code-split.tsx": js` - import { useLoaderData } from "@remix-run/react" - - export const loader: LoaderFunction = async () => { - const lib = await import("../ssr-code-split-lib"); - return lib.ssrCodeSplitTest(); - }; - - export default function SsrCodeSplitRoute() { - const loaderData = useLoaderData(); - return ( -
{loaderData}
- ); - } - `, - - "app/ssr-code-split-lib.ts": js` - export function ssrCodeSplitTest() { - return "ssrCodeSplitTest"; - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); + + + + ); + } + `, + "app/routes/_index.tsx": js` + import { useState, useEffect } from "react"; + import { json } from "@remix-run/node"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return json({ serverOnly1 }) + } + + export const action = () => { + console.log(serverOnly2) + return null + } + + export default function() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( + <> +

Index

+ {!mounted ?

Loading...

:

Mounted

} + + ); + } + `, + "app/utils.server.ts": js` + export const serverOnly1 = "SERVER_ONLY_1" + export const serverOnly2 = "SERVER_ONLY_2" + `, + "app/routes/resource.ts": js` + import { json } from "@remix-run/node"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return json({ serverOnly1 }) + } + + export const action = () => { + console.log(serverOnly2) + return null + } + `, + "app/routes/mdx.mdx": js` + import { useEffect, useState } from "react"; + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return json({ + serverOnly1, + content: "MDX route content from loader", + }) + } + + export const action = () => { + console.log(serverOnly2) + return null + } + + export function MdxComponent() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + const { content } = useLoaderData(); + const text = content + (mounted + ? ": mounted" + : ": not mounted"); + return
{text}
+ } + + ## MDX Route + + + `, + "app/routes/code-split1.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; + + export default function CodeSplit1Route() { + return
; + } + `, + "app/routes/code-split2.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; + + export default function CodeSplit2Route() { + return
; + } + `, + "app/code-split-component.tsx": js` + import classes from "./code-split.module.css"; + + export function CodeSplitComponent() { + return ok + } + `, + "app/code-split.module.css": js` + .test { + background-color: rgb(255, 170, 0); + } + `, + "app/routes/dotenv.tsx": js` + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => { + return json({ + loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE ?? '.env file was NOT loaded, which is a good thing', + }) + } + + export default function DotenvRoute() { + const { loaderContent } = useLoaderData(); + + return
{loaderContent}
; + } + `, + + "app/assets/test.txt": "test", + "app/routes/ssr-only-assets.tsx": js` + import txtUrl from "../assets/test.txt?url"; + import { useLoaderData } from "@remix-run/react" + + export const loader: LoaderFunction = () => { + return { txtUrl }; + }; + + export default function SsrOnlyAssetsRoute() { + const loaderData = useLoaderData(); + return ( +
+ txtUrl +
+ ); + } + `, + + "app/assets/test.css": ".test{color:red}", + "app/routes/ssr-only-css-url-files.tsx": js` + import cssUrl from "../assets/test.css?url"; + import { useLoaderData } from "@remix-run/react" + + export const loader: LoaderFunction = () => { + return { cssUrl }; + }; + + export default function SsrOnlyCssUrlFilesRoute() { + const loaderData = useLoaderData(); + return ( +
+ cssUrl +
+ ); + } + `, + + "app/routes/ssr-code-split.tsx": js` + import { useLoaderData } from "@remix-run/react" + + export const loader: LoaderFunction = async () => { + const lib = await import("../ssr-code-split-lib"); + return lib.ssrCodeSplitTest(); + }; + + export default function SsrCodeSplitRoute() { + const loaderData = useLoaderData(); + return ( +
{loaderData}
+ ); + } + `, + + "app/ssr-code-split-lib.ts": js` + export function ssrCodeSplitTest() { + return "ssrCodeSplitTest"; + } + `, }); - test("server code is removed from client build", async () => { - let clientBuildDir = path.join(fixture.projectDir, "build/client"); - - // detect client asset files - let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", { - cwd: clientBuildDir, - absolute: true, - }); + let { stderr, status } = viteBuild({ cwd }); + expect( + stderr + .toString() + // This can be removed when this issue is fixed: https://github.com/remix-run/remix/issues/9055 + .replace('Generated an empty chunk: "resource".', "") + .trim() + ).toBeFalsy(); + expect(status).toBe(0); + stop = await viteRemixServe({ cwd, port }); +}); +test.afterAll(() => stop()); - // grep for server-only values in client assets - let result = shell - .grep("-l", /SERVER_ONLY_1|SERVER_ONLY_2/, assetFiles) - .stdout.trim() - .split("\n") - .filter((line) => line.length > 0); +test("Vite / build / server code is removed from client build", async () => { + expect(grep(path.join(cwd, "build/client"), /SERVER_ONLY_1/).length).toBe(0); + expect(grep(path.join(cwd, "build/client"), /SERVER_ONLY_2/).length).toBe(0); +}); - expect(result).toHaveLength(0); - }); +test("Vite / build / renders matching MDX routes", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - test("server renders matching routes", async () => { - let res = await fixture.requestDocument("/"); - expect(res.status).toBe(200); - expect(selectHtml(await res.text(), "#content")).toBe(`
-

Root

-

Index

-

Loading...

-
`); + await page.goto(`http://localhost:${port}/mdx`, { + waitUntil: "networkidle", }); + await expect(page.locator("[data-mdx-route]")).toHaveText( + "MDX route content from loader: mounted" + ); + expect(pageErrors).toEqual([]); +}); - test("hydrates", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - expect(await page.locator("#content h2").textContent()).toBe("Index"); - expect(await page.locator("#content h3[data-mounted]").textContent()).toBe( - "Mounted" - ); - }); +test("Vite / build / emits SSR-only assets to the client assets directory", async ({ + page, +}) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - test("server renders matching MDX routes", async ({ page }) => { - let res = await fixture.requestDocument("/mdx"); - expect(res.status).toBe(200); - expect(selectHtml(await res.text(), "[data-mdx-route]")).toBe( - `
MDX route content from loader: not mounted
` - ); + await page.goto(`http://localhost:${port}/ssr-only-assets`, { + waitUntil: "networkidle", }); - test("hydrates matching MDX routes", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + await page.getByRole("link", { name: "txtUrl" }).click(); + await page.waitForURL("**/assets/test-*.txt"); + await expect(page.getByText("test")).toBeVisible(); + expect(pageErrors).toEqual([]); +}); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/mdx"); - await expect(page.locator("[data-mdx-route]")).toContainText( - "MDX route content from loader: mounted" - ); +test("Vite / build /emits SSR-only .css?url files to the client assets directory", async ({ + page, +}) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - expect(pageErrors).toEqual([]); + await page.goto(`http://localhost:${port}/ssr-only-css-url-files`, { + waitUntil: "networkidle", }); - test("emits SSR-only assets to the client assets directory", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/ssr-only-assets"); - - await page.getByRole("link", { name: "txtUrl" }).click(); - await page.waitForURL("**/assets/test-*.txt"); - await expect(page.getByText("test")).toBeVisible(); - }); + await page.getByRole("link", { name: "cssUrl" }).click(); + await page.waitForURL("**/assets/test-*.css"); + await expect(page.getByText(".test{")).toBeVisible(); + expect(pageErrors).toEqual([]); +}); - test("emits SSR-only .css?url files to the client assets directory", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/ssr-only-css-url-files"); +test("Vite / build / supports code-split JS from SSR build", async ({ + page, +}) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - await page.getByRole("link", { name: "cssUrl" }).click(); - await page.waitForURL("**/assets/test-*.css"); - await expect(page.getByText(".test{")).toBeVisible(); + await page.goto(`http://localhost:${port}/ssr-code-split`, { + waitUntil: "networkidle", }); - test("supports code-split JS from SSR build", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); + await expect(page.locator("[data-ssr-code-split]")).toHaveText( + "ssrCodeSplitTest" + ); + expect(pageErrors).toEqual([]); +}); - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/ssr-code-split`); - expect(pageErrors).toEqual([]); +test("Vite / build / removes assets (other than code-split JS) and CSS files from SSR build", async () => { + let assetFiles = glob.sync("build/server/assets/**/*", { cwd }); + let [asset, ...rest] = assetFiles; + expect(rest).toEqual([]); // Provide more useful test output if this fails + expect(asset).toMatch(/ssr-code-split-lib-.*\.js/); +}); - await expect(page.locator("[data-ssr-code-split]")).toHaveText( - "ssrCodeSplitTest" - ); +test("Vite / build / supports code-split CSS", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - expect(pageErrors).toEqual([]); + await page.goto(`http://localhost:${port}/code-split1`, { + waitUntil: "networkidle", }); - - test("removes assets (other than code-split JS) and CSS files from SSR build", async () => { - let assetFiles = glob.sync("*", { - cwd: path.join(fixture.projectDir, "build/server/assets"), - }); - let [asset, ...rest] = assetFiles; - expect(rest).toEqual([]); // Provide more useful test output if this fails - expect(asset).toMatch(/ssr-code-split-lib-.*\.js/); + expect( + await page + .locator("#code-split1 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); + + await page.goto(`http://localhost:${port}/code-split2`, { + waitUntil: "networkidle", }); + expect( + await page + .locator("#code-split2 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); - test("supports code-split css", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/code-split1"); - expect( - await page - .locator("#code-split1 span") - .evaluate((e) => window.getComputedStyle(e).backgroundColor) - ).toBe("rgb(255, 170, 0)"); - - await app.goto("/code-split2"); - expect( - await page - .locator("#code-split2 span") - .evaluate((e) => window.getComputedStyle(e).backgroundColor) - ).toBe("rgb(255, 170, 0)"); - - expect(pageErrors).toEqual([]); - }); + expect(pageErrors).toEqual([]); +}); - test("doesn't load .env file", async ({ page }) => { - let pageErrors: unknown[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); +test("Vite / build / doesn't load .env file", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/dotenv`); - expect(pageErrors).toEqual([]); + await page.goto(`http://localhost:${port}/dotenv`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); - let loaderContent = page.locator("[data-dotenv-route-loader-content]"); - await expect(loaderContent).toHaveText( - ".env file was NOT loaded, which is a good thing" - ); + let loaderContent = page.locator("[data-dotenv-route-loader-content]"); + await expect(loaderContent).toHaveText( + ".env file was NOT loaded, which is a good thing" + ); - expect(pageErrors).toEqual([]); - }); + expect(pageErrors).toEqual([]); }); 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/jest/jest.config.shared.js b/jest/jest.config.shared.js index 547a2da9a6a..0255c1b4c65 100644 --- a/jest/jest.config.shared.js +++ b/jest/jest.config.shared.js @@ -9,11 +9,6 @@ const ignorePatterns = [ /** @type {import('jest').Config} */ module.exports = { moduleNameMapper: { - "^@remix-run/web-blob$": require.resolve("@remix-run/web-blob"), - "^@remix-run/web-fetch$": require.resolve("@remix-run/web-fetch"), - "^@remix-run/web-file": require.resolve("@remix-run/web-file"), - "^@remix-run/web-form-data$": require.resolve("@remix-run/web-form-data"), - "^@remix-run/web-stream$": require.resolve("@remix-run/web-stream"), "^@web3-storage/multipart-parser$": require.resolve( "@web3-storage/multipart-parser" ), diff --git a/package.json b/package.json index 09ac1bd85b9..61aede9cd91 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "changeset:version": "changeset version && node ./scripts/remove-prerelease-changelogs.mjs && node ./scripts/patchup-version.mjs", "changeset:release": "pnpm build --tsc && changeset publish", "version": "node ./scripts/version.js", - "version:experimental": "node ./scripts/version.js experimental", "watch": "rollup -c --watch --watch.onEnd=\"node scripts/copy-build-to-dist.mjs\"" }, "packageManager": "pnpm@8.10.5", @@ -62,7 +61,6 @@ "@remix-run/css-bundle": "workspace:*", "@remix-run/dev": "workspace:*", "@remix-run/node": "workspace:*", - "@remix-run/web-fetch": "^4.4.2", "@remix-run/react": "workspace:*", "@remix-run/testing": "workspace:*", "@rollup/plugin-babel": "^5.2.2", diff --git a/packages/remix-architect/__tests__/server-test.ts b/packages/remix-architect/__tests__/server-test.ts index 7cbfce23f96..196f00f9488 100644 --- a/packages/remix-architect/__tests__/server-test.ts +++ b/packages/remix-architect/__tests__/server-test.ts @@ -217,7 +217,7 @@ describe("architect createRemixHeaders", () => { "x-foo": "bar, baz", "x-bar": "baz", }); - expect(headers.getAll("x-foo")).toEqual(["bar, baz"]); + expect(headers.get("x-foo")).toEqual("bar, baz"); expect(headers.get("x-bar")).toBe("baz"); }); @@ -226,9 +226,9 @@ describe("architect createRemixHeaders", () => { "__session=some_value", "__other=some_other_value", ]); - expect(headers.getAll("cookie")).toEqual([ - "__session=some_value; __other=some_other_value", - ]); + expect(headers.get("cookie")).toEqual( + "__session=some_value; __other=some_other_value" + ); }); }); }); diff --git a/packages/remix-architect/server.ts b/packages/remix-architect/server.ts index 9deda1b3f0a..6ad6c1c692b 100644 --- a/packages/remix-architect/server.ts +++ b/packages/remix-architect/server.ts @@ -88,7 +88,9 @@ export function createRemixHeaders( } if (requestCookies) { - headers.append("Cookie", requestCookies.join("; ")); + for (let cookie of requestCookies) { + headers.append("Cookie", cookie); + } } return headers; 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/cli/commands.ts b/packages/remix-dev/cli/commands.ts index eabd6edbb00..7eeded3c271 100644 --- a/packages/remix-dev/cli/commands.ts +++ b/packages/remix-dev/cli/commands.ts @@ -303,7 +303,8 @@ export async function generateEntry( let defaultEntryClient = path.resolve(defaultsDirectory, "entry.client.tsx"); let defaultEntryServer = path.resolve( defaultsDirectory, - ctx?.remixConfig.ssr === false + ctx?.remixConfig.ssr === false && + ctx?.remixConfig.future.unstable_singleFetch !== true ? `entry.server.spa.tsx` : `entry.server.${serverRuntime}.tsx` ); diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 45a3920caad..4ddf67780df 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< @@ -468,7 +469,7 @@ export async function resolveConfig( let pkgJson = await PackageJson.load(rootDirectory); let deps = pkgJson.content.dependencies ?? {}; - if (isSpaMode) { + if (isSpaMode && appConfig.future?.unstable_singleFetch != true) { // This is a super-simple default since we don't need streaming in SPA Mode. // We can include this in a remix-spa template, but right now `npx remix reveal` // will still expose the streaming template since that command doesn't have @@ -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/config/defaults/entry.server.node.tsx b/packages/remix-dev/config/defaults/entry.server.node.tsx index 5005bc69178..65ad16eb8b0 100644 --- a/packages/remix-dev/config/defaults/entry.server.node.tsx +++ b/packages/remix-dev/config/defaults/entry.server.node.tsx @@ -15,7 +15,10 @@ export default function handleRequest( remixContext: EntryContext, loadContext: AppLoadContext ) { - return isBotRequest(request.headers.get("user-agent")) + let prohibitOutOfOrderStreaming = + isBotRequest(request.headers.get("user-agent")) || remixContext.isSpaMode; + + return prohibitOutOfOrderStreaming ? handleBotRequest( request, responseStatusCode, 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-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 8dc1a266b3f..2160bcf7edf 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -1623,10 +1623,13 @@ export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { } } - return { - code: removeExports(code, SERVER_ONLY_ROUTE_EXPORTS), - map: null, - }; + let [filepath] = id.split("?"); + + return removeExports(code, SERVER_ONLY_ROUTE_EXPORTS, { + sourceMaps: true, + filename: id, + sourceFileName: filepath, + }); }, }, { diff --git a/packages/remix-dev/vite/remove-exports-test.ts b/packages/remix-dev/vite/remove-exports-test.ts index bb52e1f8822..86af19d2c8f 100644 --- a/packages/remix-dev/vite/remove-exports-test.ts +++ b/packages/remix-dev/vite/remove-exports-test.ts @@ -12,11 +12,11 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "export const clientExport_1 = () => {}; export const clientExport_2 = () => {};" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("arrow function with dependencies", () => { @@ -34,13 +34,13 @@ describe("removeExports", () => { export const serverExport_1 = () => serverUtil() export const serverExport_2 = () => serverUtil() - + export const clientExport_1 = () => clientUtil() export const clientExport_2 = () => clientUtil() `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "import { clientLib } from 'client-lib'; import { sharedLib } from 'shared-lib'; const sharedUtil = () => sharedLib(); @@ -48,7 +48,7 @@ describe("removeExports", () => { export const clientExport_1 = () => clientUtil(); export const clientExport_2 = () => clientUtil();" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("function statement", () => { @@ -62,11 +62,11 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "export function clientExport_1() {} export function clientExport_2() {}" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("function statement with dependencies", () => { @@ -90,7 +90,7 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "import { clientLib } from 'client-lib'; import { sharedLib } from 'shared-lib'; function sharedUtil() { @@ -106,7 +106,7 @@ describe("removeExports", () => { return clientUtil(); }" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("object", () => { @@ -120,11 +120,11 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "export const clientExport_1 = {}; export const clientExport_2 = {};" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("object with dependencies", () => { @@ -148,7 +148,7 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "import { clientLib } from 'client-lib'; import { sharedLib } from 'shared-lib'; const sharedUtil = () => sharedLib(); @@ -160,7 +160,7 @@ describe("removeExports", () => { value: clientUtil() };" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("function call", () => { @@ -174,11 +174,11 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "export const clientExport_1 = globalFunction(); export const clientExport_2 = globalFunction();" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("function call with dependencies", () => { @@ -202,7 +202,7 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "import { clientLib } from 'client-lib'; import { sharedLib } from 'shared-lib'; const sharedUtil = () => sharedLib(); @@ -210,7 +210,7 @@ describe("removeExports", () => { export const clientExport_1 = clientUtil(); export const clientExport_2 = clientUtil();" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("iife", () => { @@ -224,11 +224,11 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "export const clientExport_1 = (() => {})(); export const clientExport_2 = (() => {})();" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("iife with dependencies", () => { @@ -252,7 +252,7 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "import { clientLib } from 'client-lib'; import { sharedLib } from 'shared-lib'; const sharedUtil = () => sharedLib(); @@ -260,7 +260,7 @@ describe("removeExports", () => { export const clientExport_1 = (() => clientUtil())(); export const clientExport_2 = (() => clientUtil())();" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("re-export", () => { @@ -274,11 +274,11 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "export { clientExport_1 } from './client/1'; export { clientExport_2 } from './client/2';" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("re-export multiple", () => { @@ -290,10 +290,10 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot( + expect(result.code).toMatchInlineSnapshot( "\"export { clientExport_1, clientExport_2 } from './client';\"" ); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("re-export manual", () => { @@ -312,13 +312,13 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "import { clientExport_1 } from './client/1'; import { clientExport_2 } from './client/2'; export { clientExport_1 }; export { clientExport_2 };" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("number", () => { @@ -332,11 +332,11 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "export const clientExport_1 = 123; export const clientExport_2 = 123;" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("string", () => { @@ -350,11 +350,11 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "export const clientExport_1 = 'string'; export const clientExport_2 = 'string';" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("string reference", () => { @@ -371,12 +371,12 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "const CLIENT_STRING = 'CLIENT_STRING'; export const clientExport_1 = CLIENT_STRING; export const clientExport_2 = CLIENT_STRING;" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); test("null", () => { @@ -390,10 +390,10 @@ describe("removeExports", () => { `, ["serverExport_1", "serverExport_2"] ); - expect(result).toMatchInlineSnapshot(` + expect(result.code).toMatchInlineSnapshot(` "export const clientExport_1 = null; export const clientExport_2 = null;" `); - expect(result).not.toMatch(/server/i); + expect(result.code).not.toMatch(/server/i); }); }); diff --git a/packages/remix-dev/vite/remove-exports.ts b/packages/remix-dev/vite/remove-exports.ts index cb5da87b9a6..298d2cac869 100644 --- a/packages/remix-dev/vite/remove-exports.ts +++ b/packages/remix-dev/vite/remove-exports.ts @@ -1,5 +1,7 @@ // Adapted from https://github.com/egoist/babel-plugin-eliminator/blob/d29859396b7708b7f7abbacdd951cbbc80902f00/src/index.ts // Which was originally adapted from https://github.com/vercel/next.js/blob/574fe0b582d5cc1b13663121fd47a3d82deaaa17/packages/next/build/babel/plugins/next-ssg-transform.ts +import type { GeneratorOptions } from "@babel/generator"; + import { type BabelTypes, type NodePath, @@ -61,9 +63,12 @@ function isIdentifierReferenced( return false; } -export const removeExports = (source: string, exportsToRemove: string[]) => { +export const removeExports = ( + source: string, + exportsToRemove: string[], + generateOptions: GeneratorOptions = {} +) => { let document = parse(source, { sourceType: "module" }); - let generateCode = () => generate(document).code; let referencedIdentifiers = new Set>(); let removedExports = new Set(); @@ -213,7 +218,7 @@ export const removeExports = (source: string, exportsToRemove: string[]) => { if (removedExports.size === 0) { // No server-specific exports found so there's // no need to remove unused references - return generateCode(); + return generate(document, generateOptions); } let referencesRemovedInThisPass: number; @@ -359,5 +364,5 @@ export const removeExports = (source: string, exportsToRemove: string[]) => { }); } while (referencesRemovedInThisPass); - return generateCode(); + return generate(document, generateOptions); }; diff --git a/packages/remix-express/__tests__/server-test.ts b/packages/remix-express/__tests__/server-test.ts index 41d493b0e77..0646d033af4 100644 --- a/packages/remix-express/__tests__/server-test.ts +++ b/packages/remix-express/__tests__/server-test.ts @@ -178,7 +178,7 @@ describe("express createRemixHeaders", () => { "x-foo": ["bar", "baz"], "x-bar": "baz", }); - expect(headers.getAll("x-foo")).toEqual(["bar", "baz"]); + expect(headers.get("x-foo")).toEqual("bar, baz"); expect(headers.get("x-bar")).toBe("baz"); }); @@ -189,7 +189,7 @@ describe("express createRemixHeaders", () => { "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", ], }); - expect(headers.getAll("set-cookie")).toEqual([ + expect(headers.getSetCookie()).toEqual([ "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", ]); diff --git a/packages/remix-node/__tests__/fileUploadHandler-test.ts b/packages/remix-node/__tests__/fileUploadHandler-test.ts index 670db4941b7..0c5a9ea3ff7 100644 --- a/packages/remix-node/__tests__/fileUploadHandler-test.ts +++ b/packages/remix-node/__tests__/fileUploadHandler-test.ts @@ -1,14 +1,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { ReadableStream } from "@remix-run/web-stream"; import { NodeOnDiskFile } from "../upload/fileUploadHandler"; import { readableStreamToString } from "../stream"; -beforeAll(() => { - global.ReadableStream = ReadableStream; -}); - describe("NodeOnDiskFile", () => { let filepath = path.resolve(__dirname, "assets/test.txt"); let size = fs.statSync(filepath).size; diff --git a/packages/remix-node/globals.ts b/packages/remix-node/globals.ts index e50a1abf314..2ae3f0daa57 100644 --- a/packages/remix-node/globals.ts +++ b/packages/remix-node/globals.ts @@ -5,22 +5,7 @@ import { Headers as NodeHeaders, Request as NodeRequest, Response as NodeResponse, -} from "@remix-run/web-fetch"; -import { - ByteLengthQueuingStrategy as NodeByteLengthQueuingStrategy, - CountQueuingStrategy as NodeCountQueuingStrategy, - ReadableByteStreamController as NodeReadableByteStreamController, - ReadableStream as NodeReadableStream, - ReadableStreamBYOBReader as NodeReadableStreamBYOBReader, - ReadableStreamBYOBRequest as NodeReadableStreamBYOBRequest, - ReadableStreamDefaultController as NodeReadableStreamDefaultController, - ReadableStreamDefaultReader as NodeReadableStreamDefaultReader, - TransformStream as NodeTransformStream, - TransformStreamDefaultController as NodeTransformStreamDefaultController, - WritableStream as NodeWritableStream, - WritableStreamDefaultController as NodeWritableStreamDefaultController, - WritableStreamDefaultWriter as NodeWritableStreamDefaultWriter, -} from "@remix-run/web-stream"; +} from "undici"; declare global { namespace NodeJS { @@ -41,30 +26,23 @@ declare global { WritableStream: typeof WritableStream; } } + + interface RequestInit { + duplex?: "half"; + } } export function installGlobals() { - global.File = NodeFile; + global.File = NodeFile as unknown as typeof File; - global.Headers = NodeHeaders as typeof Headers; - global.Request = NodeRequest as typeof Request; - global.Response = NodeResponse as unknown as typeof Response; - global.fetch = nodeFetch as typeof fetch; + // @ts-expect-error - overriding globals + global.Headers = NodeHeaders; + // @ts-expect-error - overriding globals + global.Request = NodeRequest; + // @ts-expect-error - overriding globals + global.Response = NodeResponse; + // @ts-expect-error - overriding globals + global.fetch = nodeFetch; + // @ts-expect-error - overriding globals global.FormData = NodeFormData; - - // Export everything from https://developer.mozilla.org/en-US/docs/Web/API/Streams_API - global.ByteLengthQueuingStrategy = NodeByteLengthQueuingStrategy; - global.CountQueuingStrategy = NodeCountQueuingStrategy; - global.ReadableByteStreamController = NodeReadableByteStreamController; - global.ReadableStream = NodeReadableStream; - global.ReadableStreamBYOBReader = NodeReadableStreamBYOBReader; - global.ReadableStreamBYOBRequest = NodeReadableStreamBYOBRequest; - global.ReadableStreamDefaultController = NodeReadableStreamDefaultController; - global.ReadableStreamDefaultReader = NodeReadableStreamDefaultReader; - global.TransformStream = NodeTransformStream; - global.TransformStreamDefaultController = - NodeTransformStreamDefaultController; - global.WritableStream = NodeWritableStream; - global.WritableStreamDefaultController = NodeWritableStreamDefaultController; - global.WritableStreamDefaultWriter = NodeWritableStreamDefaultWriter; } diff --git a/packages/remix-node/package.json b/packages/remix-node/package.json index 759fd44f654..cf517ea7e75 100644 --- a/packages/remix-node/package.json +++ b/packages/remix-node/package.json @@ -21,13 +21,11 @@ }, "dependencies": { "@remix-run/server-runtime": "workspace:*", - "@remix-run/web-fetch": "^4.4.2", - "@remix-run/web-file": "^3.1.0", - "@remix-run/web-stream": "^1.1.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie-signature": "^1.1.0", "source-map-support": "^0.5.21", - "stream-slice": "^0.1.2" + "stream-slice": "^0.1.2", + "undici": "^6.10.1" }, "devDependencies": { "@types/cookie-signature": "^1.0.3", 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/__tests__/setup.ts b/packages/remix-react/__tests__/setup.ts index 451031301ac..87f5cefb48f 100644 --- a/packages/remix-react/__tests__/setup.ts +++ b/packages/remix-react/__tests__/setup.ts @@ -1,3 +1,8 @@ -import { installGlobals } from "@remix-run/node"; +const JSDOMFormData = global.FormData; +global.TextDecoder = require("util").TextDecoder; +global.TextEncoder = require("util").TextEncoder; +global.ReadableStream = require("stream/web").ReadableStream; +global.WritableStream = require("stream/web").WritableStream; -installGlobals(); +require("@remix-run/node").installGlobals(); +global.FormData = JSDOMFormData; 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..390cab12b8d 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,41 @@ function PrefetchPageLinksImpl({ // just the manifest like the other links in here. let keyedPrefetchLinks = useKeyedPrefetchLinks(newMatchesForAssets); + let linksToRender: React.ReactNode | React.ReactNode[] | null = null; + if (!future.unstable_singleFetch) { + // Non-single-fetch prefetching + linksToRender = dataHrefs.map((href) => ( + + )); + } else if (newMatchesForData.length > 0) { + // Single-fetch with routes that require data + let url = addRevalidationParam( + manifest, + routeModules, + nextMatches.map((m) => m.route), + newMatchesForData.map((m) => m.route), + singleFetchUrl(page) + ); + if (url.searchParams.get("_routes") !== "") { + linksToRender = ( + + ); + } else { + // No single-fetch prefetching if _routes param is empty due to `clientLoader`'s + } + } else { + // No single-fetch prefetching if there are no new matches for data + } + return ( <> - {dataHrefs.map((href) => ( - - ))} + {linksToRender} {moduleHrefs.map((href) => ( ))} @@ -622,12 +653,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 +732,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..2df962276c5 --- /dev/null +++ b/packages/remix-react/single-fetch.tsx @@ -0,0 +1,370 @@ +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 { escapeHtml } from "./markup"; +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 ? ( +