diff --git a/integration/CHANGELOG.md b/integration/CHANGELOG.md
new file mode 100644
index 0000000000..6fccf850d7
--- /dev/null
+++ b/integration/CHANGELOG.md
@@ -0,0 +1,15 @@
+# integration-tests
+
+## 0.0.0
+
+### Minor Changes
+
+- Unstable Vite support for Node-based Remix apps ([#7590](https://github.com/remix-run/remix/pull/7590))
+
+ - `remix build` 👉 `vite build && vite build --ssr`
+ - `remix dev` 👉 `vite dev`
+
+ Other runtimes (e.g. Deno, Cloudflare) not yet supported.
+ Custom server (e.g. Express) not yet supported.
+
+ See "Future > Vite" in the Remix Docs for details.
diff --git a/integration/abort-signal-test.ts b/integration/abort-signal-test.ts
new file mode 100644
index 0000000000..34d148e0ca
--- /dev/null
+++ b/integration/abort-signal-test.ts
@@ -0,0 +1,66 @@
+import { test } from "@playwright/test";
+
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+
+let fixture: Fixture;
+let appFixture: AppFixture;
+
+test.beforeAll(async () => {
+ fixture = await createFixture({
+ files: {
+ "app/routes/_index.tsx": js`
+ import { json } from "@remix-run/node";
+ import { useActionData, useLoaderData, Form } from "@remix-run/react";
+
+ export async function action ({ request }) {
+ // New event loop causes express request to close
+ await new Promise(r => setTimeout(r, 0));
+ return json({ aborted: request.signal.aborted });
+ }
+
+ export function loader({ request }) {
+ return json({ aborted: request.signal.aborted });
+ }
+
+ export default function Index() {
+ let actionData = useActionData();
+ let data = useLoaderData();
+ return (
+
+
{actionData ? String(actionData.aborted) : "empty"}
+
{String(data.aborted)}
+
+
+ )
+ }
+ `,
+ },
+ });
+
+ // This creates an interactive app using playwright.
+ appFixture = await createAppFixture(fixture);
+});
+
+test.afterAll(() => {
+ appFixture.close();
+});
+
+test("should not abort the request in a new event loop", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await page.waitForSelector(`.action:has-text("empty")`);
+ await page.waitForSelector(`.loader:has-text("false")`);
+
+ await app.clickElement('button[type="submit"]');
+
+ await page.waitForSelector(`.action:has-text("false")`);
+ await page.waitForSelector(`.loader:has-text("false")`);
+});
diff --git a/integration/action-test.ts b/integration/action-test.ts
new file mode 100644
index 0000000000..b6de9ef020
--- /dev/null
+++ b/integration/action-test.ts
@@ -0,0 +1,428 @@
+import { test, expect } from "@playwright/test";
+
+import {
+ createFixture,
+ createAppFixture,
+ 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("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({
+ 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 (
+
+ );
+ }
+ `,
+
+ "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 (
+
+ );
+ }
+ `,
+
+ [`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.collectDataResponses();
+ await app.clickSubmitButton(`/${THROWS_REDIRECT}`);
+
+ await page.waitForSelector(`#${REDIRECT_TARGET}`);
+
+ expect(responses.length).toBe(1);
+ expect(responses[0].status()).toBe(204);
+
+ expect(new URL(page.url()).pathname).toBe(`/${REDIRECT_TARGET}`);
+ expect(await app.getHtml()).toMatch(PAGE_TEXT);
+ });
+});
+
+// Duplicate suite of the tests above running with single fetch enabled
+// TODO(v3): remove the above suite of tests and just keep these
+test.describe("single fetch", () => {
+ test.describe("actions", () => {
+ let fixture: Fixture;
+ let appFixture: AppFixture;
+
+ let FIELD_NAME = "message";
+ let WAITING_VALUE = "Waiting...";
+ let SUBMITTED_VALUE = "Submission";
+ let THROWS_REDIRECT = "redirect-throw";
+ let REDIRECT_TARGET = "page";
+ let PAGE_TEXT = "PAGE_TEXT";
+
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ 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 (
+
+ );
+ }
+ `,
+
+ "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 (
+
+ );
+ }
+ `,
+
+ [`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/assets/toupload.txt b/integration/assets/toupload.txt
new file mode 100644
index 0000000000..b45ef6fec8
--- /dev/null
+++ b/integration/assets/toupload.txt
@@ -0,0 +1 @@
+Hello, World!
\ No newline at end of file
diff --git a/integration/assets/touploadtoobig.txt b/integration/assets/touploadtoobig.txt
new file mode 100644
index 0000000000..8811b05287
--- /dev/null
+++ b/integration/assets/touploadtoobig.txt
@@ -0,0 +1 @@
+Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!
\ No newline at end of file
diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts
new file mode 100644
index 0000000000..b5eeb96d14
--- /dev/null
+++ b/integration/browser-entry-test.ts
@@ -0,0 +1,88 @@
+import { test, expect } from "@playwright/test";
+
+import type { AppFixture, Fixture } from "./helpers/create-fixture.js";
+import {
+ createFixture,
+ js,
+ createAppFixture,
+} from "./helpers/create-fixture.js";
+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`
+ import { Link } from "@remix-run/react";
+
+ export default function Index() {
+ return (
+
+ )
+ }
+ `,
+
+ "app/routes/burgers.tsx": js`
+ export default function Index() {
+ return cheeseburger
;
+ }
+ `,
+ },
+ });
+
+ // This creates an interactive app using puppeteer.
+ appFixture = await createAppFixture(fixture);
+});
+
+test.afterAll(() => appFixture.close());
+
+test(
+ "expect to be able to browse backward out of a remix app, then forward " +
+ "twice in history and have pages render correctly",
+ async ({ page, browserName }) => {
+ test.skip(
+ browserName === "firefox",
+ "FireFox doesn't support browsing to an empty page (aka about:blank)"
+ );
+
+ let app = new PlaywrightFixture(appFixture, page);
+
+ // Slow down the entry chunk on the second load so the bug surfaces
+ let isSecondLoad = false;
+ await page.route(/entry/, async (route) => {
+ if (isSecondLoad) {
+ await new Promise((r) => setTimeout(r, 1000));
+ }
+ route.continue();
+ });
+
+ // This sets up the Remix modules cache in memory, priming the error case.
+ await app.goto("/");
+ await app.clickLink("/burgers");
+ expect(await page.content()).toContain("cheeseburger");
+ await page.goBack();
+ await page.waitForSelector("#pizza");
+ expect(await app.getHtml()).toContain("pizza");
+
+ // Takes the browser out of the Remix app
+ await page.goBack();
+ expect(page.url()).toContain("about:blank");
+
+ // Forward to / and immediately again to /burgers. This will trigger the
+ // error since we'll load __routeModules for / but then try to hydrate /burgers
+ isSecondLoad = true;
+ await page.goForward();
+ await page.goForward();
+ await page.waitForSelector("#cheeseburger");
+
+ // If we resolve the error, we should hard reload and eventually
+ // successfully render /burgers
+ await page.waitForSelector("#cheeseburger");
+ expect(await app.getHtml()).toContain("cheeseburger");
+ }
+);
diff --git a/integration/bug-report-test.ts b/integration/bug-report-test.ts
new file mode 100644
index 0000000000..adc4beb59a
--- /dev/null
+++ b/integration/bug-report-test.ts
@@ -0,0 +1,121 @@
+import { test, expect } from "@playwright/test";
+
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+
+let fixture: Fixture;
+let appFixture: AppFixture;
+
+////////////////////////////////////////////////////////////////////////////////
+// 💿 👋 Hola! It's me, Dora the Remix Disc, I'm here to help you write a great
+// bug report pull request.
+//
+// You don't need to fix the bug, this is just to report one.
+//
+// The pull request you are submitting is supposed to fail when created, to let
+// the team see the erroneous behavior, and understand what's going wrong.
+//
+// If you happen to have a fix as well, it will have to be applied in a subsequent
+// commit to this pull request, and your now-succeeding test will have to be moved
+// to the appropriate file.
+//
+// First, make sure to install dependencies and build Remix. From the root of
+// the project, run this:
+//
+// ```
+// pnpm install && pnpm build
+// ```
+//
+// Now try running this test:
+//
+// ```
+// pnpm bug-report-test
+// ```
+//
+// You can add `--watch` to the end to have it re-run on file changes:
+//
+// ```
+// pnpm bug-report-test --watch
+// ```
+////////////////////////////////////////////////////////////////////////////////
+
+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({
+ ////////////////////////////////////////////////////////////////////////////
+ // 💿 Next, add files to this object, just like files in a real app,
+ // `createFixture` will make an app and run your tests against it.
+ ////////////////////////////////////////////////////////////////////////////
+ files: {
+ "app/routes/_index.tsx": js`
+ import { json } from "@remix-run/node";
+ import { useLoaderData, Link } from "@remix-run/react";
+
+ export function loader() {
+ return json("pizza");
+ }
+
+ export default function Index() {
+ let data = useLoaderData();
+ return (
+
+ {data}
+ Other Route
+
+ )
+ }
+ `,
+
+ "app/routes/burgers.tsx": js`
+ export default function Index() {
+ return cheeseburger
;
+ }
+ `,
+ },
+ });
+
+ // This creates an interactive app using playwright.
+ appFixture = await createAppFixture(fixture);
+});
+
+test.afterAll(() => {
+ appFixture.close();
+});
+
+////////////////////////////////////////////////////////////////////////////////
+// 💿 Almost done, now write your failing test case(s) down here Make sure to
+// add a good description for what you expect Remix to do 👇🏽
+////////////////////////////////////////////////////////////////////////////////
+
+test("[description of what you expect it to do]", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ // You can test any request your app might get using `fixture`.
+ let response = await fixture.requestDocument("/");
+ expect(await response.text()).toMatch("pizza");
+
+ // If you need to test interactivity use the `app`
+ await app.goto("/");
+ await app.clickLink("/burgers");
+ await page.waitForSelector("text=cheeseburger");
+
+ // If you're not sure what's going on, you can "poke" the app, it'll
+ // automatically open up in your browser for 20 seconds, so be quick!
+ // await app.poke(20);
+
+ // Go check out the other tests to see what else you can do.
+});
+
+////////////////////////////////////////////////////////////////////////////////
+// 💿 Finally, push your changes to your fork of Remix and open a pull request!
+////////////////////////////////////////////////////////////////////////////////
diff --git a/integration/catch-boundary-data-test.ts b/integration/catch-boundary-data-test.ts
new file mode 100644
index 0000000000..708f1124e2
--- /dev/null
+++ b/integration/catch-boundary-data-test.ts
@@ -0,0 +1,472 @@
+import { test, expect } from "@playwright/test";
+
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+
+let fixture: Fixture;
+let appFixture: AppFixture;
+
+let ROOT_BOUNDARY_TEXT = "ROOT_TEXT" as const;
+let LAYOUT_BOUNDARY_TEXT = "LAYOUT_BOUNDARY_TEXT" as const;
+let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT" as const;
+
+let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const;
+let NO_BOUNDARY_LOADER = "/no/loader" as const;
+
+let HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE =
+ "/yes.loader-layout-boundary" as const;
+let HAS_BOUNDARY_LAYOUT_NESTED_LOADER = "/yes/loader-layout-boundary" as const;
+
+let HAS_BOUNDARY_NESTED_LOADER_FILE = "/yes.loader-self-boundary" as const;
+let HAS_BOUNDARY_NESTED_LOADER = "/yes/loader-self-boundary" as const;
+
+let ROOT_DATA = "root data";
+let LAYOUT_DATA = "root data";
+
+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({
+ 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 (
+
+ );
+ }
+ `,
+
+ [`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}")`
+ );
+ });
+});
+
+// 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 (
+
+ );
+ }
+ `,
+
+ [`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
new file mode 100644
index 0000000000..1817988b4d
--- /dev/null
+++ b/integration/catch-boundary-test.ts
@@ -0,0 +1,734 @@
+import { test, expect } from "@playwright/test";
+
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+
+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({
+ 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}
+
+
+
+
+ ${HAS_BOUNDARY_LOADER}
+
+
+ ${HAS_BOUNDARY_LOADER}/child
+
+
+ ${NO_BOUNDARY_LOADER}
+
+
+ )
+ }
+ `,
+
+ [`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 (
+
+ )
+ }
+ `,
+
+ "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");
+ });
+});
+
+// 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}
+
+
+
+
+ ${HAS_BOUNDARY_LOADER}
+
+
+ ${HAS_BOUNDARY_LOADER}/child
+
+
+ ${NO_BOUNDARY_LOADER}
+
+
+ )
+ }
+ `,
+
+ [`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 (
+
+ )
+ }
+ `,
+
+ "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/cf-compiler-test.ts b/integration/cf-compiler-test.ts
new file mode 100644
index 0000000000..c6ae70fd4e
--- /dev/null
+++ b/integration/cf-compiler-test.ts
@@ -0,0 +1,189 @@
+import { test, expect } from "@playwright/test";
+import fs from "node:fs/promises";
+import path from "node:path";
+import shell from "shelljs";
+import glob from "glob";
+
+import { createFixtureProject, js, json } from "./helpers/create-fixture.js";
+
+const searchFiles = async (pattern: string | RegExp, files: string[]) => {
+ let result = shell.grep("-l", pattern, files);
+ return result.stdout
+ .trim()
+ .split("\n")
+ .filter((line) => line.length > 0);
+};
+
+const findCodeFiles = async (directory: string) =>
+ glob.sync("**/*.@(js|jsx|ts|tsx)", {
+ cwd: directory,
+ absolute: true,
+ });
+
+test.describe("cloudflare compiler", () => {
+ let projectDir: string;
+
+ let findBrowserBundle = (projectDir: string): string =>
+ path.resolve(projectDir, "public", "build");
+
+ test.beforeAll(async () => {
+ projectDir = await createFixtureProject({
+ template: "cf-template",
+ files: {
+ "package.json": json({
+ name: "remix-template-cloudflare-workers",
+ private: true,
+ sideEffects: false,
+ type: "module",
+ dependencies: {
+ "@cloudflare/kv-asset-handler": "0.0.0-local-version",
+ "@remix-run/cloudflare": "0.0.0-local-version",
+ "@remix-run/react": "0.0.0-local-version",
+ isbot: "0.0.0-local-version",
+ react: "0.0.0-local-version",
+ "react-dom": "0.0.0-local-version",
+
+ "worker-pkg": "0.0.0-local-version",
+ "browser-pkg": "0.0.0-local-version",
+ "esm-only-pkg": "0.0.0-local-version",
+ "cjs-only-pkg": "0.0.0-local-version",
+ },
+ devDependencies: {
+ "@cloudflare/workers-types": "0.0.0-local-version",
+ "@remix-run/dev": "0.0.0-local-version",
+ },
+ }),
+
+ "app/routes/_index.tsx": js`
+ import fake from "worker-pkg";
+ import { content as browserPackage } from "browser-pkg";
+ import { content as esmOnlyPackage } from "esm-only-pkg";
+ import { content as cjsOnlyPackage } from "cjs-only-pkg";
+ import hooks, {AsyncLocalStorage} from "node:async_hooks";
+
+ export async function loader() {
+ console.log(hooks, AsyncLocalStorage);
+
+ return null;
+ }
+
+ export default function Index() {
+ return (
+
+ {fake}
+ {browserPackage}
+ {esmOnlyPackage}
+ {cjsOnlyPackage}
+
+ )
+ }
+ `,
+ "node_modules/worker-pkg/package.json": json({
+ name: "worker-pkg",
+ version: "1.0.0",
+ type: "module",
+ main: "./default.js",
+ exports: {
+ worker: "./worker.js",
+ default: "./default.js",
+ },
+ }),
+ "node_modules/worker-pkg/worker.js": js`
+ export default "__WORKER_EXPORTS_SHOULD_BE_IN_BUNDLE__";
+ `,
+ "node_modules/worker-pkg/default.js": js`
+ export default "__DEFAULT_EXPORTS_SHOULD_NOT_BE_IN_BUNDLE__";
+ `,
+ "node_modules/browser-pkg/package.json": json({
+ name: "browser-pkg",
+ version: "1.0.0",
+ main: "./node-cjs.js",
+ module: "./node-esm.mjs",
+ browser: {
+ "./node-cjs.js": "./browser-cjs.js",
+ "./node-esm.mjs": "./browser-esm.mjs",
+ },
+ }),
+ "node_modules/browser-pkg/browser-esm.mjs": js`
+ export const content = "browser-pkg/browser-esm.mjs";
+ `,
+ "node_modules/browser-pkg/browser-cjs.js": js`
+ module.exports = { content: "browser-pkg/browser-cjs.js" };
+ `,
+ "node_modules/browser-pkg/node-esm.mjs": js`
+ export const content = "browser-pkg/node-esm.mjs";
+ `,
+ "node_modules/browser-pkg/node-cjs.js": js`
+ module.exports = { content: "browser-pkg/node-cjs.js" };
+ `,
+ "node_modules/esm-only-pkg/package.json": json({
+ name: "esm-only-pkg",
+ version: "1.0.0",
+ type: "module",
+ main: "./node-esm.js",
+ browser: "./browser-esm.js",
+ }),
+ "node_modules/esm-only-pkg/browser-esm.js": js`
+ export const content = "esm-only-pkg/browser-esm.js";
+ `,
+ "node_modules/esm-only-pkg/node-esm.js": js`
+ export const content = "esm-only-pkg/node-esm.js";
+ `,
+ "node_modules/cjs-only-pkg/package.json": json({
+ name: "cjs-only-pkg",
+ version: "1.0.0",
+ main: "./node-cjs.js",
+ browser: "./browser-cjs.js",
+ }),
+ "node_modules/cjs-only-pkg/browser-cjs.js": js`
+ module.exports = { content: "cjs-only-pkg/browser-cjs.js" };
+ `,
+ "node_modules/cjs-only-pkg/node-cjs.js": js`
+ module.exports = { content: "cjs-only-pkg/node-cjs.js" };
+ `,
+ },
+ });
+ });
+
+ test("bundles browser entry of 3rd party package correctly", async () => {
+ let serverBundle = await fs.readFile(
+ path.resolve(projectDir, "build/index.js"),
+ "utf8"
+ );
+
+ expect(serverBundle).not.toMatch("browser-pkg/browser-esm.mjs");
+ expect(serverBundle).not.toMatch("browser-pkg/browser-cjs.js");
+ expect(serverBundle).toMatch("browser-pkg/node-esm.mjs");
+ expect(serverBundle).not.toMatch("browser-pkg/node-cjs.js");
+
+ expect(serverBundle).toMatch("esm-only-pkg/browser-esm.js");
+ expect(serverBundle).not.toMatch("esm-only-pkg/node-esm.js");
+
+ expect(serverBundle).toMatch("cjs-only-pkg/browser-cjs.js");
+ expect(serverBundle).not.toMatch("cjs-only-pkg/node-cjs.js");
+ });
+
+ test("bundles worker export of 3rd party package", async () => {
+ let serverBundle = await fs.readFile(
+ path.resolve(projectDir, "build/index.js"),
+ "utf8"
+ );
+
+ expect(serverBundle).toMatch("__WORKER_EXPORTS_SHOULD_BE_IN_BUNDLE__");
+ expect(serverBundle).not.toMatch(
+ "__DEFAULT_EXPORTS_SHOULD_NOT_BE_IN_BUNDLE__"
+ );
+ });
+
+ test("node externals are not bundled in the browser bundle", async () => {
+ let browserBundle = findBrowserBundle(projectDir);
+ let browserCodeFiles = await findCodeFiles(browserBundle);
+
+ let asyncHooks = await searchFiles(
+ /async_hooks|AsyncLocalStorage/,
+ browserCodeFiles
+ );
+
+ expect(asyncHooks).toHaveLength(0);
+ });
+});
diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts
new file mode 100644
index 0000000000..2269fd41fc
--- /dev/null
+++ b/integration/client-data-test.ts
@@ -0,0 +1,2649 @@
+import { test, expect } from "@playwright/test";
+
+import { ServerMode } from "../build/node_modules/@remix-run/server-runtime/dist/mode.js";
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+import type { AppFixture, FixtureInit } from "./helpers/create-fixture.js";
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+
+function getFiles({
+ parentClientLoader,
+ parentClientLoaderHydrate,
+ parentAdditions,
+ childClientLoader,
+ childClientLoaderHydrate,
+ childAdditions,
+}: {
+ parentClientLoader: boolean;
+ parentClientLoaderHydrate: boolean;
+ parentAdditions?: string;
+ childClientLoader: boolean;
+ childClientLoaderHydrate: boolean;
+ childAdditions?: string;
+}) {
+ return {
+ "app/root.tsx": js`
+ import { Outlet, Scripts } from '@remix-run/react'
+
+ export default function Root() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+ `,
+ "app/routes/_index.tsx": js`
+ import { Link } from '@remix-run/react'
+ export default function Component() {
+ return Go to /parent/child
+ }
+ `,
+ "app/routes/parent.tsx": js`
+ import { json } from '@remix-run/node'
+ import { Outlet, useLoaderData } from '@remix-run/react'
+ export function loader() {
+ return json({ message: 'Parent Server Loader'});
+ }
+ ${
+ parentClientLoader
+ ? js`
+ export async function clientLoader({ serverLoader }) {
+ // Need a small delay to ensure we capture the server-rendered
+ // fallbacks for assertions
+ await new Promise(r => setTimeout(r, 100))
+ let data = await serverLoader();
+ return { message: data.message + " (mutated by client)" };
+ }
+ `
+ : ""
+ }
+ ${
+ parentClientLoaderHydrate
+ ? js`
+ clientLoader.hydrate = true;
+ export function HydrateFallback() {
+ return Parent Fallback
+ }
+ `
+ : ""
+ }
+ ${parentAdditions || ""}
+ export default function Component() {
+ let data = useLoaderData();
+ return (
+ <>
+ {data.message}
+
+ >
+ );
+ }
+ `,
+ "app/routes/parent.child.tsx": js`
+ import { json } from '@remix-run/node'
+ import { Form, Outlet, useActionData, useLoaderData } from '@remix-run/react'
+ export function loader() {
+ return json({ message: 'Child Server Loader'});
+ }
+ export function action() {
+ return json({ message: 'Child Server Action'});
+ }
+ ${
+ childClientLoader
+ ? js`
+ export async function clientLoader({ serverLoader }) {
+ // Need a small delay to ensure we capture the server-rendered
+ // fallbacks for assertions
+ await new Promise(r => setTimeout(r, 100))
+ let data = await serverLoader();
+ return { message: data.message + " (mutated by client)" };
+ }
+ `
+ : ""
+ }
+ ${
+ childClientLoaderHydrate
+ ? js`
+ clientLoader.hydrate = true;
+ export function HydrateFallback() {
+ return Child Fallback
+ }
+ `
+ : ""
+ }
+ ${childAdditions || ""}
+ export default function Component() {
+ let data = useLoaderData();
+ let actionData = useActionData();
+ return (
+ <>
+ {data.message}
+
+ >
+ );
+ }
+ `,
+ };
+}
+
+test.describe("Client Data", () => {
+ let appFixture: AppFixture;
+
+ test.afterAll(() => {
+ 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 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}
+ revalidator.revalidate()}>Revalidate
+ >
+ );
+ }
+ 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}
+ revalidator.revalidate()}>Revalidate
+ >
+ );
+ }
+ 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("server loader errors are persisted for non-hydrating routes", async ({
+ page,
+ }) => {
+ let _consoleError = console.error;
+ console.error = () => {};
+ appFixture = await createAppFixture(
+ await createFixture(
+ {
+ files: {
+ ...getFiles({
+ parentClientLoader: true,
+ parentClientLoaderHydrate: false,
+ // Hydrate the parent clientLoader but don't add a HydrateFallback
+ parentAdditions: js`
+ clientLoader.hydrate = true;
+ `,
+ childClientLoader: false,
+ childClientLoaderHydrate: false,
+ }),
+ "app/routes/parent.child.tsx": js`
+ import { json } from '@remix-run/node'
+ import { useRouteError } from '@remix-run/react'
+ export function loader() {
+ throw json({ message: 'Child Server Error'});
+ }
+ export default function Component() {
+ return Should not see me ;
+ }
+ export function ErrorBoundary() {
+ const error = useRouteError();
+ return (
+ <>
+ Child Error
+ {JSON.stringify(error, null, 2)}
+ >
+ );
+ }
+ `,
+ },
+ },
+ ServerMode.Development // Avoid error sanitization
+ ),
+ ServerMode.Development // Avoid error sanitization
+ );
+ let app = new PlaywrightFixture(appFixture, page);
+
+ await app.goto("/parent/child", false);
+ let html = await app.getHtml("main");
+ expect(html).toMatch("Parent Server Loader");
+ expect(html).toMatch("Child Server Error");
+ expect(html).not.toMatch("Should not see me");
+ // Ensure we hydrate and remain on the boundary
+ await page.waitForSelector(
+ ":has-text('Parent Server Loader (mutated by client)')"
+ );
+ html = await app.getHtml("main");
+ expect(html).toMatch("Parent Server Loader (mutated by client)");
+ expect(html).toMatch("Child Server Error");
+ expect(html).not.toMatch("Should not see me");
+ console.error = _consoleError;
+ });
+ });
+
+ test.describe("clientLoader - lazy route module", () => {
+ 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)"
+ }
+ }
+ `,
+ }),
+ })
+ );
+ 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")'
+ );
+ });
+ });
+});
+
+// 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}
+ revalidator.revalidate()}>Revalidate
+ >
+ );
+ }
+ 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}
+ revalidator.revalidate()}>Revalidate
+ >
+ );
+ }
+ 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/compiler-mjs-cjs-output-test.ts b/integration/compiler-mjs-cjs-output-test.ts
new file mode 100644
index 0000000000..86b03edd7a
--- /dev/null
+++ b/integration/compiler-mjs-cjs-output-test.ts
@@ -0,0 +1,35 @@
+import { test, expect } from "@playwright/test";
+import * as fs from "node:fs";
+import * as path from "node:path";
+
+import { createFixtureProject, js } from "./helpers/create-fixture.js";
+
+test.describe("", () => {
+ for (let [serverModuleExt, serverModuleFormat, exportStatement] of [
+ ["mjs", "esm", "export {"],
+ ["cjs", "cjs", "module.exports ="],
+ ]) {
+ test(`can write .${serverModuleExt} server output module`, async () => {
+ let projectDir = await createFixtureProject({
+ files: {
+ // Ensure the config is valid ESM
+ "remix.config.js": js`
+ export default {
+ serverModuleFormat: "${serverModuleFormat}",
+ serverBuildPath: "build/index.${serverModuleExt}",
+ };
+ `,
+ },
+ });
+
+ let buildPath = path.resolve(
+ projectDir,
+ "build",
+ `index.${serverModuleExt}`
+ );
+ expect(fs.existsSync(buildPath), "doesn't exist").toBe(true);
+ let contents = fs.readFileSync(buildPath, "utf8");
+ expect(contents, "no export statement").toContain(exportStatement);
+ });
+ }
+});
diff --git a/integration/compiler-test.ts b/integration/compiler-test.ts
new file mode 100644
index 0000000000..a9ff47aa2c
--- /dev/null
+++ b/integration/compiler-test.ts
@@ -0,0 +1,348 @@
+import path from "node:path";
+import fse from "fs-extra";
+import { test, expect } from "@playwright/test";
+
+import {
+ createFixture,
+ createAppFixture,
+ js,
+ json,
+ css,
+} from "./helpers/create-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+
+test.describe("compiler", () => {
+ let fixture: Fixture;
+ let appFixture: AppFixture;
+
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ files: {
+ // We need a custom config file here to test usage of `getDependenciesToBundle`
+ // since this can't be serialized from the fixture object.
+ "remix.config.js": js`
+ import { getDependenciesToBundle } from "@remix-run/dev";
+ export default {
+ serverDependenciesToBundle: [
+ "esm-only-pkg",
+ "esm-only-single-export",
+ ...getDependenciesToBundle("esm-only-exports-pkg"),
+ ...getDependenciesToBundle("esm-only-nested-exports-pkg"),
+ ],
+ browserNodeBuiltinsPolyfill: {
+ modules: {
+ path: true,
+ },
+ },
+ };
+ `,
+ "app/fake.server.ts": js`
+ export const hello = "server";
+ `,
+ "app/fake.client.ts": js`
+ export const hello = "client";
+ `,
+ "app/fake.ts": js`
+ import { hello as clientHello } from "./fake.client.js";
+ import { hello as serverHello } from "./fake.server.js";
+ export default clientHello || serverHello;
+ `,
+ "app/routes/_index.tsx": js`
+ import fake from "~/fake";
+
+ export default function Index() {
+ let hasRightModule = fake === (typeof document === "undefined" ? "server" : "client");
+ return {String(hasRightModule)}
+ }
+ `,
+ "app/routes/built-ins.tsx": js`
+ import { useLoaderData } from "@remix-run/react";
+ import * as path from "node:path";
+
+ export let loader = () => {
+ return path.join("test", "file.txt");
+ }
+
+ export default function BuiltIns() {
+ return {useLoaderData()}
+ }
+ `,
+ "app/routes/built-ins-polyfill.tsx": js`
+ import { useLoaderData } from "@remix-run/react";
+ import * as path from "node:path";
+
+ export default function BuiltIns() {
+ return {path.join("test", "file.txt")}
;
+ }
+ `,
+ "app/routes/esm-only-pkg.tsx": js`
+ import esmOnlyPkg from "esm-only-pkg";
+
+ export default function EsmOnlyPkg() {
+ return {esmOnlyPkg}
;
+ }
+ `,
+ "app/routes/esm-only-exports-pkg.tsx": js`
+ import esmOnlyPkg from "esm-only-exports-pkg";
+
+ export default function EsmOnlyPkg() {
+ return {esmOnlyPkg}
;
+ }
+ `,
+ "app/routes/esm-only-nested-exports-pkg.tsx": js`
+ import esmOnlyPkg from "esm-only-nested-exports-pkg/nested";
+
+ export default function EsmOnlyPkg() {
+ return {esmOnlyPkg}
;
+ }
+ `,
+ "app/routes/esm-only-single-export.tsx": js`
+ import esmOnlyPkg from "esm-only-single-export";
+
+ export default function EsmOnlyPkg() {
+ return {esmOnlyPkg}
;
+ }
+ `,
+ "app/routes/package-with-submodule.jsx": js`
+ import { submodule } from "@org/package/sub-package/index.js";
+
+ export default function PackageWithSubModule() {
+ return {submodule()}
;
+ }
+ `,
+ "app/routes/css.tsx": js`
+ import stylesUrl from "@org/css/index.css";
+
+ export function links() {
+ return [{ rel: "stylesheet", href: stylesUrl }]
+ }
+
+ export default function PackageWithSubModule() {
+ return {submodule()}
;
+ }
+ `,
+ "node_modules/esm-only-pkg/package.json": json({
+ name: "esm-only-pkg",
+ version: "1.0.0",
+ type: "module",
+ main: "./esm-only-pkg.js",
+ }),
+ "node_modules/esm-only-pkg/esm-only-pkg.js": js`
+ export default "esm-only-pkg";
+ `,
+ "node_modules/esm-only-exports-pkg/package.json": json({
+ name: "esm-only-exports-pkg",
+ version: "1.0.0",
+ type: "module",
+ exports: {
+ ".": "./esm-only-exports-pkg.js",
+ },
+ }),
+ "node_modules/esm-only-exports-pkg/esm-only-exports-pkg.js": js`
+ export default "esm-only-exports-pkg";
+ `,
+ "node_modules/esm-only-nested-exports-pkg/package.json": json({
+ name: "esm-only-nested-exports-pkg",
+ version: "1.0.0",
+ type: "module",
+ exports: {
+ "./package.json": "./package.json",
+ "./nested": "./esm-only-nested-exports-pkg.js",
+ },
+ }),
+ "node_modules/esm-only-nested-exports-pkg/esm-only-nested-exports-pkg.js": js`
+ export default "esm-only-nested-exports-pkg";
+ `,
+ "node_modules/esm-only-single-export/package.json": json({
+ name: "esm-only-exports-pkg",
+ version: "1.0.0",
+ type: "module",
+ exports: "./esm-only-single-export.js",
+ }),
+ "node_modules/esm-only-single-export/esm-only-single-export.js": js`
+ export default "esm-only-single-export";
+ `,
+ "node_modules/@org/package/package.json": json({
+ name: "@org/package",
+ version: "1.0.0",
+ }),
+ "node_modules/@org/package/sub-package/package.json": json({
+ module: "./index.js",
+ exports: "./index.js",
+ main: "./index.js",
+ sideEffects: false,
+ }),
+ "node_modules/@org/package/sub-package/index.js": js`
+ module.exports.submodule = require("./submodule.js");
+ `,
+ "node_modules/@org/package/sub-package/submodule.js": js`
+ module.exports = function submodule() {
+ return "package-with-submodule";
+ }
+ `,
+ "node_modules/@org/package/sub-package/esm/package.json": json({
+ type: "module",
+ sideEffects: false,
+ exports: "./esm/index.js",
+ main: "./esm/index.js",
+ }),
+ "node_modules/@org/package/sub-package/esm/index.js": js`
+ export { default as submodule } from "./submodule.js";
+ `,
+ "node_modules/@org/package/sub-package/esm/submodule.js": js`
+ export default function submodule() {
+ return "package-with-submodule";
+ }
+ `,
+ "node_modules/@org/css/package.json": json({
+ name: "@org/css",
+ version: "1.0.0",
+ main: "index.css",
+ }),
+ "node_modules/@org/css/font.woff2": "font",
+ "node_modules/@org/css/index.css": css`
+ body {
+ background: red;
+ }
+
+ @font-face {
+ font-family: "MyFont";
+ src: url("./font.woff2");
+ }
+ `,
+ },
+ });
+
+ appFixture = await createAppFixture(fixture);
+ });
+
+ test.afterAll(() => {
+ appFixture.close();
+ });
+
+ test("removes server code with `*.server` files", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await app.goto("/", true);
+ expect(res.status()).toBe(200); // server rendered fine
+
+ // rendered the page instead of the error boundary
+ expect(await app.getHtml("#index")).toBe('true
');
+ });
+
+ test("removes server code with `*.client` files", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await app.goto("/", true);
+ expect(res.status()).toBe(200); // server rendered fine
+
+ // rendered the page instead of the error boundary
+ expect(await app.getHtml("#index")).toBe('true
');
+ });
+
+ test("removes node built-ins from client bundle when used in just loader", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await app.goto("/built-ins", true);
+ expect(res.status()).toBe(200); // server rendered fine
+
+ // rendered the page instead of the error boundary
+ expect(await app.getHtml("#built-ins")).toBe(
+ `test${path.sep}file.txt
`
+ );
+
+ let routeModule = await fixture.getBrowserAsset(
+ fixture.build!.assets.routes["routes/built-ins"].module
+ );
+ // does not include `import bla from "node:path"` in the output bundle
+ expect(routeModule).not.toMatch(/from\s*"path/);
+ });
+
+ test("bundles node built-ins polyfill for client bundle when used in client code", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await app.goto("/built-ins-polyfill", true);
+ expect(res.status()).toBe(200); // server rendered fine
+
+ // rendered the page instead of the error boundary
+ expect(await app.getHtml("#built-ins-polyfill")).toBe(
+ 'test/file.txt
'
+ );
+
+ let routeModule = await fixture.getBrowserAsset(
+ fixture.build!.assets.routes["routes/built-ins-polyfill"].module
+ );
+ // does not include `import bla from "node:path"` in the output bundle
+ expect(routeModule).not.toMatch(/from\s*"path/);
+ });
+
+ test("allows consumption of ESM modules in CJS builds with `serverDependenciesToBundle`", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await app.goto("/esm-only-pkg", true);
+ expect(res.status()).toBe(200); // server rendered fine
+ // rendered the page instead of the error boundary
+ expect(await app.getHtml("#esm-only-pkg")).toBe(
+ 'esm-only-pkg
'
+ );
+ });
+
+ test("allows consumption of ESM modules in CJS builds with `serverDependenciesToBundle` when the package only exports a single file", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await app.goto("/esm-only-single-export", true);
+ expect(res.status()).toBe(200); // server rendered fine
+ // rendered the page instead of the error boundary
+ expect(await app.getHtml("#esm-only-single-export")).toBe(
+ 'esm-only-single-export
'
+ );
+ });
+
+ test("allows consumption of ESM modules with exports in CJS builds with `serverDependenciesToBundle` and `getDependenciesToBundle`", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await app.goto("/esm-only-exports-pkg", true);
+ expect(res.status()).toBe(200); // server rendered fine
+ // rendered the page instead of the error boundary
+ expect(await app.getHtml("#esm-only-exports-pkg")).toBe(
+ 'esm-only-exports-pkg
'
+ );
+ });
+
+ test("allows consumption of ESM modules with only nested exports in CJS builds with `serverDependenciesToBundle` and `getDependenciesToBundle`", async ({
+ page,
+ }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await app.goto("/esm-only-nested-exports-pkg", true);
+ expect(res.status()).toBe(200); // server rendered fine
+ // rendered the page instead of the error boundary
+ expect(await app.getHtml("#esm-only-nested-exports-pkg")).toBe(
+ 'esm-only-nested-exports-pkg
'
+ );
+ });
+
+ test("allows consumption of packages with sub modules", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let res = await app.goto("/package-with-submodule", true);
+ expect(res.status()).toBe(200); // server rendered fine
+ // rendered the page instead of the error boundary
+ expect(await app.getHtml("#package-with-submodule")).toBe(
+ 'package-with-submodule
'
+ );
+ });
+
+ test("copies imports in css files to assetsBuildDirectory", async () => {
+ let buildDir = path.join(fixture.projectDir, "public", "build", "_assets");
+ let files = await fse.readdir(buildDir);
+ expect(files).toHaveLength(2);
+
+ let cssFile = files.find((file) => file.match(/index-[a-z0-9]{8}\.css/i));
+ let fontFile = files.find((file) => file.match(/font-[a-z0-9]{8}\.woff2/i));
+ expect(cssFile).toBeTruthy();
+ expect(fontFile).toBeTruthy();
+ });
+});
diff --git a/integration/css-modules-test.ts b/integration/css-modules-test.ts
new file mode 100644
index 0000000000..cca4e50c8b
--- /dev/null
+++ b/integration/css-modules-test.ts
@@ -0,0 +1,798 @@
+import { test, expect } from "@playwright/test";
+import globby from "globby";
+import fse from "fs-extra";
+
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import {
+ createAppFixture,
+ createFixture,
+ css,
+ js,
+} from "./helpers/create-fixture.js";
+
+const TEST_PADDING_VALUE = "20px";
+
+test.describe("CSS Modules", () => {
+ let fixture: Fixture;
+ let appFixture: AppFixture;
+
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ files: {
+ "app/root.tsx": js`
+ import { Links, Outlet } from "@remix-run/react";
+ import { cssBundleHref } from "@remix-run/css-bundle";
+ export function links() {
+ return [{ rel: "stylesheet", href: cssBundleHref }];
+ }
+ export default function Root() {
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+ `,
+ ...basicStylesFixture(),
+ ...globalSelectorsFixture(),
+ ...nestedGlobalSelectorsFixture(),
+ ...localClassCompositionFixture(),
+ ...importedClassCompositionFixture(),
+ ...rootRelativeImportedClassCompositionFixture(),
+ ...globalClassCompositionFixture(),
+ ...localValueFixture(),
+ ...importedValueFixture(),
+ ...rootRelativeImportedValueFixture(),
+ ...imageUrlsFixture(),
+ ...rootRelativeImageUrlsFixture(),
+ ...absoluteImageUrlsFixture(),
+ ...clientEntrySideEffectsFixture(),
+ ...deduplicatedCssFixture(),
+ ...uniqueClassNamesFixture(),
+ ...treeShakingFixture(),
+ },
+ });
+ appFixture = await createAppFixture(fixture);
+ });
+
+ test.afterAll(() => appFixture.close());
+
+ let basicStylesFixture = () => ({
+ "app/routes/basic-styles-test.tsx": js`
+ import { Test } from "~/test-components/basic-styles";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/basic-styles/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Basic styles test
+
+ );
+ }
+ `,
+ "app/test-components/basic-styles/styles.module.css": css`
+ .root {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ });
+ test("basic styles", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/basic-styles-test");
+ let locator = await page.locator("[data-testid='basic-styles']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let globalSelectorsFixture = () => ({
+ "app/routes/global-selector-test.tsx": js`
+ import { Test } from "~/test-components/global-selector";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/global-selector/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+
+ Nested global selector test
+
+
+ );
+ }
+ `,
+ "app/test-components/global-selector/styles.module.css": css`
+ :global(.global_container) .root {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ });
+ test("global selectors", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/global-selector-test");
+ let locator = await page.locator("[data-testid='global-selector']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let nestedGlobalSelectorsFixture = () => ({
+ "app/routes/nested-global-selector-test.tsx": js`
+ import { Test } from "~/test-components/nested-global-selector";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/nested-global-selector/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+
+ Nested global selector test
+
+
+ );
+ }
+ `,
+ "app/test-components/nested-global-selector/styles.module.css": css`
+ :global .global_container :local .root {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ });
+ test("nested global selectors", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/nested-global-selector-test");
+ let locator = await page.locator("[data-testid='nested-global-selector']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let localClassCompositionFixture = () => ({
+ "app/routes/local-class-composition-test.tsx": js`
+ import { Test } from "~/test-components/local-class-composition";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/local-class-composition/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Local composes test
+
+ );
+ }
+ `,
+ "app/test-components/local-class-composition/styles.module.css": css`
+ .padding {
+ padding: ${TEST_PADDING_VALUE};
+ }
+ .root {
+ background: peachpuff;
+ composes: padding;
+ }
+ `,
+ });
+ test("local class composition", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/local-class-composition-test");
+ let locator = await page.locator("[data-testid='local-class-composition']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let importedClassCompositionFixture = () => ({
+ "app/routes/imported-class-composition-test.tsx": js`
+ import { Test } from "~/test-components/imported-class-composition";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/imported-class-composition/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Imported class composition test
+
+ );
+ }
+ `,
+ "app/test-components/imported-class-composition/styles.module.css": css`
+ .root {
+ background: peachpuff;
+ composes: padding from "./import.module.css";
+ }
+ `,
+ "app/test-components/imported-class-composition/import.module.css": css`
+ .padding {
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ });
+ test("imported class composition", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/imported-class-composition-test");
+ let locator = await page.locator(
+ "[data-testid='imported-class-composition']"
+ );
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let rootRelativeImportedClassCompositionFixture = () => ({
+ "app/routes/root-relative-imported-class-composition-test.tsx": js`
+ import { Test } from "~/test-components/root-relative-imported-class-composition";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/root-relative-imported-class-composition/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Root relative imported class composition test
+
+ );
+ }
+ `,
+ "app/test-components/root-relative-imported-class-composition/styles.module.css": css`
+ .root {
+ background: peachpuff;
+ composes: padding from "~/test-components/root-relative-imported-class-composition/import.module.css";
+ }
+ `,
+ "app/test-components/root-relative-imported-class-composition/import.module.css": css`
+ .padding {
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ });
+ test("root relative imported class composition", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/root-relative-imported-class-composition-test");
+ let locator = await page.locator(
+ "[data-testid='root-relative-imported-class-composition']"
+ );
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let globalClassCompositionFixture = () => ({
+ "app/routes/global-class-composition-test.tsx": js`
+ import { Test } from "~/test-components/global-class-composition";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/global-class-composition/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Global class composition test
+
+ );
+ }
+ `,
+ "app/test-components/global-class-composition/styles.module.css": css`
+ .root {
+ background: peachpuff;
+ composes: padding from global;
+ }
+ :global(.padding) {
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ });
+ test("global class composition", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/global-class-composition-test");
+ let locator = await page.locator(
+ "[data-testid='global-class-composition']"
+ );
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let localValueFixture = () => ({
+ "app/routes/local-value-test.tsx": js`
+ import { Test } from "~/test-components/local-value";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/local-value/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Local @value test
+
+ );
+ }
+ `,
+ "app/test-components/local-value/styles.module.css": css`
+ @value padding: ${TEST_PADDING_VALUE};
+ .root {
+ background: peachpuff;
+ padding: padding;
+ }
+ `,
+ });
+ test("local @value", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/local-value-test");
+ let locator = await page.locator("[data-testid='local-value']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let importedValueFixture = () => ({
+ "app/routes/imported-value-test.tsx": js`
+ import { Test } from "~/test-components/imported-value";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/imported-value/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Imported @value test
+
+ );
+ }
+ `,
+ "app/test-components/imported-value/styles.module.css": css`
+ @value padding from "./values.module.css";
+ .root {
+ background: peachpuff;
+ padding: padding;
+ }
+ `,
+ "app/test-components/imported-value/values.module.css": css`
+ @value padding: ${TEST_PADDING_VALUE};
+ `,
+ });
+ test("imported @value", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/imported-value-test");
+ let locator = await page.locator("[data-testid='imported-value']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let rootRelativeImportedValueFixture = () => ({
+ "app/routes/root-relative-imported-value-test.tsx": js`
+ import { Test } from "~/test-components/root-relative-imported-value";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/root-relative-imported-value/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Root relative imported @value test
+
+ );
+ }
+ `,
+ "app/test-components/root-relative-imported-value/styles.module.css": css`
+ @value padding from "~/test-components/root-relative-imported-value/values.module.css";
+ .root {
+ background: peachpuff;
+ padding: padding;
+ }
+ `,
+ "app/test-components/root-relative-imported-value/values.module.css": css`
+ @value padding: ${TEST_PADDING_VALUE};
+ `,
+ });
+ test("root relative imported @value", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/root-relative-imported-value-test");
+ let locator = await page.locator(
+ "[data-testid='root-relative-imported-value']"
+ );
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let imageUrlsFixture = () => ({
+ "app/routes/image-urls-test.tsx": js`
+ import { Test } from "~/test-components/image-urls";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/image-urls/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Image URLs test
+
+ );
+ }
+ `,
+ "app/test-components/image-urls/styles.module.css": css`
+ .root {
+ background-color: peachpuff;
+ background-image: url(./image.svg);
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/test-components/image-urls/image.svg": `
+
+
+
+ `,
+ });
+ test("image URLs", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let imgStatus: number | null = null;
+ app.page.on("response", (res) => {
+ if (res.url().endsWith(".svg")) imgStatus = res.status();
+ });
+ await app.goto("/image-urls-test");
+ let locator = await page.locator("[data-testid='image-urls']");
+ let backgroundImage = await locator.evaluate(
+ (element) => window.getComputedStyle(element).backgroundImage
+ );
+ expect(backgroundImage).toContain(".svg");
+ expect(imgStatus).toBe(200);
+ });
+
+ let rootRelativeImageUrlsFixture = () => ({
+ "app/routes/root-relative-image-urls-test.tsx": js`
+ import { Test } from "~/test-components/root-relative-image-urls";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/root-relative-image-urls/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Root relative image URLs test
+
+ );
+ }
+ `,
+ "app/test-components/root-relative-image-urls/styles.module.css": css`
+ .root {
+ background-color: peachpuff;
+ background-image: url(~/test-components/root-relative-image-urls/image.svg);
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/test-components/root-relative-image-urls/image.svg": `
+
+
+
+ `,
+ });
+ test("root relative image URLs", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let imgStatus: number | null = null;
+ app.page.on("response", (res) => {
+ if (res.url().endsWith(".svg")) imgStatus = res.status();
+ });
+ await app.goto("/root-relative-image-urls-test");
+ let locator = await page.locator(
+ "[data-testid='root-relative-image-urls']"
+ );
+ let backgroundImage = await locator.evaluate(
+ (element) => window.getComputedStyle(element).backgroundImage
+ );
+ expect(backgroundImage).toContain(".svg");
+ expect(imgStatus).toBe(200);
+ });
+
+ let absoluteImageUrlsFixture = () => ({
+ "app/routes/absolute-image-urls-test.tsx": js`
+ import { Test } from "~/test-components/absolute-image-urls";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/absolute-image-urls/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Image URLs test
+
+ );
+ }
+ `,
+ "app/test-components/absolute-image-urls/styles.module.css": css`
+ .root {
+ background-color: peachpuff;
+ background-image: url(/absolute-image-urls/image.svg);
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "public/absolute-image-urls/image.svg": `
+
+
+
+ `,
+ });
+ test("absolute image URLs", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let imgStatus: number | null = null;
+ app.page.on("response", (res) => {
+ if (res.url().endsWith(".svg")) imgStatus = res.status();
+ });
+ await app.goto("/absolute-image-urls-test");
+ let locator = await page.locator("[data-testid='absolute-image-urls']");
+ let backgroundImage = await locator.evaluate(
+ (element) => window.getComputedStyle(element).backgroundImage
+ );
+ expect(backgroundImage).toContain(".svg");
+ expect(imgStatus).toBe(200);
+ });
+
+ let clientEntrySideEffectsFixture = () => ({
+ "app/entry.client.tsx": js`
+ import { RemixBrowser } from "@remix-run/react";
+ import { startTransition, StrictMode } from "react";
+ import { hydrateRoot } from "react-dom/client";
+ import "./entry.client.module.css";
+ const hydrate = () => {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+ };
+ if (window.requestIdleCallback) {
+ window.requestIdleCallback(hydrate);
+ } else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ window.setTimeout(hydrate, 1);
+ }
+ `,
+ "app/entry.client.module.css": css`
+ :global(.clientEntry) {
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/routes/client-entry-side-effects-test.tsx": js`
+ export default function() {
+ return (
+
+ Client entry side effects test
+
+ );
+ }
+ `,
+ });
+ test("client entry side effects", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/client-entry-side-effects-test");
+ let locator = await page.locator(
+ "[data-testid='client-entry-side-effects']"
+ );
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let deduplicatedCssFixture = () => ({
+ "app/routes/deduplicated-css-test.tsx": js`
+ import { Test } from "~/test-components/deduplicated-css";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/deduplicated-css/index.tsx": js`
+ import styles_1 from "./styles_1.module.css";
+ import styles_2 from "./styles_2.module.css";
+ import sharedStyles from "./shared.module.css";
+ export function Test() {
+ return (
+
+ Deduplicated CSS test
+
+ );
+ }
+ `,
+ "app/test-components/deduplicated-css/styles_1.module.css": css`
+ .root {
+ composes: deduplicated from "./shared.module.css";
+ }
+ `,
+ "app/test-components/deduplicated-css/styles_2.module.css": css`
+ .root {
+ composes: deduplicated from "./shared.module.css";
+ }
+ `,
+ "app/test-components/deduplicated-css/shared.module.css": css`
+ .deduplicated {
+ background: peachpuff;
+ }
+ `,
+ });
+ test("deduplicated CSS", async ({ page }) => {
+ // Using `composes: xxx from "./another.module.css"` leads
+ // to duplicate CSS in the final bundle prior to optimization.
+ // This test ensures the optimization does in fact happen,
+ // otherwise it could lead to very large CSS files if this
+ // feature is used heavily.
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/deduplicated-css-test");
+
+ let element = await app.getElement("[data-testid='deduplicated-css']");
+ let deduplicatedClassName = element.data().deduplicatedClassName;
+
+ if (typeof deduplicatedClassName !== "string") {
+ throw new Error(
+ "Couldn't find data-deduplicated-class-name value on test element"
+ );
+ }
+
+ let [cssBundlePath] = await globby(["public/build/css-bundle-*.css"], {
+ cwd: fixture.projectDir,
+ absolute: true,
+ });
+
+ if (!cssBundlePath) {
+ throw new Error("Couldn't find CSS bundle");
+ }
+
+ let cssBundleContents = await fse.readFile(cssBundlePath, "utf8");
+
+ let deduplicatedClassNameUsages = cssBundleContents.match(
+ new RegExp(`\\.${deduplicatedClassName}`, "g")
+ );
+
+ expect(deduplicatedClassNameUsages?.length).toBe(1);
+ });
+
+ let uniqueClassNamesFixture = () => ({
+ "app/routes/unique-class-names-test.tsx": js`
+ import { Test } from "~/test-components/unique-class-names";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/unique-class-names/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function Test() {
+ return (
+
+ Unique class names test
+
+ );
+ }
+ `,
+ "app/test-components/unique-class-names/styles.module.css": css`
+ .background {
+ background: peachpuff;
+ }
+ .color {
+ color: coral;
+ }
+ `,
+ });
+ test("unique class names", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/unique-class-names-test");
+ let element = await app.getElement("[data-testid='unique-class-names']");
+ let classNames = element.attr("class")?.split(" ");
+ expect(new Set(classNames).size).toBe(2);
+ });
+
+ let treeShakingFixture = () => ({
+ "app/routes/tree-shaking-test.tsx": js`
+ import { UsedTest } from "~/test-components/tree-shaking";
+ export default function() {
+ return ;
+ }
+ `,
+ "app/test-components/tree-shaking/index.ts": js`
+ export { UsedTest } from "./used";
+ export { UnusedTest } from "./unused";
+ `,
+ "app/test-components/tree-shaking/used/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function UsedTest() {
+ return (
+
+ Tree shaking test
+
+ );
+ }
+ `,
+ "app/test-components/tree-shaking/used/styles.module.css": css`
+ .root {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/test-components/tree-shaking/unused/index.tsx": js`
+ import styles from "./styles.module.css";
+ export function UnusedTest() {
+ return (
+
+ Unused component
+
+ );
+ }
+ `,
+ "app/test-components/tree-shaking/unused/styles.module.css": css`
+ :global(.global-class-from-unused-component) {
+ padding: 999px !important;
+ }
+ .root {
+ background: peachpuff;
+ }
+ `,
+ });
+ test("tree shaking of unused component styles", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/tree-shaking-test");
+ let locator = await page.locator("[data-testid='tree-shaking']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+});
diff --git a/integration/css-side-effect-imports-test.ts b/integration/css-side-effect-imports-test.ts
new file mode 100644
index 0000000000..7d6b6fe0d4
--- /dev/null
+++ b/integration/css-side-effect-imports-test.ts
@@ -0,0 +1,351 @@
+import { test, expect } from "@playwright/test";
+
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import {
+ createAppFixture,
+ createFixture,
+ css,
+ js,
+ json,
+} from "./helpers/create-fixture.js";
+
+const TEST_PADDING_VALUE = "20px";
+
+test.describe("CSS side-effect imports", () => {
+ let fixture: Fixture;
+ let appFixture: AppFixture;
+
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ config: {
+ serverDependenciesToBundle: ["react", /@test-package/],
+ },
+ files: {
+ "app/root.tsx": js`
+ import { Links, Outlet } from "@remix-run/react";
+ import { cssBundleHref } from "@remix-run/css-bundle";
+ export function links() {
+ return [{ rel: "stylesheet", href: cssBundleHref }];
+ }
+ export default function Root() {
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+ `,
+ ...basicSideEffectFixture(),
+ ...rootRelativeFixture(),
+ ...imageUrlsFixture(),
+ ...rootRelativeImageUrlsFixture(),
+ ...absoluteImageUrlsFixture(),
+ ...jsxInJsFileFixture(),
+ ...commonJsPackageFixture(),
+ ...esmPackageFixture(),
+ },
+ });
+ appFixture = await createAppFixture(fixture);
+ });
+
+ test.afterAll(() => appFixture.close());
+
+ let basicSideEffectFixture = () => ({
+ "app/basicSideEffect/styles.css": css`
+ .basicSideEffect {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/routes/basic-side-effect-test.tsx": js`
+ import "../basicSideEffect/styles.css";
+
+ export default function() {
+ return (
+
+ Basic side effect test
+
+ )
+ }
+ `,
+ });
+ test("basic side effect", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/basic-side-effect-test");
+ let locator = await page.locator("[data-testid='basic-side-effect']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let rootRelativeFixture = () => ({
+ "app/rootRelative/styles.css": css`
+ .rootRelative {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/routes/root-relative-test.tsx": js`
+ import "~/rootRelative/styles.css";
+
+ export default function() {
+ return (
+
+ Root relative import test
+
+ )
+ }
+ `,
+ });
+ test("root relative", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/root-relative-test");
+ let locator = await page.locator("[data-testid='root-relative']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let imageUrlsFixture = () => ({
+ "app/imageUrls/styles.css": css`
+ .imageUrls {
+ background-color: peachpuff;
+ background-image: url(./image.svg);
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/imageUrls/image.svg": `
+
+
+
+ `,
+ "app/routes/image-urls-test.tsx": js`
+ import "../imageUrls/styles.css";
+
+ export default function() {
+ return (
+
+ Image URLs test
+
+ )
+ }
+ `,
+ });
+ test("image URLs", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let imgStatus: number | null = null;
+ app.page.on("response", (res) => {
+ if (res.url().endsWith(".svg")) imgStatus = res.status();
+ });
+ await app.goto("/image-urls-test");
+ let locator = await page.locator("[data-testid='image-urls']");
+ let backgroundImage = await locator.evaluate(
+ (element) => window.getComputedStyle(element).backgroundImage
+ );
+ expect(backgroundImage).toContain(".svg");
+ expect(imgStatus).toBe(200);
+ });
+
+ let rootRelativeImageUrlsFixture = () => ({
+ "app/rootRelativeImageUrls/styles.css": css`
+ .rootRelativeImageUrls {
+ background-color: peachpuff;
+ background-image: url(~/rootRelativeImageUrls/image.svg);
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/rootRelativeImageUrls/image.svg": `
+
+
+
+ `,
+ "app/routes/root-relative-image-urls-test.tsx": js`
+ import "../rootRelativeImageUrls/styles.css";
+
+ export default function() {
+ return (
+
+ Image URLs test
+
+ )
+ }
+ `,
+ });
+ test("root relative image URLs", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let imgStatus: number | null = null;
+ app.page.on("response", (res) => {
+ if (res.url().endsWith(".svg")) imgStatus = res.status();
+ });
+ await app.goto("/root-relative-image-urls-test");
+ let locator = await page.locator(
+ "[data-testid='root-relative-image-urls']"
+ );
+ let backgroundImage = await locator.evaluate(
+ (element) => window.getComputedStyle(element).backgroundImage
+ );
+ expect(backgroundImage).toContain(".svg");
+ expect(imgStatus).toBe(200);
+ });
+
+ let absoluteImageUrlsFixture = () => ({
+ "app/absoluteImageUrls/styles.css": css`
+ .absoluteImageUrls {
+ background-color: peachpuff;
+ background-image: url(/absoluteImageUrls/image.svg);
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "public/absoluteImageUrls/image.svg": `
+
+
+
+ `,
+ "app/routes/absolute-image-urls-test.tsx": js`
+ import "../absoluteImageUrls/styles.css";
+
+ export default function() {
+ return (
+
+ Absolute image URLs test
+
+ )
+ }
+ `,
+ });
+ test("absolute image URLs", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let imgStatus: number | null = null;
+ app.page.on("response", (res) => {
+ if (res.url().endsWith(".svg")) imgStatus = res.status();
+ });
+ await app.goto("/absolute-image-urls-test");
+ let locator = await page.locator("[data-testid='absolute-image-urls']");
+ let backgroundImage = await locator.evaluate(
+ (element) => window.getComputedStyle(element).backgroundImage
+ );
+ expect(backgroundImage).toContain(".svg");
+ expect(imgStatus).toBe(200);
+ });
+
+ let jsxInJsFileFixture = () => ({
+ "app/jsxInJsFile/styles.css": css`
+ .jsxInJsFile {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "app/routes/jsx-in-js-file-test.js": js`
+ import "../jsxInJsFile/styles.css";
+
+ export default function() {
+ return (
+
+ JSX in JS file test
+
+ )
+ }
+ `,
+ });
+ test("JSX in JS file", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/jsx-in-js-file-test");
+ let locator = await page.locator("[data-testid='jsx-in-js-file']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let commonJsPackageFixture = () => ({
+ "node_modules/@test-package/commonjs/styles.css": css`
+ .commonJsPackage {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "node_modules/@test-package/commonjs/index.js": js`
+ var React = require('react');
+ require('./styles.css');
+
+ exports.Test = function() {
+ return React.createElement(
+ 'div',
+ {
+ 'data-testid': 'commonjs-package',
+ 'className': 'commonJsPackage'
+ },
+ 'CommonJS package test',
+ );
+ };
+ `,
+ "node_modules/@test-package/commonjs/package.json": json({
+ main: "./index.js",
+ }),
+ "app/routes/commonjs-package-test.jsx": js`
+ import { Test } from "@test-package/commonjs";
+ export default function() {
+ return ;
+ }
+ `,
+ });
+ test("CommonJS package", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/commonjs-package-test");
+ let locator = await page.locator("[data-testid='commonjs-package']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+
+ let esmPackageFixture = () => ({
+ "node_modules/@test-package/esm/styles.css": css`
+ .esmPackage {
+ background: peachpuff;
+ padding: ${TEST_PADDING_VALUE};
+ }
+ `,
+ "node_modules/@test-package/esm/index.mjs": js`
+ import React from 'react';
+ import './styles.css';
+
+ export function Test() {
+ return React.createElement(
+ 'div',
+ {
+ 'data-testid': 'esm-package',
+ 'className': 'esmPackage'
+ },
+ 'ESM package test',
+ );
+ };
+ `,
+ "node_modules/@test-package/esm/package.json": json({
+ exports: "./index.mjs",
+ }),
+ "app/routes/esm-package-test.tsx": js`
+ import { Test } from "@test-package/esm";
+ export default function() {
+ return ;
+ }
+ `,
+ });
+ test("ESM package", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/esm-package-test");
+ let locator = await page.locator("[data-testid='esm-package']");
+ let padding = await locator.evaluate(
+ (element) => window.getComputedStyle(element).padding
+ );
+ expect(padding).toBe(TEST_PADDING_VALUE);
+ });
+});
diff --git a/integration/custom-entry-server-test.ts b/integration/custom-entry-server-test.ts
new file mode 100644
index 0000000000..dea6e9f496
--- /dev/null
+++ b/integration/custom-entry-server-test.ts
@@ -0,0 +1,60 @@
+import { expect, test } from "@playwright/test";
+
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+
+let fixture: Fixture;
+let appFixture: AppFixture;
+
+test.beforeAll(async () => {
+ fixture = await createFixture({
+ files: {
+ "app/entry.server.tsx": js`
+ import * as React from "react";
+ import { RemixServer } from "@remix-run/react";
+ import { renderToString } from "react-dom/server";
+
+ export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ ) {
+ let markup = renderToString(
+
+ );
+ responseHeaders.set("Content-Type", "text/html");
+ responseHeaders.set("x-custom-header", "custom-value");
+ return new Response('' + markup, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ });
+ }
+ `,
+ "app/routes/_index.tsx": js`
+ export default function Index() {
+ return Hello World
+ }
+ `,
+ },
+ });
+
+ appFixture = await createAppFixture(fixture);
+});
+
+test.afterAll(() => {
+ appFixture.close();
+});
+
+test("allows user specified entry.server", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ let responses = app.collectResponses((url) => url.pathname === "/");
+ await app.goto("/");
+ let header = await responses[0].headerValues("x-custom-header");
+ expect(header).toEqual(["custom-value"]);
+});
diff --git a/integration/defer-loader-test.ts b/integration/defer-loader-test.ts
new file mode 100644
index 0000000000..0923b99447
--- /dev/null
+++ b/integration/defer-loader-test.ts
@@ -0,0 +1,199 @@
+import { test, expect } from "@playwright/test";
+
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+
+let fixture: Fixture;
+let appFixture: AppFixture;
+
+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 (
+
+ 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");
+ });
+});
+
+// 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
new file mode 100644
index 0000000000..487be7f8b0
--- /dev/null
+++ b/integration/defer-test.ts
@@ -0,0 +1,2650 @@
+import { test, expect } from "@playwright/test";
+import type { ConsoleMessage, Page } from "@playwright/test";
+
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import type { Fixture, AppFixture } from "./helpers/create-fixture.js";
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from "./helpers/create-fixture.js";
+
+const ROOT_ID = "ROOT_ID";
+const INDEX_ID = "INDEX_ID";
+const DEFERRED_ID = "DEFERRED_ID";
+const RESOLVED_DEFERRED_ID = "RESOLVED_DEFERRED_ID";
+const FALLBACK_ID = "FALLBACK_ID";
+const ERROR_ID = "ERROR_ID";
+const UNDEFINED_ERROR_ID = "UNDEFINED_ERROR_ID";
+const NEVER_SHOW_ID = "NEVER_SHOW_ID";
+const ERROR_BOUNDARY_ID = "ERROR_BOUNDARY_ID";
+const MANUAL_RESOLVED_ID = "MANUAL_RESOLVED_ID";
+const MANUAL_FALLBACK_ID = "MANUAL_FALLBACK_ID";
+const MANUAL_ERROR_ID = "MANUAL_ERROR_ID";
+
+declare global {
+ // eslint-disable-next-line prefer-let/prefer-let
+ var __deferredManualResolveCache: {
+ nextId: number;
+ deferreds: Record<
+ string,
+ { resolve: (value: any) => void; reject: (error: Error) => void }
+ >;
+ };
+}
+
+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: {
+ "app/components/counter.tsx": js`
+ import { useState } from "react";
+
+ export default function Counter({ id }) {
+ let [count, setCount] = useState(0);
+ return (
+
+
setCount((c) => c+1)}>Increment
+
{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 ? (
+
+ ) : 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 (
+
+
+
+
+
+
+
+
+
+
+ {/* 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 }>
+ (
+
+ )}
+ />
+
+ 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();
+ });
+
+ 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("