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 ( +
+

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

+

+ + +

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

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

+

+ + + +

+
+ ); + } + `, + + [`app/routes/${THROWS_REDIRECT}.jsx`]: js` + import { redirect } from "@remix-run/node"; + import { Form } from "@remix-run/react"; + + export function action() { + throw redirect("/${REDIRECT_TARGET}") + } + + export default function () { + return ( +
+ +
+ ) + } + `, + + [`app/routes/${REDIRECT_TARGET}.jsx`]: js` + export default function () { + return
${PAGE_TEXT}
+ } + `, + + "app/routes/no-action.tsx": js` + import { Form } from "@remix-run/react"; + + export default function Component() { + return ( +
+ +
+ ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + let logs: string[] = []; + + test.beforeEach(({ page }) => { + page.on("console", (msg) => { + logs.push(msg.text()); + }); + }); + + test.afterEach(() => { + expect(logs).toHaveLength(0); + }); + + test("is not called on document GET requests", async () => { + let res = await fixture.requestDocument("/urlencoded"); + let html = selectHtml(await res.text(), "#text"); + expect(html).toMatch(WAITING_VALUE); + }); + + test("is called on document POST requests", async () => { + let FIELD_VALUE = "cheeseburger"; + + let params = new URLSearchParams(); + params.append(FIELD_NAME, FIELD_VALUE); + + let res = await fixture.postDocument("/urlencoded", params); + + let html = selectHtml(await res.text(), "#text"); + expect(html).toMatch(FIELD_VALUE); + }); + + test("is called on script transition POST requests", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/urlencoded`); + await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); + + await page.click("button[type=submit]"); + await page.waitForSelector("#action-text"); + await page.waitForSelector(`#text:has-text("${SUBMITTED_VALUE}")`); + }); + + test("throws a 405 when no action exists", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/no-action`); + await page.click("button[type=submit]"); + await page.waitForSelector(`h1:has-text("405 Method Not Allowed")`); + expect(logs.length).toBe(2); + expect(logs[0]).toMatch('Route "routes/no-action" does not have an action'); + // logs[1] is the raw ErrorResponse instance from the boundary but playwright + // seems to just log the name of the constructor, which in the minified code + // is meaningless so we don't bother asserting + + // The rest of the tests in this suite assert no logs, so clear this out to + // avoid failures in afterEach + logs = []; + }); + + test("properly encodes form data for request.text() usage", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/request-text`); + await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); + + await page.click("button[type=submit]"); + await page.waitForSelector("#action-text"); + expect(await app.getHtml("#action-text")).toBe( + 'a=1&b=2' + ); + }); + + test("redirects a thrown response on document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(`/${THROWS_REDIRECT}`, params); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe(`/${REDIRECT_TARGET}`); + }); + + test("redirects a thrown response on script transitions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/${THROWS_REDIRECT}`); + let responses = app.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 ( +
+

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

+

+ + +

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

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

+

+ + + +

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

${OWN_BOUNDARY_TEXT}

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

{useLoaderData()}

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

{useLoaderData()}

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

{error.status} {error.data}

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("non-matching urls on document requests", async () => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + let res = await fixture.requestDocument(NOT_FOUND_HREF); + expect(res.status).toBe(404); + let html = await res.text(); + expect(html).toMatch(ROOT_BOUNDARY_TEXT); + + // There should be no loader data on the root route + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {} }, + ]).replace(/"/g, """); + expect(html).toContain(`
${expected}
`); + + console.error = oldConsoleError; + }); + + test("non-matching urls on client transitions", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NOT_FOUND_HREF, { wait: false }); + await page.waitForSelector("#root-boundary"); + + // Root loader data sticks around from previous load + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {}, data: { data: "ROOT LOADER" } }, + ]); + expect(await app.getHtml("#matches")).toContain(expected); + }); + + test("own boundary, action, document request", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from other route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("own boundary, action, client transition from itself", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(HAS_BOUNDARY_ACTION); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("bubbles to parent in action document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("bubbles to parent in action script transitions from self", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_BOUNDARY_ACTION); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("own boundary, loader, document request", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, loader, client transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LOADER); + await page.waitForSelector("#boundary-loader"); + }); + + test("bubbles to parent in loader document requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector("#root-boundary"); + }); + + test("uses correct catch boundary on server action errors", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/action/child-catch`); + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-data")).toMatch("CHILD"); + await page.click("button[type=submit]"); + await page.waitForSelector("#child-catch"); + // Preserves parent loader data + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-catch")).toMatch("400"); + expect(await app.getHtml("#child-catch")).toMatch("Caught!"); + }); + + test("prefers parent catch when child loader also bubbles, document request", async () => { + let res = await fixture.requestDocument(`${HAS_BOUNDARY_LOADER}/child`); + expect(res.status).toBe(401); + let text = await res.text(); + expect(text).toMatch(OWN_BOUNDARY_TEXT); + expect(text).toMatch('
401
'); + }); + + test("prefers parent catch when child loader also bubbles, client transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(`${HAS_BOUNDARY_LOADER}/child`); + await page.waitForSelector("#boundary-loader"); + expect(await app.getHtml("#boundary-loader")).toMatch(OWN_BOUNDARY_TEXT); + expect(await app.getHtml("#status")).toMatch("401"); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("ErrorBoundary (thrown responses)", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let ROOT_BOUNDARY_TEXT = "ROOT_TEXT" as const; + let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT" as const; + + let HAS_BOUNDARY_LOADER = "/yes/loader" as const; + let HAS_BOUNDARY_LOADER_FILE = "/yes.loader" as const; + let HAS_BOUNDARY_ACTION = "/yes/action" as const; + let HAS_BOUNDARY_ACTION_FILE = "/yes.action" as const; + let NO_BOUNDARY_ACTION = "/no/action" as const; + let NO_BOUNDARY_ACTION_FILE = "/no.action" as const; + let NO_BOUNDARY_LOADER = "/no/loader" as const; + let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; + + let NOT_FOUND_HREF = "/not/found"; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts, useMatches } from "@remix-run/react"; + + export function loader() { + return json({ data: "ROOT LOADER" }); + } + + export default function Root() { + return ( + + + + + + + + + + + ); + } + + export function ErrorBoundary() { + let matches = useMatches() + return ( + + + +
${ROOT_BOUNDARY_TEXT}
+
{JSON.stringify(matches)}
+ + + + ) + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + export default function() { + return ( +
+ ${NOT_FOUND_HREF} + +
+
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "@remix-run/react"; + export async function action() { + throw new Response("", { status: 401 }) + } + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

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

{useLoaderData()}

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

{useLoaderData()}

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

{error.status} {error.data}

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("non-matching urls on document requests", async () => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + let res = await fixture.requestDocument(NOT_FOUND_HREF); + expect(res.status).toBe(404); + let html = await res.text(); + expect(html).toMatch(ROOT_BOUNDARY_TEXT); + + // There should be no loader data on the root route + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {} }, + ]).replace(/"/g, """); + expect(html).toContain(`
${expected}
`); + + console.error = oldConsoleError; + }); + + test("non-matching urls on client transitions", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NOT_FOUND_HREF, { wait: false }); + await page.waitForSelector("#root-boundary"); + + // Root loader data sticks around from previous load + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {}, data: { data: "ROOT LOADER" } }, + ]); + expect(await app.getHtml("#matches")).toContain(expected); + }); + + test("own boundary, action, document request", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from other route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("own boundary, action, client transition from itself", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(HAS_BOUNDARY_ACTION); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("bubbles to parent in action document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("bubbles to parent in action script transitions from self", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_BOUNDARY_ACTION); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("own boundary, loader, document request", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, loader, client transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LOADER); + await page.waitForSelector("#boundary-loader"); + }); + + test("bubbles to parent in loader document requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector("#root-boundary"); + }); + + test("uses correct catch boundary on server action errors", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/action/child-catch`); + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-data")).toMatch("CHILD"); + await page.click("button[type=submit]"); + await page.waitForSelector("#child-catch"); + // Preserves parent loader data + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-catch")).toMatch("400"); + expect(await app.getHtml("#child-catch")).toMatch("Caught!"); + }); + + test("prefers parent catch when child loader also bubbles, document request", async () => { + let res = await fixture.requestDocument(`${HAS_BOUNDARY_LOADER}/child`); + expect(res.status).toBe(401); + let text = await res.text(); + expect(text).toMatch(OWN_BOUNDARY_TEXT); + expect(text).toMatch('
401
'); + }); + + test("prefers parent catch when child loader also bubbles, client transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(`${HAS_BOUNDARY_LOADER}/child`); + await page.waitForSelector("#boundary-loader"); + expect(await app.getHtml("#boundary-loader")).toMatch(OWN_BOUNDARY_TEXT); + expect(await app.getHtml("#status")).toMatch("401"); + }); + }); +}); diff --git a/integration/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}

+
+ + {actionData ?

{actionData.message}

: null} +
+ + ); + } + `, + }; +} + +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}

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

Loading...

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

{data.message}

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

Loading...

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

Should not see me

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

{error.message}

; + } + `, + }, + }, + ServerMode.Development // Avoid error sanitization + ), + ServerMode.Development // Avoid error sanitization + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Broken!"); + // Ensure we hydrate and remain on the boundary + await new Promise((r) => setTimeout(r, 100)); + html = await app.getHtml("main"); + expect(html).toMatch("Broken!"); + expect(html).not.toMatch("Should not see me"); + console.error = _consoleError; + }); + + test("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}

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

Loading...

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

{data.message}

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

Loading...

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

Should not see me

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

{error.message}

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

Child

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

Loading...

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

{error.status} {error.data}

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

{error.status} {error.data}

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

{error.status} {error.data}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.goto("/parent/child"); + await page.waitForSelector("form"); + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverAction() on a route that does " + + 'not have a server action (routeId: "routes/parent.child")' + ); + }); + }); + }); +}); diff --git a/integration/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 ( +
+ +

{count}

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

interactive

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

{id}

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

YOOOOOOOOOO {i}

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

{id}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

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

{resolvedDeferredId}

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

{deferredId}

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

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{id}

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

{count}

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

interactive

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

{id}

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

YOOOOOOOOOO {i}

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

{deferredId}

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

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("server aborts render the errorElement", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + }); + + test("server aborts render the ErrorBoundary when no errorElement", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted-no-error-element"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("non-aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeEach(async ({ context }) => { + await context.route(/.data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/components/counter.tsx": js` + import { useState } from "react"; + + export default function Counter({ id }) { + let [count, setCount] = useState(0); + return ( +
+ +

{count}

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

interactive

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

{id}

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

YOOOOOOOOOO {i}

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

{id}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

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

{resolvedDeferredId}

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

{deferredId}

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

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{id}

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

${val}

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

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

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

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

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

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

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

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

{count}

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

interactive

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

{id}

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

YOOOOOOOOOO {i}

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

{deferredId}

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

{resolvedDeferredId}

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

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("server aborts render the errorElement", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + }); + + test("server aborts render the ErrorBoundary when no errorElement", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted-no-error-element"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); + }); +}); + +async function ensureInteractivity(page: Page, id: string, expect: number = 1) { + await page.waitForSelector("#interactive"); + let increment = await page.waitForSelector("#increment-" + id); + await increment.click(); + await page.waitForSelector(`#count-${id}:has-text('${expect}')`); +} + +function monitorConsole(page: Page) { + let messages: ConsoleMessage[] = []; + page.on("console", (message) => { + messages.push(message); + }); + + return async () => { + if (!messages.length) return; + let errors: string[] = []; + for (let message of messages) { + let logs = []; + let args = message.args(); + if (args[0]) { + let arg0 = await args[0].jsonValue(); + if ( + typeof arg0 === "string" && + arg0.includes("Download the React DevTools") + ) { + continue; + } + logs.push(arg0); + } + errors.push( + `Unexpected console.log(${JSON.stringify(logs).slice(1, -1)})` + ); + } + if (errors.length) { + throw new Error(`Unexpected console.log's:\n` + errors.join("\n") + "\n"); + } + }; +} diff --git a/integration/deno-compiler-test.ts b/integration/deno-compiler-test.ts new file mode 100644 index 0000000000..6da8b70d59 --- /dev/null +++ b/integration/deno-compiler-test.ts @@ -0,0 +1,234 @@ +import { test, expect } from "@playwright/test"; +import fse from "fs-extra"; +import path from "node:path"; +import shell from "shelljs"; +import glob from "glob"; + +import { createFixtureProject, js, json } from "./helpers/create-fixture.js"; + +let projectDir: string; + +const findBrowserBundle = (projectDir: string): string => + path.resolve(projectDir, "public", "build"); + +const findServerBundle = (projectDir: string): string => + path.resolve(projectDir, "build", "index.js"); + +const importPattern = (importSpecifier: string) => + new RegExp( + String.raw`import\s*{.*}\s*from\s*"` + importSpecifier + String.raw`"` + ); + +const findCodeFiles = async (directory: string) => + glob.sync("**/*.@(js|jsx|ts|tsx)", { + cwd: directory, + absolute: true, + }); +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); +}; + +test.beforeAll(async () => { + projectDir = await createFixtureProject({ + template: "deno-template", + files: { + "package.json": json({ + name: "remix-template-deno", + private: true, + sideEffects: false, + dependencies: { + "@remix-run/deno": "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", + + component: "0.0.0-local-version", + "deno-pkg": "0.0.0-local-version", + }, + devDependencies: { + "@remix-run/dev": "0.0.0-local-version", + }, + }), + + "app/routes/_index.tsx": js` + import fake from "deno-pkg"; + import { urlComponent } from "https://deno.land/x/component.ts"; + import { urlUtil } from "https://deno.land/x/util.ts"; + import { urlServerOnly } from "https://deno.land/x/server-only.ts"; + + import { npmComponent } from "npm-component"; + import { npmUtil } from "npm-util"; + import { npmServerOnly } from "npm-server-only"; + + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => { + return json({ + a: urlUtil(), + b: urlServerOnly(), + c: npmUtil(), + d: npmServerOnly(), + }); + } + + export default function Index() { + const data = useLoaderData(); + return ( +
    +
  • {fake}
  • + +
  • {urlComponent}
  • +
  • {urlUtil()}
  • +
  • {data.a}
  • +
  • {data.b}
  • + +
  • {npmComponent}
  • +
  • {npmUtil()}
  • +
  • {data.c}
  • +
  • {data.d}
  • +
+ ) + } + `, + "node_modules/npm-component/package.json": json({ + name: "npm-component", + version: "1.0.0", + sideEffects: false, + }), + "node_modules/npm-component/index.js": js` + module.exports = { npmComponent: () => "NPM_COMPONENT" }; + `, + "node_modules/npm-util/package.json": json({ + name: "npm-util", + version: "1.0.0", + sideEffects: false, + }), + "node_modules/npm-util/index.js": js` + module.exports = { npmUtil: () => "NPM_UTIL" }; + `, + "node_modules/npm-server-only/package.json": json({ + name: "npm-server-only", + version: "1.0.0", + sideEffects: false, + }), + "node_modules/npm-server-only/index.js": js` + module.exports = { npmServerOnly: () => "NPM_SERVER_ONLY" }; + `, + "node_modules/deno-pkg/package.json": json({ + name: "deno-pkg", + version: "1.0.0", + type: "module", + main: "./default.js", + exports: { + deno: "./deno.js", + worker: "./worker.js", + default: "./default.js", + }, + sideEffects: false, + }), + "node_modules/deno-pkg/deno.js": js` + export default "DENO_EXPORTS"; + `, + "node_modules/deno-pkg/worker.js": js` + export default "WORKER_EXPORTS"; + `, + "node_modules/deno-pkg/default.js": js` + export default "DEFAULT_EXPORTS"; + `, + }, + }); +}); + +test("compiler does not bundle url imports for server", async () => { + let serverBundle = await fse.readFile(findServerBundle(projectDir), "utf8"); + expect(serverBundle).toMatch(importPattern("https://deno.land/x/util.ts")); + expect(serverBundle).toMatch( + importPattern("https://deno.land/x/server-only.ts") + ); + + // server-side rendering + expect(serverBundle).toMatch( + importPattern("https://deno.land/x/component.ts") + ); +}); + +test("compiler does not bundle url imports for browser", async () => { + let browserBundle = findBrowserBundle(projectDir); + let browserCodeFiles = await findCodeFiles(browserBundle); + + let utilFiles = await searchFiles( + importPattern("https://deno.land/x/util.ts"), + browserCodeFiles + ); + expect(utilFiles.length).toBeGreaterThanOrEqual(1); + + let componentFiles = await searchFiles( + importPattern("https://deno.land/x/component.ts"), + browserCodeFiles + ); + expect(componentFiles.length).toBeGreaterThanOrEqual(1); + + /* + Url imports _could_ have side effects, but the vast majority do not. + Currently Remix marks all URL imports as side-effect free. + */ + let serverOnlyUtilFiles = await searchFiles( + importPattern("https://deno.land/x/server-only.ts"), + browserCodeFiles + ); + expect(serverOnlyUtilFiles.length).toBe(0); +}); + +test("compiler bundles npm imports for server", async () => { + let serverBundle = await fse.readFile(findServerBundle(projectDir), "utf8"); + + expect(serverBundle).not.toMatch(importPattern("npm-component")); + expect(serverBundle).toContain("NPM_COMPONENT"); + + expect(serverBundle).not.toMatch(importPattern("npm-util")); + expect(serverBundle).toContain("NPM_UTIL"); + + expect(serverBundle).not.toMatch(importPattern("npm-server-only")); + expect(serverBundle).toContain("NPM_SERVER_ONLY"); +}); + +test("compiler bundles npm imports for browser", async () => { + let browserBundle = findBrowserBundle(projectDir); + let browserCodeFiles = await findCodeFiles(browserBundle); + + let utilImports = await searchFiles( + importPattern("npm-util"), + browserCodeFiles + ); + expect(utilImports.length).toBe(0); + let utilFiles = await searchFiles("NPM_UTIL", browserCodeFiles); + expect(utilFiles.length).toBeGreaterThanOrEqual(1); + + let componentImports = await searchFiles( + importPattern("npm-component"), + browserCodeFiles + ); + expect(componentImports.length).toBe(0); + let componentFiles = await searchFiles("NPM_COMPONENT", browserCodeFiles); + expect(componentFiles.length).toBeGreaterThanOrEqual(1); + + let serverOnlyImports = await searchFiles( + importPattern("npm-server-only"), + browserCodeFiles + ); + expect(serverOnlyImports.length).toBe(0); + let serverOnlyFiles = await searchFiles("NPM_SERVER_ONLY", browserCodeFiles); + expect(serverOnlyFiles.length).toBe(0); +}); + +test("compiler bundles deno export of 3rd party package", async () => { + let serverBundle = await fse.readFile(findServerBundle(projectDir), "utf8"); + + expect(serverBundle).toMatch("DENO_EXPORTS"); + expect(serverBundle).not.toMatch("DEFAULT_EXPORTS"); +}); diff --git a/integration/deterministic-build-output-test.ts b/integration/deterministic-build-output-test.ts new file mode 100644 index 0000000000..a3e9ef5fc8 --- /dev/null +++ b/integration/deterministic-build-output-test.ts @@ -0,0 +1,118 @@ +import { test, expect } from "@playwright/test"; +import globby from "globby"; +import fs from "node:fs"; +import path from "node:path"; + +import type { FixtureInit } from "./helpers/create-fixture.js"; +import { createFixtureProject, js, css } from "./helpers/create-fixture.js"; + +test("builds deterministically under different paths", async () => { + // This test validates various flavors of remix virtual modules to ensure + // we get identical builds regardless of the parent paths. If a virtual + // module resolves or imports from absolute paths (e.g. via `path.resolve`), + // the build hashes may change even though the output is identical. This + // can cause broken apps (i.e. manifest mismatch) if the server and client + // are built separately. + + // Virtual modules tested: + // * browserRouteModulesPlugin (implicitly tested by root route) + // * cssEntryModulePlugin (implicitly tested by build) + // * cssModulesPlugin (via app/routes/foo.tsx' CSS Modules import) + // * cssSideEffectImportsPlugin (via app/routes/foo.tsx' CSS side-effect import) + // * emptyModulesPlugin (via app/routes/foo.tsx' server import) + // * mdx (via app/routes/_index.mdx) + // * serverAssetsManifestPlugin (implicitly tested by build) + // * serverEntryModulePlugin (implicitly tested by build) + // * serverRouteModulesPlugin (implicitly tested by build) + // * vanillaExtractPlugin (via app/routes/foo.tsx' .css.ts file import) + let init: FixtureInit = { + files: { + "postcss.config.js": js` + export default { + plugins: { + "postcss-import": {}, + }, + }; + `, + "app/routes/_index.mdx": "# hello world", + "app/routes/foo.tsx": js` + export * from "~/foo/bar.server"; + import styles from "~/styles/foo.module.css"; + import { vanilla } from "~/styles/vanilla.css"; + import "~/styles/side-effect.css"; + export default () =>
YAY
; + `, + "app/foo/bar.server.ts": "export const meta = () => []", + "app/styles/foo.module.css": css` + .foo { + background-image: url(~/images/foo.svg); + composes: bar from "~/styles/bar.module.css"; + composes: baz from "./baz.module.css"; + } + `, + "app/styles/bar.module.css": css` + .bar { + background-color: peachpuff; + } + `, + "app/styles/baz.module.css": css` + .baz { + color: coral; + } + `, + "app/images/foo.svg": ` + + + + `, + "app/styles/vanilla.css.ts": js` + import { style } from "@vanilla-extract/css"; + import { chocolate } from "./chocolate.css"; + import imageUrl from "~/images/foo.svg"; + + export const vanilla = style([ + chocolate, + { + backgroundImage: [ + "url(" + imageUrl + ")", + "url(~/images/foo.svg)", + ], + } + ]); + `, + "app/styles/chocolate.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const chocolate = style({ + color: "chocolate", + }); + `, + "app/styles/side-effect.css": css` + .side-effect { + color: mintcream; + } + `, + }, + }; + let dir1 = await createFixtureProject(init); + let dir2 = await createFixtureProject(init); + + expect(dir1).not.toEqual(dir2); + + let files1 = await globby(["build/index.js", "public/build/**/*.{js,css}"], { + cwd: dir1, + }); + files1 = files1.sort(); + let files2 = await globby(["build/index.js", "public/build/**/*.{js,css}"], { + cwd: dir2, + }); + files2 = files2.sort(); + + expect(files1.length).toBeGreaterThan(0); + expect(files1).toEqual(files2); + files1.forEach((file, i) => { + expect(fs.readFileSync(path.join(dir1, file))).toEqual( + fs.readFileSync(path.join(dir2, files2[i])) + ); + }); +}); diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts new file mode 100644 index 0000000000..dadfa3d31c --- /dev/null +++ b/integration/error-boundary-test.ts @@ -0,0 +1,2718 @@ +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 { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + + let ROOT_BOUNDARY_TEXT = "ROOT_BOUNDARY_TEXT"; + let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT"; + + let HAS_BOUNDARY_LOADER = "/yes/loader" as const; + let HAS_BOUNDARY_LOADER_FILE = "/yes.loader" as const; + let HAS_BOUNDARY_ACTION = "/yes/action" as const; + let HAS_BOUNDARY_ACTION_FILE = "/yes.action" as const; + let HAS_BOUNDARY_RENDER = "/yes/render" as const; + let HAS_BOUNDARY_RENDER_FILE = "/yes.render" as const; + let HAS_BOUNDARY_NO_LOADER_OR_ACTION = "/yes/no-loader-or-action" as const; + let HAS_BOUNDARY_NO_LOADER_OR_ACTION_FILE = + "/yes.no-loader-or-action" as const; + + let NO_BOUNDARY_ACTION = "/no/action" as const; + let NO_BOUNDARY_ACTION_FILE = "/no.action" as const; + let NO_BOUNDARY_LOADER = "/no/loader" as const; + let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; + let NO_BOUNDARY_RENDER = "/no/render" as const; + let NO_BOUNDARY_RENDER_FILE = "/no.render" as const; + let NO_BOUNDARY_NO_LOADER_OR_ACTION = "/no/no-loader-or-action" as const; + let NO_BOUNDARY_NO_LOADER_OR_ACTION_FILE = "/no.no-loader-or-action" as const; + + let NOT_FOUND_HREF = "/not/found"; + + // packages/remix-react/errorBoundaries.tsx + let INTERNAL_ERROR_BOUNDARY_HEADING = "Application Error"; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = () => {}; + fixture = await createFixture( + { + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + + export function ErrorBoundary() { + return ( + + + +
+
${ROOT_BOUNDARY_TEXT}
+
+ + + + ) + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + export default function () { + return ( +
+ ${NOT_FOUND_HREF} + +
+ + + + +
+ + + ${HAS_BOUNDARY_LOADER} + + + ${NO_BOUNDARY_LOADER} + + + ${HAS_BOUNDARY_RENDER} + + + ${NO_BOUNDARY_RENDER} + +
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "@remix-run/react"; + export async function action() { + throw new Error("Kaboom!") + } + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

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

${OWN_BOUNDARY_TEXT}

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

{useLoaderData()}

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

{useLoaderData()}

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

{error.message}

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

Home

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

Hello

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

Goodbye

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

{data}

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

{data}

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

{useLoaderData()}

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

{useLoaderData()}

+

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

+

{error.message}

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

{useLoaderData()}

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

{useLoaderData()}

+

{error.message}

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

{useLoaderData()}

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

PARENT

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

CHILD

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

Broken!

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

PARENT

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

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

PARENT

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

CHILD

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

Broken!

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

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

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

{error.message}

+

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

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

{error.message}

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

Index

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

Loader Error

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

ERROR BOUNDARY

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

INDEX

+ This will error +
+ ); + } + `, + + "app/routes/boom.tsx": js` + import { json } from "@remix-run/node"; + export function loader() { return boom(); } + export default function() { return my page; } + `, + }, + }); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await page.waitForSelector("#index"); + expect(app.page.url()).not.toMatch("/boom"); + + await app.clickLink("/boom"); + await page.waitForSelector("#error"); + expect(app.page.url()).toMatch("/boom"); + + await app.reload(); + await page.waitForSelector("#error"); + expect(app.page.url()).toMatch("boom"); + + await app.goBack(); + + // Here be dragons + // - Playwright sets the Firefox `fission.webContentIsolationStrategy=0` preference + // for reasons having to do with out-of-process iframes: + // https://github.com/microsoft/playwright/issues/22640#issuecomment-1543287282 + // - That preference exposes a bug in firefox where a hard reload adds to the + // history stack: https://bugzilla.mozilla.org/show_bug.cgi?id=1832341 + // - Your can disable this preference via the Playwright `firefoxUserPrefs` config, + // but that is broken until 1.34: + // https://github.com/microsoft/playwright/issues/22640#issuecomment-1546230104 + // https://github.com/microsoft/playwright/issues/15405 + // - We can't yet upgrade to 1.34 because it drops support for Node 14: + // https://github.com/microsoft/playwright/releases/tag/v1.34.0 + // + // So for now when in firefox we just navigate back twice to work around the issue + if (browserName === "firefox") { + await app.goBack(); + } + + await page.waitForSelector("#index"); + expect(app.page.url()).not.toContain("boom"); + + appFixture.close(); + console.error = _consoleError; +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + + let ROOT_BOUNDARY_TEXT = "ROOT_BOUNDARY_TEXT"; + let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT"; + + let HAS_BOUNDARY_LOADER = "/yes/loader" as const; + let HAS_BOUNDARY_LOADER_FILE = "/yes.loader" as const; + let HAS_BOUNDARY_ACTION = "/yes/action" as const; + let HAS_BOUNDARY_ACTION_FILE = "/yes.action" as const; + let HAS_BOUNDARY_RENDER = "/yes/render" as const; + let HAS_BOUNDARY_RENDER_FILE = "/yes.render" as const; + let HAS_BOUNDARY_NO_LOADER_OR_ACTION = "/yes/no-loader-or-action" as const; + let HAS_BOUNDARY_NO_LOADER_OR_ACTION_FILE = + "/yes.no-loader-or-action" as const; + + let NO_BOUNDARY_ACTION = "/no/action" as const; + let NO_BOUNDARY_ACTION_FILE = "/no.action" as const; + let NO_BOUNDARY_LOADER = "/no/loader" as const; + let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; + let NO_BOUNDARY_RENDER = "/no/render" as const; + let NO_BOUNDARY_RENDER_FILE = "/no.render" as const; + let NO_BOUNDARY_NO_LOADER_OR_ACTION = "/no/no-loader-or-action" as const; + let NO_BOUNDARY_NO_LOADER_OR_ACTION_FILE = + "/no.no-loader-or-action" as const; + + let NOT_FOUND_HREF = "/not/found"; + + // packages/remix-react/errorBoundaries.tsx + let INTERNAL_ERROR_BOUNDARY_HEADING = "Application Error"; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = () => {}; + fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + + export function ErrorBoundary() { + return ( + + + +
+
${ROOT_BOUNDARY_TEXT}
+
+ + + + ) + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + export default function () { + return ( +
+ ${NOT_FOUND_HREF} + +
+ + + + +
+ + + ${HAS_BOUNDARY_LOADER} + + + ${NO_BOUNDARY_LOADER} + + + ${HAS_BOUNDARY_RENDER} + + + ${NO_BOUNDARY_RENDER} + +
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "@remix-run/react"; + export async function action() { + throw new Error("Kaboom!") + } + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

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

${OWN_BOUNDARY_TEXT}

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

{useLoaderData()}

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

{useLoaderData()}

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

{error.message}

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

Home

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

Hello

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

Goodbye

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

{data}

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

{data}

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

{useLoaderData()}

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

{useLoaderData()}

+

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

+

{error.message}

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

{useLoaderData()}

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

{useLoaderData()}

+

{error.message}

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

{useLoaderData()}

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

PARENT

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

CHILD

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

Broken!

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

PARENT

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

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

PARENT

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

CHILD

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

Broken!

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

PARENT

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

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

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

{error.message}

+

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

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

{error.message}

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

Index

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

Loader Error

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

ERROR BOUNDARY

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

INDEX

+ This will error +
+ ); + } + `, + + "app/routes/boom.tsx": js` + import { json } from "@remix-run/node"; + export function loader() { return boom(); } + export default function() { return my page; } + `, + }, + }); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await page.waitForSelector("#index"); + expect(app.page.url()).not.toMatch("/boom"); + + await app.clickLink("/boom"); + await page.waitForSelector("#error"); + expect(app.page.url()).toMatch("/boom"); + + await app.reload(); + await page.waitForSelector("#error"); + expect(app.page.url()).toMatch("boom"); + + await app.goBack(); + + // Here be dragons + // - Playwright sets the Firefox `fission.webContentIsolationStrategy=0` preference + // for reasons having to do with out-of-process iframes: + // https://github.com/microsoft/playwright/issues/22640#issuecomment-1543287282 + // - That preference exposes a bug in firefox where a hard reload adds to the + // history stack: https://bugzilla.mozilla.org/show_bug.cgi?id=1832341 + // - Your can disable this preference via the Playwright `firefoxUserPrefs` config, + // but that is broken until 1.34: + // https://github.com/microsoft/playwright/issues/22640#issuecomment-1546230104 + // https://github.com/microsoft/playwright/issues/15405 + // - We can't yet upgrade to 1.34 because it drops support for Node 14: + // https://github.com/microsoft/playwright/releases/tag/v1.34.0 + // + // So for now when in firefox we just navigate back twice to work around the issue + if (browserName === "firefox") { + await app.goBack(); + } + + await page.waitForSelector("#index"); + expect(app.page.url()).not.toContain("boom"); + + appFixture.close(); + console.error = _consoleError; + }); +}); diff --git a/integration/error-boundary-v2-test.ts b/integration/error-boundary-v2-test.ts new file mode 100644 index 0000000000..16014bc1fa --- /dev/null +++ b/integration/error-boundary-v2-test.ts @@ -0,0 +1,492 @@ +import type { Page } from "@playwright/test"; +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 { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let oldConsoleError: () => void; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { + Link, + Outlet, + isRouteErrorResponse, + useLoaderData, + useRouteError, + } from "@remix-run/react"; + + export function loader() { + return "PARENT LOADER"; + } + + export default function Component() { + return ( +
+ +

{useLoaderData()}

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

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

: +

{error.message}

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

{data}

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

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

: +

{error.message}

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

{data}

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.beforeEach(({ page }) => { + oldConsoleError = console.error; + console.error = () => {}; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runBoundaryTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runBoundaryTests(); + + test("Network errors that never reach the Remix server", async ({ + page, + }) => { + // Cause a ?_data request to trigger an HTTP error that never reaches the + // Remix server, and ensure we properly handle it at the ErrorBoundary + await page.route( + "**/parent/child-with-boundary?_data=routes%2Fparent.child-with-boundary", + (route) => route.fulfill({ status: 500, body: "CDN Error!" }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#child-error", "CDN Error!"); + }); + }); + + function runBoundaryTests() { + test("No errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#child-data", "CHILD LOADER"); + }); + + test("Throwing a Response to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#child-error-response", + "418 Loader Response" + ); + }); + + test("Throwing an Error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=error"); + await waitForAndAssert(page, app, "#child-error", "Loader Error"); + }); + + test("Throwing a render error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=render"); + await waitForAndAssert(page, app, "#child-error", "Render Error"); + }); + + test("Throwing a Response to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#parent-error-response", + "418 Loader Response" + ); + }); + + test("Throwing an Error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=error"); + await waitForAndAssert(page, app, "#parent-error", "Loader Error"); + }); + + test("Throwing a render error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=render"); + await waitForAndAssert(page, app, "#parent-error", "Render Error"); + }); + } +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let oldConsoleError: () => void; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { + Link, + Outlet, + isRouteErrorResponse, + useLoaderData, + useRouteError, + } from "@remix-run/react"; + + export function loader() { + return "PARENT LOADER"; + } + + export default function Component() { + return ( +
+ +

{useLoaderData()}

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

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

: +

{error.message}

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

{data}

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

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

: +

{error.message}

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

{data}

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.beforeEach(({ page }) => { + oldConsoleError = console.error; + console.error = () => {}; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runBoundaryTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runBoundaryTests(); + + test("Network errors that never reach the Remix server", async ({ + page, + }) => { + // Cause a ?_data request to trigger an HTTP error that never reaches the + // Remix server, and ensure we properly handle it at the ErrorBoundary + await page.route(/\/parent\/child-with-boundary\.data$/, (route) => { + route.fulfill({ status: 500, body: "CDN Error!" }); + }); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#child-error", "CDN Error!"); + }); + }); + + function runBoundaryTests() { + test("No errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#child-data", "CHILD LOADER"); + }); + + test("Throwing a Response to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#child-error-response", + "418 Loader Response" + ); + }); + + test("Throwing an Error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=error"); + await waitForAndAssert(page, app, "#child-error", "Loader Error"); + }); + + test("Throwing a render error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=render"); + await waitForAndAssert(page, app, "#child-error", "Render Error"); + }); + + test("Throwing a Response to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#parent-error-response", + "418 Loader Response" + ); + }); + + test("Throwing an Error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=error"); + await waitForAndAssert(page, app, "#parent-error", "Loader Error"); + }); + + test("Throwing a render error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=render"); + await waitForAndAssert(page, app, "#parent-error", "Render Error"); + }); + } + }); +}); + +// Shorthand util to wait for an element to appear before asserting it +async function waitForAndAssert( + page: Page, + app: PlaywrightFixture, + selector: string, + match: string +) { + await page.waitForSelector(selector); + expect(await app.getHtml(selector)).toMatch(match); +} diff --git a/integration/error-data-request-test.ts b/integration/error-data-request-test.ts new file mode 100644 index 0000000000..b19e8cea4f --- /dev/null +++ b/integration/error-data-request-test.ts @@ -0,0 +1,353 @@ +import { test, expect } from "@playwright/test"; +import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from "@remix-run/router"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; + +test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + let errorLogs: any[] = []; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = (v) => errorLogs.push(v); + + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + + export default function () { + return

Index

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

Hello

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

Hello

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

Goodbye

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

Hi!

+ } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.beforeEach(async () => { + errorLogs = []; + }); + + test.afterAll(() => { + console.error = _consoleError; + appFixture.close(); + }); + + function assertLoggedErrorInstance(message: string) { + let error = errorLogs[0] as Error; + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual(message); + } + + test("returns a 400 x-remix-error on a data fetch to a path with no loader", async () => { + let response = await fixture.requestData("/", "routes/_index"); + expect(response.status).toBe(400); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + expect(errorLogs[0]).toBeInstanceOf(Error); + assertLoggedErrorInstance( + 'You made a GET request to "/" but did not provide a `loader` for route "routes/_index", so there is no way to handle the request.' + ); + }); + + test("returns a 405 x-remix-error on a data fetch POST to a path with no action", async () => { + let response = await fixture.requestData("/?index", "routes/_index", { + method: "POST", + }); + expect(response.status).toBe(405); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + assertLoggedErrorInstance( + 'You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' + ); + }); + + test("returns a 405 x-remix-error on a data fetch with a bad method", async () => { + expect(() => + fixture.requestData("/loader-return-json", "routes/loader-return-json", { + method: "TRACE", + }) + ).rejects.toThrowError( + `Failed to construct 'Request': 'TRACE' HTTP method is unsupported.` + ); + }); + + test("returns a 403 x-remix-error on a data fetch GET to a bad path", async () => { + // just headers content-type mismatch but differs from POST below + let response = await fixture.requestData("/", "routes/loader-return-json"); + expect(response.status).toBe(403); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + assertLoggedErrorInstance( + 'Route "routes/loader-return-json" does not match URL "/"' + ); + }); + + test("returns a 403 x-remix-error on a data fetch POST to a bad path", async () => { + let response = await fixture.requestData("/", "routes/loader-return-json", { + method: "POST", + }); + expect(response.status).toBe(403); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + assertLoggedErrorInstance( + 'Route "routes/loader-return-json" does not match URL "/"' + ); + }); + + test("returns a 404 x-remix-error on a data fetch to a path with no matches", async () => { + let response = await fixture.requestData("/i/match/nothing", "routes/junk"); + expect(response.status).toBe(404); + expect(response.headers.get("X-Remix-Error")).toBe("yes"); + expect(await response.text()).toMatch("Unexpected Server Error"); + assertLoggedErrorInstance('No route matches URL "/i/match/nothing"'); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + let errorLogs: any[]; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = (v) => errorLogs.push(v); + + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "@remix-run/react"; + + export default function () { + return

Index

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

Hello

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

Hello

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

Goodbye

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

Hi!

+ } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.beforeEach(async () => { + errorLogs = []; + }); + + test.afterAll(() => { + console.error = _consoleError; + appFixture.close(); + }); + + function assertLoggedErrorInstance(message: string) { + let error = errorLogs[0] as Error; + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual(message); + } + + test("returns a 200 empty response on a data fetch to a path with no loaders", async () => { + let { status, headers, data } = await fixture.requestSingleFetchData( + "/_root.data" + ); + expect(status).toBe(200); + expect(headers.has("X-Remix-Error")).toBe(false); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: null, + }, + }); + }); + + test("returns a 405 on a data fetch POST to a path with no action", async () => { + let { status, headers, data } = await fixture.requestSingleFetchData( + "/_root.data?index", + { + method: "POST", + } + ); + expect(status).toBe(405); + expect(headers.has("X-Remix-Error")).toBe(false); + expect(data).toEqual({ + error: new ErrorResponseImpl( + 405, + "Method Not Allowed", + 'Error: You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' + ), + }); + assertLoggedErrorInstance( + 'You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' + ); + }); + + test("returns a 405 on a data fetch with a bad method", async () => { + expect(() => + fixture.requestSingleFetchData("/loader-return-json.data", { + method: "TRACE", + }) + ).rejects.toThrowError( + `Failed to construct 'Request': 'TRACE' HTTP method is unsupported.` + ); + }); + + test("returns a 404 on a data fetch to a path with no matches", async () => { + let { status, headers, data } = await fixture.requestSingleFetchData( + "/i/match/nothing.data" + ); + expect(status).toBe(404); + expect(headers.has("X-Remix-Error")).toBe(false); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/i/match/nothing"' + ), + }, + }); + assertLoggedErrorInstance('No route matches URL "/i/match/nothing"'); + }); + }); +}); diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts new file mode 100644 index 0000000000..40cae6ab04 --- /dev/null +++ b/integration/error-sanitization-test.ts @@ -0,0 +1,1290 @@ +import { test, expect } from "@playwright/test"; +import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from "@remix-run/router"; + +import { ServerMode } from "../build/node_modules/@remix-run/server-runtime/dist/mode.js"; +import type { Fixture } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +const routeFiles = { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { useLoaderData, useLocation, useRouteError } from "@remix-run/react"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + throw new Error("Loader Error"); + } + if (new URL(request.url).searchParams.has('subclass')) { + // This will throw a ReferenceError + console.log(thisisnotathing); + } + return "LOADER" + } + + export default function Component() { + let data = useLoaderData(); + let location = useLocation(); + + if (location.search.includes('render')) { + throw new Error("Render Error"); + } + + return ( + <> +

Index Route

+

{JSON.stringify(data)}

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

Index Error

+

{"MESSAGE:" + error.message}

+

{"NAME:" + error.name}

+ {error.stack ?

{"STACK:" + error.stack}

: null} + + ); + } + `, + + "app/routes/defer.tsx": js` + import * as React from 'react'; + import { defer } from "@remix-run/server-runtime"; + import { Await, useAsyncError, useLoaderData, useRouteError } from "@remix-run/react"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + return defer({ + lazy: Promise.reject(new Error("REJECTED")), + }) + } + return defer({ + lazy: Promise.resolve("RESOLVED"), + }) + } + + export default function Component() { + let data = useLoaderData(); + + return ( + <> +

Defer Route

+ Loading...

}> + }> + {(val) =>

{val}

} +
+
+ + ); + } + + function AwaitError() { + let error = useAsyncError(); + return ( + <> +

Defer Error

+

{error.message}

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

Defer Error

+

{"MESSAGE:" + error.message}

+ {error.stack ?

{"STACK:" + error.stack}

: null} + + ); + } + `, + + "app/routes/resource.tsx": js` + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + throw new Error("Loader Error"); + } + return "RESOURCE LOADER" + } + `, +}; + +test.describe("Error Sanitization", () => { + let fixture: Fixture; + let oldConsoleError: () => void; + let errorLogs: any[] = []; + + test.beforeEach(() => { + oldConsoleError = console.error; + errorLogs = []; + console.error = (...args) => errorLogs.push(args); + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("serverMode=production", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: routeFiles, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch('{"message":"Unexpected Server Error"}'); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).toMatch("x.stack=undefined;"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let response = await fixture.requestData("/", "routes/_index"); + let text = await response.text(); + expect(text).toMatch("LOADER"); + expect(text).not.toMatch("MESSAGE:"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in data requests", async () => { + let response = await fixture.requestData("/?loader", "routes/_index"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let response = await fixture.requestData("/defer", "routes/defer"); + let text = await response.text(); + expect(text).toMatch("RESOLVED"); + expect(text).not.toMatch("REJECTED"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let response = await fixture.requestData("/defer?loader", "routes/defer"); + let text = await response.text(); + expect(text).toBe( + '{"lazy":"__deferred_promise:lazy"}\n\n' + + 'error:{"lazy":{"message":"Unexpected Server Error"}}\n\n' + ); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + // Note: This is currently inconsistent with document requests - we do not + // serialize ErrorResponse as Errors in document requests and we do send the + // data (i.e., Route "not-a-route" does not match URL "/"). Probably no + // real need to align those now with data requests on the way out - we + // have aligned them in single fetch + test("sanitizes mismatched route errors in data requests", async () => { + let response = await fixture.requestData("/", "not-a-route"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch( + 'Route "not-a-route" does not match URL "/"' + ); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not support hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

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

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

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

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

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

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

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

STACK:Error: Render Error"); + expect(html).toMatch( + 'errors":{"routes/_index":{"message":"Render Error","stack":"Error: Render Error\\n' + ); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/"stack":/i); + }); + + test("does not sanitize defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).toMatch("x.stack=e.stack;"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let response = await fixture.requestData("/", "routes/_index"); + let text = await response.text(); + expect(text).toMatch("LOADER"); + expect(text).not.toMatch("MESSAGE:"); + expect(text).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in data requests", async () => { + let response = await fixture.requestData("/?loader", "routes/_index"); + let text = await response.text(); + expect(text).toMatch( + '{"message":"Loader Error","stack":"Error: Loader Error' + ); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let response = await fixture.requestData("/defer", "routes/defer"); + let text = await response.text(); + expect(text).toMatch("RESOLVED"); + expect(text).not.toMatch("REJECTED"); + expect(text).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in deferred data requests", async () => { + let response = await fixture.requestData("/defer?loader", "routes/defer"); + let text = await response.text(); + expect(text).toMatch( + 'error:{"lazy":{"message":"REJECTED","stack":"Error: REJECTED' + ); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("does not sanitize loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error\n\nError: Loader Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let response = await fixture.requestData("/", "not-a-route"); + let text = await response.text(); + expect(text).toMatch( + '{"message":"Route \\"not-a-route\\" does not match URL \\"/\\"","stack":"Error: Route \\"not-a-route\\" does not match URL \\"/\\"' + ); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch( + 'Route "not-a-route" does not match URL "/"' + ); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("supports hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

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

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

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

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

NAME:ReferenceError"); + expect(html).toMatch( + "STACK:ReferenceError: thisisnotathing is not defined" + ); + }); + }); + + test.describe("serverMode=production (user-provided handleError)", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: { + "app/entry.server.tsx": js` + import type { EntryContext } from "@remix-run/node"; + import { RemixServer, isRouteErrorResponse } from "@remix-run/react"; + import { renderToString } from "react-dom/server"; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + let markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); + } + + export function handleError( + error: unknown, + { request }: { request: Request }, + ) { + console.error("App Specific Error Logging:"); + console.error(" Request: " + request.method + " " + request.url); + if (isRouteErrorResponse(error)) { + console.error(" Status: " + error.status + " " + error.statusText); + console.error(" Error: " + error.error.message); + console.error(" Stack: " + error.error.stack); + } else if (error instanceof Error) { + console.error(" Error: " + error.message); + console.error(" Stack: " + error.stack); + } else { + console.error("Dunno what this is"); + } + } + `, + ...routeFiles, + }, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?loader"); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + expect(html).toMatch( + '{"routes/_index":{"message":"Unexpected Server Error","__type":"Error"}}' + ); + expect(html).not.toMatch(/stack/i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?render"); + expect(errorLogs[2][0]).toEqual(" Error: Render Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch('{"message":"Unexpected Server Error"}'); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).toMatch("x.stack=undefined;"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let response = await fixture.requestData("/", "routes/_index"); + let text = await response.text(); + expect(text).toMatch("LOADER"); + expect(text).not.toMatch("MESSAGE:"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in data requests", async () => { + let response = await fixture.requestData("/?loader", "routes/_index"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/?loader=&_data=routes%2F_index" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("returns deferred data without errors", async () => { + let response = await fixture.requestData("/defer", "routes/defer"); + let text = await response.text(); + expect(text).toMatch("RESOLVED"); + expect(text).not.toMatch("REJECTED"); + expect(text).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let response = await fixture.requestData("/defer?loader", "routes/defer"); + let text = await response.text(); + expect(text).toBe( + '{"lazy":"__deferred_promise:lazy"}\n\n' + + 'error:{"lazy":{"message":"Unexpected Server Error"}}\n\n' + ); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error"); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/resource?loader" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("sanitizes mismatched route errors in data requests", async () => { + let response = await fixture.requestData("/", "not-a-route"); + let text = await response.text(); + expect(text).toBe('{"message":"Unexpected Server Error"}'); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/?_data=not-a-route" + ); + expect(errorLogs[2][0]).toEqual(" Status: 403 Forbidden"); + expect(errorLogs[3][0]).toEqual( + ' Error: Route "not-a-route" does not match URL "/"' + ); + expect(errorLogs[4][0]).toMatch(" at "); + expect(errorLogs.length).toBe(5); + }); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("Error Sanitization", () => { + let fixture: Fixture; + let oldConsoleError: () => void; + let errorLogs: any[] = []; + + test.beforeEach(() => { + oldConsoleError = console.error; + errorLogs = []; + console.error = (...args) => errorLogs.push(args); + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("serverMode=production", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: routeFiles, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("Unexpected Server Error"); + expect(html).not.toMatch("stack"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("sanitizes loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData( + "/_root.data?loader" + ); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + error: new Error("Unexpected Server Error"), + }, + }); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData( + "/defer.data?loader" + ); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("Unexpected Server Error"); + expect((e as Error).stack).toBeUndefined(); + } + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData( + "/not-a-route.data" + ); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs).toEqual([ + [new Error('No route matches URL "/not-a-route"')], + ]); + }); + + test("does not support hydration of Error subclasses", async ({ + page, + }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

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

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

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

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

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

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

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

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

REJECTED

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

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

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

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

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

NAME:ReferenceError"); + expect(html).toMatch( + "STACK:ReferenceError: thisisnotathing is not defined" + ); + }); + }); + + test.describe("serverMode=production (user-provided handleError)", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + + import { createReadableStreamFromReadable } from "@remix-run/node"; + import { RemixServer, isRouteErrorResponse } from "@remix-run/react"; + import { renderToPipeableStream } from "react-dom/server"; + + const ABORT_DELAY = 5_000; + + export default function handleRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error) { + reject(error); + }, + onError(error) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); + } + + export function handleError( + error: unknown, + { request }: { request: Request }, + ) { + console.error("App Specific Error Logging:"); + console.error(" Request: " + request.method + " " + request.url); + if (isRouteErrorResponse(error)) { + console.error(" Status: " + error.status + " " + error.statusText); + console.error(" Error: " + error.error.message); + console.error(" Stack: " + error.error.stack); + } else if (error instanceof Error) { + console.error(" Error: " + error.message); + console.error(" Stack: " + error.stack); + } else { + console.error("Dunno what this is"); + } + } + `, + ...routeFiles, + }, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?loader"); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?render"); + expect(errorLogs[2][0]).toEqual(" Error: Render Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("Unexpected Server Error"); + expect(html).not.toMatch("stack"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("sanitizes loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData( + "/_root.data?loader" + ); + expect(data).toEqual({ + root: { data: null }, + "routes/_index": { error: new Error("Unexpected Server Error") }, + }); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/_root.data?loader" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toBe("RESOLVED"); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData( + "/defer.data?loader" + ); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("Unexpected Server Error"); + expect((e as Error).stack).toBeUndefined(); + } + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error"); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/resource?loader" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData( + "/not-a-route.data" + ); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/not-a-route.data" + ); + expect(errorLogs[2][0]).toEqual(" Status: 404 Not Found"); + expect(errorLogs[3][0]).toEqual( + ' Error: No route matches URL "/not-a-route"' + ); + expect(errorLogs[4][0]).toMatch(" at "); + expect(errorLogs.length).toBe(5); + }); + }); + }); +}); diff --git a/integration/fetch-globals-test.ts b/integration/fetch-globals-test.ts new file mode 100644 index 0000000000..3592f230ae --- /dev/null +++ b/integration/fetch-globals-test.ts @@ -0,0 +1,43 @@ +import { test, expect } from "@playwright/test"; + +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 { useLoaderData } from "@remix-run/react"; + export async function loader() { + const resp = await fetch('https://reqres.in/api/users?page=2'); + return (resp instanceof Response) ? 'is an instance of global Response' : 'is not an instance of global Response'; + } + export default function Index() { + let data = useLoaderData(); + return ( +

+ {data} +
+ ) + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(async () => appFixture.close()); + +test("returned variable from fetch() should be instance of global Response", async () => { + let response = await fixture.requestDocument("/"); + expect(await response.text()).toMatch("is an instance of global Response"); +}); diff --git a/integration/fetcher-layout-test.ts b/integration/fetcher-layout-test.ts new file mode 100644 index 0000000000..cd29f91a79 --- /dev/null +++ b/integration/fetcher-layout-test.ts @@ -0,0 +1,563 @@ +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("multi fetch", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/layout-action.tsx": js` + import { json } from "@remix-run/node"; + import { Outlet, useFetcher, useFormAction } from "@remix-run/react"; + + export let action = ({ params }) => json("layout action data"); + + export default function ActionLayout() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.submit({}, { method: "post", action }); + }; + + return ( +
+

Layout

+ + {!!fetcher.data &&

{fetcher.data}

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

{data}

+ + {!!fetcher.data &&

{fetcher.data}

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

{data}

+ + {!!fetcher.data &&

{fetcher.data}

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

Layout

+ + {!!fetcher.data &&

{fetcher.data}

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

{fetcher.data}

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

{fetcher.data}

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

Layout

+ + {!!fetcher.data &&

{fetcher.data}

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

{data}

+ + {!!fetcher.data &&

{fetcher.data}

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

{data}

+ + {!!fetcher.data &&

{fetcher.data}

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

Layout

+ + {!!fetcher.data &&

{fetcher.data}

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

{fetcher.data}

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

{fetcher.data}

} + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("fetcher calls layout route action when at index route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("index data"); + }); + + test("fetcher calls layout route loader when at index route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout loader data"); + }); + + test("fetcher calls index route action when at index route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action"); + await app.clickElement("#index-fetcher"); + await page.waitForSelector("#index-fetcher-data"); + let dataElement = await app.getElement("#index-fetcher-data"); + expect(dataElement.text()).toBe("index action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("index data"); + }); + + test("fetcher calls index route loader when at index route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader"); + await app.clickElement("#index-fetcher"); + await page.waitForSelector("#index-fetcher-data"); + let dataElement = await app.getElement("#index-fetcher-data"); + expect(dataElement.text()).toBe("index data"); + }); + + test("fetcher calls layout route action when at paramaterized route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action/foo"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("foo"); + }); + + test("fetcher calls layout route loader when at parameterized route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader/foo"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout loader data"); + }); + + test("fetcher calls parameterized route route action", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action/foo"); + await app.clickElement("#param-fetcher"); + await page.waitForSelector("#param-fetcher-data"); + let dataElement = await app.getElement("#param-fetcher-data"); + expect(dataElement.text()).toBe("param action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("foo"); + }); + + test("fetcher calls parameterized route route loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader/foo"); + await app.clickElement("#param-fetcher"); + await page.waitForSelector("#param-fetcher-data"); + let dataElement = await app.getElement("#param-fetcher-data"); + expect(dataElement.text()).toBe("foo"); + }); +}); diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts new file mode 100644 index 0000000000..ad7959f6b9 --- /dev/null +++ b/integration/fetcher-test.ts @@ -0,0 +1,1065 @@ +import { expect, test } 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("useFetcher", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let CHEESESTEAK = "CHEESESTEAK"; + let LUNCH = "LUNCH"; + let PARENT_LAYOUT_LOADER = "parent layout loader"; + let PARENT_LAYOUT_ACTION = "parent layout action"; + let PARENT_INDEX_LOADER = "parent index loader"; + let PARENT_INDEX_ACTION = "parent index action"; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/resource-route-action-only.ts": js` + import { json } from "@remix-run/node"; + export function action() { + return json("${CHEESESTEAK}"); + } + `, + + "app/routes/fetcher-action-only-call.tsx": js` + import { useFetcher } from "@remix-run/react"; + + export default function FetcherActionOnlyCall() { + let fetcher = useFetcher(); + + let executeFetcher = () => { + fetcher.submit(new URLSearchParams(), { + method: 'post', + action: '/resource-route-action-only', + }); + }; + + return ( + <> + + {fetcher.data &&
{fetcher.data}
} + + ); + } + `, + + "app/routes/resource-route.tsx": js` + export function loader() { + return "${LUNCH}"; + } + export function action() { + return "${CHEESESTEAK}"; + } + `, + + "app/routes/_index.tsx": js` + import { useFetcher } from "@remix-run/react"; + export default function Index() { + let fetcher = useFetcher(); + return ( + <> + + + + + + +
{fetcher.data}
+ + ); + } + `, + + "app/routes/parent.tsx": js` + import { Outlet } from "@remix-run/react"; + + export function action() { + return "${PARENT_LAYOUT_ACTION}"; + }; + + export function loader() { + return "${PARENT_LAYOUT_LOADER}"; + }; + + export default function Parent() { + return ; + } + `, + + "app/routes/parent._index.tsx": js` + import { useFetcher } from "@remix-run/react"; + + export function action() { + return "${PARENT_INDEX_ACTION}"; + }; + + export function loader() { + return "${PARENT_INDEX_LOADER}"; + }; + + export default function ParentIndex() { + let fetcher = useFetcher(); + + return ( + <> +
{fetcher.data}
+ + + + + + + + + ); + } + `, + + "app/routes/fetcher-echo.tsx": js` + import { json } from "@remix-run/node"; + import { useFetcher } from "@remix-run/react"; + + export async function action({ request }) { + await new Promise(r => setTimeout(r, 1000)); + let contentType = request.headers.get('Content-Type'); + let value; + if (contentType.includes('application/json')) { + let json = await request.json(); + value = json === null ? json : json.value; + } else if (contentType.includes('text/plain')) { + value = await request.text(); + } else { + value = (await request.formData()).get('value'); + } + return json({ data: "ACTION (" + contentType + ") " + value }) + } + + export async function loader({ request }) { + await new Promise(r => setTimeout(r, 1000)); + let value = new URL(request.url).searchParams.get('value'); + return json({ data: "LOADER " + value }) + } + + export default function Index() { + let fetcherValues = []; + if (typeof window !== 'undefined') { + if (!window.fetcherValues) { + window.fetcherValues = []; + } + fetcherValues = window.fetcherValues + } + + let fetcher = useFetcher(); + + let currentValue = fetcher.state + '/' + fetcher.data?.data; + if (fetcherValues[fetcherValues.length - 1] !== currentValue) { + fetcherValues.push(currentValue) + } + + return ( + <> + + + + + + + + + {fetcher.state === 'idle' ?

IDLE

: null} +
{JSON.stringify(fetcherValues)}
+ + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("No JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("Form can hit a loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await Promise.all([ + page.waitForNavigation(), + app.clickSubmitButton("/resource-route", { + wait: false, + method: "get", + }), + ]); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // a
 but Edge puts it in some weird code editor markup:
+      // 
+      //   
+      expect(await app.getHtml()).toContain(LUNCH);
+    });
+
+    test("Form can hit an action", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/");
+      await Promise.all([
+        page.waitForNavigation({ waitUntil: "load" }),
+        app.clickSubmitButton("/resource-route", {
+          wait: false,
+          method: "post",
+        }),
+      ]);
+      // Check full HTML here - Chromium/Firefox/Webkit seem to render this in
+      // a 
 but Edge puts it in some weird code editor markup:
+      // 
+      //   
+      expect(await app.getHtml()).toContain(CHEESESTEAK);
+    });
+  });
+
+  test("load can hit a loader", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/");
+    await app.clickElement("#fetcher-load");
+    await page.waitForSelector(`pre:has-text("${LUNCH}")`);
+  });
+
+  test("submit can hit an action", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/");
+    await app.clickElement("#fetcher-submit");
+    await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
+  });
+
+  test("submit can hit an action with json", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-echo", true);
+    await page.fill("#fetcher-input", "input value");
+    await app.clickElement("#fetcher-submit-json");
+    await page.waitForSelector(`#fetcher-idle`);
+    expect(await app.getHtml()).toMatch(
+      'ACTION (application/json) input value"'
+    );
+  });
+
+  test("submit can hit an action with null json", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-echo", true);
+    await app.clickElement("#fetcher-submit-json-null");
+    await new Promise((r) => setTimeout(r, 1000));
+    await page.waitForSelector(`#fetcher-idle`);
+    expect(await app.getHtml()).toMatch('ACTION (application/json) null"');
+  });
+
+  test("submit can hit an action with text", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-echo", true);
+    await page.fill("#fetcher-input", "input value");
+    await app.clickElement("#fetcher-submit-text");
+    await page.waitForSelector(`#fetcher-idle`);
+    expect(await app.getHtml()).toMatch(
+      'ACTION (text/plain;charset=UTF-8) input value"'
+    );
+  });
+
+  test("submit can hit an action with empty text", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-echo", true);
+    await app.clickElement("#fetcher-submit-text-empty");
+    await new Promise((r) => setTimeout(r, 1000));
+    await page.waitForSelector(`#fetcher-idle`);
+    expect(await app.getHtml()).toMatch('ACTION (text/plain;charset=UTF-8) "');
+  });
+
+  test("submit can hit an action only route", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-action-only-call");
+    await app.clickElement("#fetcher-submit");
+    await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
+  });
+
+  test("fetchers handle ?index param correctly", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/parent");
+
+    await app.clickElement("#load-parent");
+    await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
+
+    await app.clickElement("#load-index");
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+    // fetcher.submit({}) defaults to GET for the current Route
+    await app.clickElement("#submit-empty");
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+    await app.clickElement("#submit-parent-get");
+    await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
+
+    await app.clickElement("#submit-index-get");
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+    await app.clickElement("#submit-parent-post");
+    await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_ACTION}")`);
+
+    await app.clickElement("#submit-index-post");
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_ACTION}")`);
+  });
+
+  test("fetcher.load persists data through reloads", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+
+    await app.goto("/fetcher-echo", true);
+    expect(await app.getHtml("pre")).toMatch(
+      JSON.stringify(["idle/undefined"])
+    );
+
+    await page.fill("#fetcher-input", "1");
+    await app.clickElement("#fetcher-load");
+    await page.waitForSelector("#fetcher-idle");
+    expect(await app.getHtml("pre")).toMatch(
+      JSON.stringify(["idle/undefined", "loading/undefined", "idle/LOADER 1"])
+    );
+
+    await page.fill("#fetcher-input", "2");
+    await app.clickElement("#fetcher-load");
+    await page.waitForSelector("#fetcher-idle");
+    expect(await app.getHtml("pre")).toMatch(
+      JSON.stringify([
+        "idle/undefined",
+        "loading/undefined",
+        "idle/LOADER 1",
+        "loading/LOADER 1", // Preserves old data during reload
+        "idle/LOADER 2",
+      ])
+    );
+  });
+
+  test("fetcher.submit persists data through resubmissions", async ({
+    page,
+  }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+
+    await app.goto("/fetcher-echo", true);
+    expect(await app.getHtml("pre")).toMatch(
+      JSON.stringify(["idle/undefined"])
+    );
+
+    await page.fill("#fetcher-input", "1");
+    await app.clickElement("#fetcher-submit");
+    await page.waitForSelector("#fetcher-idle");
+    expect(await app.getHtml("pre")).toMatch(
+      JSON.stringify([
+        "idle/undefined",
+        "submitting/undefined",
+        "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+      ])
+    );
+
+    await page.fill("#fetcher-input", "2");
+    await app.clickElement("#fetcher-submit");
+    await page.waitForSelector("#fetcher-idle");
+    expect(await app.getHtml("pre")).toMatch(
+      JSON.stringify([
+        "idle/undefined",
+        "submitting/undefined",
+        "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        // Preserves old data during resubmissions
+        "submitting/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
+        "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
+      ])
+    );
+  });
+});
+
+test.describe("fetcher aborts and adjacent forms", () => {
+  let fixture: Fixture;
+  let appFixture: AppFixture;
+
+  test.beforeAll(async () => {
+    fixture = await createFixture({
+      files: {
+        "app/routes/_index.tsx": js`
+          import * as React from "react";
+          import {
+            Form,
+            useFetcher,
+            useLoaderData,
+            useNavigation
+          } from "@remix-run/react";
+
+          export async function loader({ request }) {
+            // 1 second timeout on data
+            await new Promise((r) => setTimeout(r, 1000));
+            return { foo: 'bar' };
+          }
+
+          export default function Index() {
+            const [open, setOpen] = React.useState(true);
+            const { data } = useLoaderData();
+            const navigation = useNavigation();
+
+            return (
+              
+ {navigation.state === 'idle' &&
Idle
} +
+ +
+ + + {open && setOpen(false)} />} +
+ ); + } + + function Child({ onClose }) { + const fetcher = useFetcher(); + + return ( + + + + + ); + } + `, + + "app/routes/api.tsx": js` + export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 500)); + return { message: 'Hello world!' } + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("Unmounting a fetcher does not cancel the request of an adjacent form", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Works as expected before the fetcher is loaded + + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for our navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + + // Breaks after the fetcher is loaded + + // re-mount the fetcher form + await app.clickElement("#open"); + // submit the fetcher form + await app.clickElement("#submit-fetcher"); + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("useFetcher", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let CHEESESTEAK = "CHEESESTEAK"; + let LUNCH = "LUNCH"; + let PARENT_LAYOUT_LOADER = "parent layout loader"; + let PARENT_LAYOUT_ACTION = "parent layout action"; + let PARENT_INDEX_LOADER = "parent index loader"; + let PARENT_INDEX_ACTION = "parent index action"; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/resource-route-action-only.ts": js` + import { json } from "@remix-run/node"; + export function action() { + return new Response("${CHEESESTEAK}"); + } + `, + + "app/routes/fetcher-action-only-call.tsx": js` + import { useFetcher } from "@remix-run/react"; + + export default function FetcherActionOnlyCall() { + let fetcher = useFetcher(); + + let executeFetcher = () => { + fetcher.submit(new URLSearchParams(), { + method: 'post', + action: '/resource-route-action-only', + }); + }; + + return ( + <> + + {fetcher.data &&
{fetcher.data}
} + + ); + } + `, + + "app/routes/resource-route.tsx": js` + export function loader() { + return new Response("${LUNCH}"); + } + export function action() { + return new Response("${CHEESESTEAK}"); + } + `, + + "app/routes/_index.tsx": js` + import { useFetcher } from "@remix-run/react"; + export default function Index() { + let fetcher = useFetcher(); + return ( + <> + + + + + + +
{fetcher.data}
+ + ); + } + `, + + "app/routes/parent.tsx": js` + import { Outlet } from "@remix-run/react"; + + export function action() { + return new Response("${PARENT_LAYOUT_ACTION}"); + }; + + export function loader() { + return new Response("${PARENT_LAYOUT_LOADER}"); + }; + + export default function Parent() { + return ; + } + `, + + "app/routes/parent._index.tsx": js` + import { useFetcher } from "@remix-run/react"; + + export function action() { + return new Response("${PARENT_INDEX_ACTION}"); + }; + + export function loader() { + return new Response("${PARENT_INDEX_LOADER}"); + }; + + export default function ParentIndex() { + let fetcher = useFetcher(); + + return ( + <> +
{fetcher.data}
+ + + + + + + + + ); + } + `, + + "app/routes/fetcher-echo.tsx": js` + import { json } from "@remix-run/node"; + import { useFetcher } from "@remix-run/react"; + + export async function action({ request }) { + await new Promise(r => setTimeout(r, 1000)); + let contentType = request.headers.get('Content-Type'); + let value; + if (contentType.includes('application/json')) { + let json = await request.json(); + value = json === null ? json : json.value; + } else if (contentType.includes('text/plain')) { + value = await request.text(); + } else { + value = (await request.formData()).get('value'); + } + return json({ data: "ACTION (" + contentType + ") " + value }) + } + + export async function loader({ request }) { + await new Promise(r => setTimeout(r, 1000)); + let value = new URL(request.url).searchParams.get('value'); + return json({ data: "LOADER " + value }) + } + + export default function Index() { + let fetcherValues = []; + if (typeof window !== 'undefined') { + if (!window.fetcherValues) { + window.fetcherValues = []; + } + fetcherValues = window.fetcherValues + } + + let fetcher = useFetcher(); + + let currentValue = fetcher.state + '/' + fetcher.data?.data; + if (fetcherValues[fetcherValues.length - 1] !== currentValue) { + fetcherValues.push(currentValue) + } + + return ( + <> + + + + + + + + + {fetcher.state === 'idle' ?

IDLE

: null} +
{JSON.stringify(fetcherValues)}
+ + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("No JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("Form can hit a loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await Promise.all([ + page.waitForNavigation(), + app.clickSubmitButton("/resource-route", { + wait: false, + method: "get", + }), + ]); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // a
 but Edge puts it in some weird code editor markup:
+        // 
+        //   
+        expect(await app.getHtml()).toContain(LUNCH);
+      });
+
+      test("Form can hit an action", async ({ page }) => {
+        let app = new PlaywrightFixture(appFixture, page);
+        await app.goto("/");
+        await Promise.all([
+          page.waitForNavigation({ waitUntil: "load" }),
+          app.clickSubmitButton("/resource-route", {
+            wait: false,
+            method: "post",
+          }),
+        ]);
+        // Check full HTML here - Chromium/Firefox/Webkit seem to render this in
+        // a 
 but Edge puts it in some weird code editor markup:
+        // 
+        //   
+        expect(await app.getHtml()).toContain(CHEESESTEAK);
+      });
+    });
+
+    test("load can hit a loader", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/");
+      await app.clickElement("#fetcher-load");
+      await page.waitForSelector(`pre:has-text("${LUNCH}")`);
+    });
+
+    test("submit can hit an action", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/");
+      await app.clickElement("#fetcher-submit");
+      await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
+    });
+
+    test("submit can hit an action with json", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-echo", true);
+      await page.fill("#fetcher-input", "input value");
+      await app.clickElement("#fetcher-submit-json");
+      await page.waitForSelector(`#fetcher-idle`);
+      expect(await app.getHtml()).toMatch(
+        'ACTION (application/json) input value"'
+      );
+    });
+
+    test("submit can hit an action with null json", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-echo", true);
+      await app.clickElement("#fetcher-submit-json-null");
+      await new Promise((r) => setTimeout(r, 1000));
+      await page.waitForSelector(`#fetcher-idle`);
+      expect(await app.getHtml()).toMatch('ACTION (application/json) null"');
+    });
+
+    test("submit can hit an action with text", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-echo", true);
+      await page.fill("#fetcher-input", "input value");
+      await app.clickElement("#fetcher-submit-text");
+      await page.waitForSelector(`#fetcher-idle`);
+      expect(await app.getHtml()).toMatch(
+        'ACTION (text/plain;charset=UTF-8) input value"'
+      );
+    });
+
+    test("submit can hit an action with empty text", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-echo", true);
+      await app.clickElement("#fetcher-submit-text-empty");
+      await new Promise((r) => setTimeout(r, 1000));
+      await page.waitForSelector(`#fetcher-idle`);
+      expect(await app.getHtml()).toMatch(
+        'ACTION (text/plain;charset=UTF-8) "'
+      );
+    });
+
+    test("submit can hit an action only route", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/fetcher-action-only-call");
+      await app.clickElement("#fetcher-submit");
+      await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
+    });
+
+    test("fetchers handle ?index param correctly", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/parent");
+
+      await app.clickElement("#load-parent");
+      await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
+
+      await app.clickElement("#load-index");
+      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+      // fetcher.submit({}) defaults to GET for the current Route
+      await app.clickElement("#submit-empty");
+      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+      await app.clickElement("#submit-parent-get");
+      await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
+
+      await app.clickElement("#submit-index-get");
+      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+      await app.clickElement("#submit-parent-post");
+      await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_ACTION}")`);
+
+      await app.clickElement("#submit-index-post");
+      await page.waitForSelector(`pre:has-text("${PARENT_INDEX_ACTION}")`);
+    });
+
+    test("fetcher.load persists data through reloads", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+
+      await app.goto("/fetcher-echo", true);
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify(["idle/undefined"])
+      );
+
+      await page.fill("#fetcher-input", "1");
+      await app.clickElement("#fetcher-load");
+      await page.waitForSelector("#fetcher-idle");
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify(["idle/undefined", "loading/undefined", "idle/LOADER 1"])
+      );
+
+      await page.fill("#fetcher-input", "2");
+      await app.clickElement("#fetcher-load");
+      await page.waitForSelector("#fetcher-idle");
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify([
+          "idle/undefined",
+          "loading/undefined",
+          "idle/LOADER 1",
+          "loading/LOADER 1", // Preserves old data during reload
+          "idle/LOADER 2",
+        ])
+      );
+    });
+
+    test("fetcher.submit persists data through resubmissions", async ({
+      page,
+    }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+
+      await app.goto("/fetcher-echo", true);
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify(["idle/undefined"])
+      );
+
+      await page.fill("#fetcher-input", "1");
+      await app.clickElement("#fetcher-submit");
+      await page.waitForSelector("#fetcher-idle");
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify([
+          "idle/undefined",
+          "submitting/undefined",
+          "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+          "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        ])
+      );
+
+      await page.fill("#fetcher-input", "2");
+      await app.clickElement("#fetcher-submit");
+      await page.waitForSelector("#fetcher-idle");
+      expect(await app.getHtml("pre")).toMatch(
+        JSON.stringify([
+          "idle/undefined",
+          "submitting/undefined",
+          "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+          "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+          // Preserves old data during resubmissions
+          "submitting/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+          "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
+          "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
+        ])
+      );
+    });
+  });
+
+  test.describe("fetcher aborts and adjacent forms", () => {
+    let fixture: Fixture;
+    let appFixture: AppFixture;
+
+    test.beforeAll(async () => {
+      fixture = await createFixture({
+        config: {
+          future: {
+            unstable_singleFetch: true,
+          },
+        },
+        files: {
+          "app/routes/_index.tsx": js`
+            import * as React from "react";
+            import {
+              Form,
+              useFetcher,
+              useLoaderData,
+              useNavigation
+            } from "@remix-run/react";
+
+            export async function loader({ request }) {
+              // 1 second timeout on data
+              await new Promise((r) => setTimeout(r, 1000));
+              return { foo: 'bar' };
+            }
+
+            export default function Index() {
+              const [open, setOpen] = React.useState(true);
+              const { data } = useLoaderData();
+              const navigation = useNavigation();
+
+              return (
+                
+ {navigation.state === 'idle' &&
Idle
} +
+ +
+ + + {open && setOpen(false)} />} +
+ ); + } + + function Child({ onClose }) { + const fetcher = useFetcher(); + + return ( + + + + + ); + } + `, + + "app/routes/api.tsx": js` + export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 500)); + return { message: 'Hello world!' } + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("Unmounting a fetcher does not cancel the request of an adjacent form", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Works as expected before the fetcher is loaded + + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for our navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + + // Breaks after the fetcher is loaded + + // re-mount the fetcher form + await app.clickElement("#open"); + // submit the fetcher form + await app.clickElement("#submit-fetcher"); + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + }); + }); +}); diff --git a/integration/file-uploads-test.ts b/integration/file-uploads-test.ts new file mode 100644 index 0000000000..ccc95be5e9 --- /dev/null +++ b/integration/file-uploads-test.ts @@ -0,0 +1,295 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as url from "node:url"; +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 } from "./helpers/playwright-fixture.js"; + +test.describe("file-uploads", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/fileUploadHandler.ts": js` + import * as path from "node:path"; + import * as url from "node:url"; + import { + unstable_composeUploadHandlers as composeUploadHandlers, + unstable_createFileUploadHandler as createFileUploadHandler, + unstable_createMemoryUploadHandler as createMemoryUploadHandler, + } from "@remix-run/node"; + + const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); + export let uploadHandler = composeUploadHandlers( + createFileUploadHandler({ + directory: path.resolve(__dirname, "..", "uploads"), + maxPartSize: 10_000, // 10kb + // you probably want to avoid conflicts in production + // do not set to false or passthrough filename in real + // applications. + avoidFileConflicts: false, + file: ({ filename }) => filename + }), + createMemoryUploadHandler(), + ); + `, + "app/routes/file-upload.tsx": js` + import { + unstable_parseMultipartFormData as parseMultipartFormData, + } from "@remix-run/node"; + import { Form, useActionData } from "@remix-run/react"; + import { uploadHandler } from "~/fileUploadHandler"; + + export let action = async ({ request }) => { + try { + let formData = await parseMultipartFormData(request, uploadHandler); + + if (formData.get("test") !== "hidden") { + return { errorMessage: "hidden field not in form data" }; + } + + let file = formData.get("file"); + if (typeof file === "string" || !file) { + return { errorMessage: "invalid file type" }; + } + + return { name: file.name, size: file.size }; + } catch (error) { + return { errorMessage: error.message }; + } + }; + + export default function Upload() { + let actionData = useActionData(); + return ( + <> +
+ + + + +
+ {actionData ?
{JSON.stringify(actionData, null, 2)}
: null} + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("handles files under upload size limit", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let uploadFile = path.join( + fixture.projectDir, + "toUpload", + "underLimit.txt" + ); + let uploadData = Array(1_000).fill("a").join(""); // 1kb + await fs + .mkdir(path.dirname(uploadFile), { recursive: true }) + .catch(() => {}); + await fs.writeFile(uploadFile, uploadData, "utf8"); + + await app.goto("/file-upload"); + await app.uploadFile("#file", uploadFile); + await app.clickSubmitButton("/file-upload"); + await page.waitForSelector("pre"); + expect(await app.getHtml("pre")).toBe(`
+{
+  "name": "underLimit.txt",
+  "size": 1000
+}
`); + + let written = await fs.readFile( + url.pathToFileURL( + path.join(fixture.projectDir, "uploads/underLimit.txt") + ), + "utf8" + ); + expect(written).toBe(uploadData); + }); + + test("rejects files over upload size limit", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let uploadFile = path.join(fixture.projectDir, "toUpload", "overLimit.txt"); + let uploadData = Array(10_001).fill("a").join(""); // 10.000001KB + await fs + .mkdir(path.dirname(uploadFile), { recursive: true }) + .catch(() => {}); + await fs.writeFile(uploadFile, uploadData, "utf8"); + + await app.goto("/file-upload"); + await app.uploadFile("#file", uploadFile); + await app.clickSubmitButton("/file-upload"); + await page.waitForSelector("pre"); + expect(await app.getHtml("pre")).toBe(`
+{
+  "errorMessage": "Field \\"file\\" exceeded upload size of 10000 bytes."
+}
`); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("file-uploads", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/fileUploadHandler.ts": js` + import * as path from "node:path"; + import * as url from "node:url"; + import { + unstable_composeUploadHandlers as composeUploadHandlers, + unstable_createFileUploadHandler as createFileUploadHandler, + unstable_createMemoryUploadHandler as createMemoryUploadHandler, + } from "@remix-run/node"; + + const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); + export let uploadHandler = composeUploadHandlers( + createFileUploadHandler({ + directory: path.resolve(__dirname, "..", "uploads"), + maxPartSize: 10_000, // 10kb + // you probably want to avoid conflicts in production + // do not set to false or passthrough filename in real + // applications. + avoidFileConflicts: false, + file: ({ filename }) => filename + }), + createMemoryUploadHandler(), + ); + `, + "app/routes/file-upload.tsx": js` + import { + unstable_parseMultipartFormData as parseMultipartFormData, + } from "@remix-run/node"; + import { Form, useActionData } from "@remix-run/react"; + import { uploadHandler } from "~/fileUploadHandler"; + + export let action = async ({ request }) => { + try { + let formData = await parseMultipartFormData(request, uploadHandler); + + if (formData.get("test") !== "hidden") { + return { errorMessage: "hidden field not in form data" }; + } + + let file = formData.get("file"); + if (typeof file === "string" || !file) { + return { errorMessage: "invalid file type" }; + } + + return { name: file.name, size: file.size }; + } catch (error) { + return { errorMessage: error.message }; + } + }; + + export default function Upload() { + let actionData = useActionData(); + return ( + <> +
+ + + + +
+ {actionData ?
{JSON.stringify(actionData, null, 2)}
: null} + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("handles files under upload size limit", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let uploadFile = path.join( + fixture.projectDir, + "toUpload", + "underLimit.txt" + ); + let uploadData = Array(1_000).fill("a").join(""); // 1kb + await fs + .mkdir(path.dirname(uploadFile), { recursive: true }) + .catch(() => {}); + await fs.writeFile(uploadFile, uploadData, "utf8"); + + await app.goto("/file-upload"); + await app.uploadFile("#file", uploadFile); + await app.clickSubmitButton("/file-upload"); + await page.waitForSelector("pre"); + expect(await app.getHtml("pre")).toBe(`
+{
+  "name": "underLimit.txt",
+  "size": 1000
+}
`); + + let written = await fs.readFile( + url.pathToFileURL( + path.join(fixture.projectDir, "uploads/underLimit.txt") + ), + "utf8" + ); + expect(written).toBe(uploadData); + }); + + test("rejects files over upload size limit", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let uploadFile = path.join( + fixture.projectDir, + "toUpload", + "overLimit.txt" + ); + let uploadData = Array(10_001).fill("a").join(""); // 10.000001KB + await fs + .mkdir(path.dirname(uploadFile), { recursive: true }) + .catch(() => {}); + await fs.writeFile(uploadFile, uploadData, "utf8"); + + await app.goto("/file-upload"); + await app.uploadFile("#file", uploadFile); + await app.clickSubmitButton("/file-upload"); + await page.waitForSelector("pre"); + expect(await app.getHtml("pre")).toBe(`
+{
+  "errorMessage": "Field \\"file\\" exceeded upload size of 10000 bytes."
+}
`); + }); + }); +}); diff --git a/integration/flat-routes-test.ts b/integration/flat-routes-test.ts new file mode 100644 index 0000000000..ae021d6f39 --- /dev/null +++ b/integration/flat-routes-test.ts @@ -0,0 +1,416 @@ +import { PassThrough } from "node:stream"; +import { test, expect } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { createFixtureProject } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.describe("flat routes", () => { + let IGNORED_ROUTE = "/ignore-me-pls"; + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + ignoredRouteFiles: [IGNORED_ROUTE], + }, + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
+

Root

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

Index

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

Folder (Route.jsx)

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

Folder (Index.jsx)

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

Flat File

; + } + `, + + "app/routes/.dotfile": ` + DOTFILE SHOULD BE IGNORED + `, + + "app/routes/.route-with-unescaped-leading-dot.tsx": js` + throw new Error("This file should be ignored as a route"); + `, + + "app/routes/[.]route-with-escaped-leading-dot.tsx": js` + export default function () { + return

Route With Escaped Leading Dot

; + } + `, + + "app/routes/dashboard/route.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default function () { + return ( + <> +

Dashboard Layout

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

Dashboard Index

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

i should 404

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runTests(); + }); + + function runTests() { + test("renders matching routes (index)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Index

+
`); + }); + + test("renders matching routes (folder route.jsx)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/folder"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Folder (Route.jsx)

+
`); + }); + + test("renders matching routes (folder index.jsx)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/folder2"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Folder (Index.jsx)

+
`); + }); + + test("renders matching routes (flat file)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/flat/file"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Flat File

+
`); + }); + + test("renders matching routes (route with escaped leading dot)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/.route-with-escaped-leading-dot"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Route With Escaped Leading Dot

+
`); + }); + + test("renders matching routes (nested)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/dashboard"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Dashboard Layout

+

Dashboard Index

+
`); + }); + } + + test("allows ignoredRouteFiles to be configured", async () => { + let routeIds = Object.keys(fixture.build!.routes); + + expect(routeIds).not.toContain(IGNORED_ROUTE); + }); +}); + +test.describe("emits warnings for route conflicts", async () => { + let buildStdio = new PassThrough(); + let buildOutput: string; + + let originalConsoleLog = console.log; + let originalConsoleWarn = console.warn; + let originalConsoleError = console.error; + + test.beforeAll(async () => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + await createFixtureProject({ + buildStdio, + files: { + "routes/_dashboard._index.tsx": js` + export default function () { + return

routes/_dashboard._index

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

routes._index

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

routes/_landing._index

; + } + `, + }, + }); + + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + }); + + test.afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + test("warns about conflicting routes", () => { + console.log(buildOutput); + expect(buildOutput).toContain(`⚠️ Route Path Collision: "/"`); + }); +}); + +test.describe("", () => { + let buildStdio = new PassThrough(); + let buildOutput: string; + + let originalConsoleLog = console.log; + let originalConsoleWarn = console.warn; + let originalConsoleError = console.error; + + test.beforeAll(async () => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + await createFixtureProject({ + buildStdio, + files: { + "app/routes/_index/route.tsx": js``, + "app/routes/_index/utils.ts": js``, + }, + }); + + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + }); + + test.afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + test("doesn't emit a warning for nested index files with co-located files", () => { + expect(buildOutput).not.toContain(`Route Path Collision`); + }); +}); + +test.describe("pathless routes and route collisions", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts, useMatches } from "@remix-run/react"; + + export default function App() { + let matches = 'Number of matches: ' + useMatches().length; + return ( + + + +

{matches}

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

Index

; + } + `, + "app/routes/nested._pathless.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default function Layout() { + return ( + <> +
Pathless Layout
+ + + ); + } + `, + "app/routes/nested._pathless.foo.tsx": js` + export default function Foo() { + return

Foo

; + } + `, + "app/routes/nested._pathless2.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default function Layout() { + return ( + <> +
Pathless 2 Layout
+ + + ); + } + `, + "app/routes/nested._pathless2.bar.tsx": js` + export default function Bar() { + return

Bar

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(async () => appFixture.close()); + + test.describe("with JavaScript", () => { + runTests(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + /** + * Routes for this test look like this, for reference for the matches assertions: + * + * + * + * + * + * + * + * + * + * + */ + + function runTests() { + test("displays index page and not pathless layout page", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested"); + expect(await app.getHtml()).toMatch("Index"); + expect(await app.getHtml()).not.toMatch("Pathless Layout"); + expect(await app.getHtml()).toMatch("Number of matches: 2"); + }); + + test("displays page inside of pathless layout", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested/foo"); + expect(await app.getHtml()).not.toMatch("Index"); + expect(await app.getHtml()).toMatch("Pathless Layout"); + expect(await app.getHtml()).toMatch("Foo"); + expect(await app.getHtml()).toMatch("Number of matches: 3"); + }); + + // This also asserts that we support multiple sibling pathless route layouts + test("displays page inside of second pathless layout", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested/bar"); + expect(await app.getHtml()).not.toMatch("Index"); + expect(await app.getHtml()).toMatch("Pathless 2 Layout"); + expect(await app.getHtml()).toMatch("Bar"); + expect(await app.getHtml()).toMatch("Number of matches: 3"); + }); + } +}); diff --git a/integration/form-data-test.ts b/integration/form-data-test.ts new file mode 100644 index 0000000000..d9cd69b780 --- /dev/null +++ b/integration/form-data-test.ts @@ -0,0 +1,125 @@ +import { test, expect } from "@playwright/test"; + +import { createFixture, js } from "./helpers/create-fixture.js"; +import type { Fixture } from "./helpers/create-fixture.js"; + +let fixture: Fixture; + +test.describe("multi fetch", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + + export async function action({ request }) { + try { + await request.formData() + } catch { + return json("no pizza"); + } + return json("pizza"); + } + `, + }, + }); + }); + + test("invalid content-type does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "application/json" }, + }); + expect(await response.text()).toMatch("no pizza"); + }); + + test("invalid urlencoded body does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "$rofl this is totally invalid$", + }); + expect(await response.text()).toMatch("pizza"); + }); + + test("invalid multipart content-type does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "multipart/form-data" }, + body: "$rofl this is totally invalid$", + }); + expect(await response.text()).toMatch("pizza"); + }); + + test("invalid multipart body does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "multipart/form-data; boundary=abc" }, + body: "$rofl this is totally invalid$", + }); + expect(await response.text()).toMatch("pizza"); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + + export async function action({ request }) { + try { + await request.formData() + } catch { + return json("no pizza"); + } + return json("pizza"); + } + `, + }, + }); + }); + + test("invalid content-type does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "application/json" }, + }); + expect(await response.text()).toMatch("no pizza"); + }); + + test("invalid urlencoded body does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "$rofl this is totally invalid$", + }); + expect(await response.text()).toMatch("pizza"); + }); + + test("invalid multipart content-type does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "multipart/form-data" }, + body: "$rofl this is totally invalid$", + }); + expect(await response.text()).toMatch("pizza"); + }); + + test("invalid multipart body does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "multipart/form-data; boundary=abc" }, + body: "$rofl this is totally invalid$", + }); + expect(await response.text()).toMatch("pizza"); + }); +}); diff --git a/integration/form-test.ts b/integration/form-test.ts new file mode 100644 index 0000000000..b7103e332a --- /dev/null +++ b/integration/form-test.ts @@ -0,0 +1,2281 @@ +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 { getElement, PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("Forms", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let KEYBOARD_INPUT = "KEYBOARD_INPUT"; + let CHECKBOX_BUTTON = "CHECKBOX_BUTTON"; + let ORPHAN_BUTTON = "ORPHAN_BUTTON"; + let FORM_WITH_ACTION_INPUT = "FORM_WITH_ACTION_INPUT"; + let FORM_WITH_ORPHAN = "FORM_WITH_ORPHAN"; + let LUNCH = "LUNCH"; + let CHEESESTEAK = "CHEESESTEAK"; + let LAKSA = "LAKSA"; + let SQUID_INK_HOTDOG = "SQUID_INK_HOTDOG"; + let ACTION = "action"; + let EAT = "EAT"; + + let STATIC_ROUTE_NO_ACTION = "static-route-none"; + let STATIC_ROUTE_ABSOLUTE_ACTION = "static-route-abs"; + let STATIC_ROUTE_CURRENT_ACTION = "static-route-cur"; + let STATIC_ROUTE_PARENT_ACTION = "static-route-parent"; + let STATIC_ROUTE_TOO_MANY_DOTS_ACTION = "static-route-too-many-dots"; + let INDEX_ROUTE_NO_ACTION = "index-route-none"; + let INDEX_ROUTE_NO_ACTION_POST = "index-route-none-post"; + let INDEX_ROUTE_ABSOLUTE_ACTION = "index-route-abs"; + let INDEX_ROUTE_CURRENT_ACTION = "index-route-cur"; + let INDEX_ROUTE_PARENT_ACTION = "index-route-parent"; + let INDEX_ROUTE_TOO_MANY_DOTS_ACTION = "index-route-too-many-dots"; + let DYNAMIC_ROUTE_NO_ACTION = "dynamic-route-none"; + let DYNAMIC_ROUTE_ABSOLUTE_ACTION = "dynamic-route-abs"; + let DYNAMIC_ROUTE_CURRENT_ACTION = "dynamic-route-cur"; + let DYNAMIC_ROUTE_PARENT_ACTION = "dynamic-route-parent"; + let DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION = "dynamic-route-too-many-dots"; + let LAYOUT_ROUTE_NO_ACTION = "layout-route-none"; + let LAYOUT_ROUTE_ABSOLUTE_ACTION = "layout-route-abs"; + let LAYOUT_ROUTE_CURRENT_ACTION = "layout-route-cur"; + let LAYOUT_ROUTE_PARENT_ACTION = "layout-route-parent"; + let LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION = "layout-route-too-many-dots"; + let SPLAT_ROUTE_NO_ACTION = "splat-route-none"; + let SPLAT_ROUTE_ABSOLUTE_ACTION = "splat-route-abs"; + let SPLAT_ROUTE_CURRENT_ACTION = "splat-route-cur"; + let SPLAT_ROUTE_PARENT_ACTION = "splat-route-parent"; + let SPLAT_ROUTE_TOO_MANY_DOTS_ACTION = "splat-route-too-many-dots"; + + test.beforeEach(async ({ context }) => { + await context.route(/_data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/get-submission.tsx": js` + import { useLoaderData, Form } from "@remix-run/react"; + + export function loader({ request }) { + let url = new URL(request.url); + return url.searchParams.toString() + } + + export default function() { + let data = useLoaderData(); + return ( + <> +
+ + + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + + + +
+ +
{data}
+ + ) + } + `, + + "app/routes/about.tsx": js` + export async function action({ request }) { + return json({ submitted: true }); + } + export default function () { + return

About

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

Blog

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

Projects

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

All projects

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

{JSON.stringify(actionData)}

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

Pathless Layout Parent

+ +
+ +

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

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

Pathless Layout

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

Pathless Layout Index

+ } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + runFormTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); // explicitly set so we don't have to check against undefined + + runFormTests(); + }); + + function runFormTests() { + test("posts to a loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + // this indirectly tests that clicking SVG children in buttons works + await app.goto("/get-submission"); + await app.clickSubmitButton("/get-submission", { wait: true }); + await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + }); + + test("posts to a loader with an ", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${FORM_WITH_ACTION_INPUT} button`); + await page.waitForSelector(`pre:has-text("${EAT}")`); + }); + + test("posts to a loader with button data with click", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement("#buttonWithValue"); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + }); + + test("posts to a loader with button data with keyboard", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await page.focus(`#${KEYBOARD_INPUT}`); + await app.waitForNetworkAfter(async () => { + await page.keyboard.press("Enter"); + // there can be a delay before the request gets kicked off (worse with JS disabled) + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + }); + + test("posts with the correct checkbox data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${CHECKBOX_BUTTON}`); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + }); + + test("posts button data from outside the form", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${ORPHAN_BUTTON}`); + await page.waitForSelector(`pre:has-text("${SQUID_INK_HOTDOG}")`); + }); + + test( + "when clicking on a submit button as a descendant of an element that " + + "stops propagation on click, still passes the clicked submit button's " + + "`name` and `value` props to the request payload", + async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/stop-propagation"); + await app.clickSubmitButton("/stop-propagation", { wait: true }); + await page.waitForSelector("#action-data"); + expect(await app.getHtml()).toMatch('{"intent":"add"}'); + } + ); + + test.describe("
action", () => { + test.describe("in a static route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/inbox"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/inbox?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/inbox"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/inbox"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + }); + + test.describe("in a dynamic route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + }); + + test.describe("in an index route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog?index&foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("handles search params correctly on GET submissions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + // Start with a query param + await app.goto("/blog?junk=1"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + expect(app.page.url()).toMatch(/\/blog\?junk=1$/); + + // On submission, we replace existing parameters (reflected in the + // form action) with the values from the form data. We also do not + // need to preserve the index param in the URL on GET submissions + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&foo=1"); + expect(app.page.url()).toMatch(/\/blog\?foo=1$/); + + // Does not append duplicate params on re-submissions + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&foo=1"); + expect(app.page.url()).toMatch(/\/blog\?foo=1$/); + }); + + test("handles search params correctly on POST submissions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + // Start with a query param + await app.goto("/blog?junk=1"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + expect(app.page.url()).toMatch(/\/blog\?junk=1$/); + + // Form action reflects the current params and change them on submission + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION_POST} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + await page.waitForURL(/\/blog\?index&junk=1$/); + expect(app.page.url()).toMatch(/\/blog\?index&junk=1$/); + }); + }); + + test.describe("in a layout route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + }); + + test.describe("in a splat route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/projects?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + }); + }); + + let FORM_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + let NATIVE_FORM_METHODS = ["GET", "POST"]; + + test.describe("uses the Form `method` attribute", () => { + FORM_METHODS.forEach((method) => { + test(`submits with ${method}`, async ({ page, javaScriptEnabled }) => { + test.fail( + !javaScriptEnabled && !NATIVE_FORM_METHODS.includes(method), + `Native doesn't support method ${method} #4420` + ); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/form-method?method=${method}`, true); + await app.clickElement(`text=Submit`); + if (method !== "GET") { + await page.waitForSelector("#action-method"); + expect(await app.getHtml("pre#action-method")).toBe( + `
${method}
` + ); + } + expect(await app.getHtml("pre#loader-method")).toBe( + `
GET
` + ); + }); + }); + }); + + test.describe("overrides the Form `method` attribute with the submitter's `formMethod` attribute", () => { + // NOTE: HTMLButtonElement only supports get/post as formMethod, which is why we don't test put/patch/delete + NATIVE_FORM_METHODS.forEach((overrideMethod) => { + // ensure the form's method is different from the submitter's + let method = overrideMethod === "GET" ? "POST" : "GET"; + test(`submits with ${overrideMethod} instead of ${method}`, async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto( + `/form-method?method=${method}&submitterFormMethod=${overrideMethod}`, + true + ); + await app.clickElement(`text=Submit with ${overrideMethod}`); + if (overrideMethod !== "GET") { + await page.waitForSelector("#action-method"); + expect(await app.getHtml("pre#action-method")).toBe( + `
${overrideMethod}
` + ); + } + expect(await app.getHtml("pre#loader-method")).toBe( + `
GET
` + ); + }); + }); + }); + + test("submits the submitter's value(s) in tree order in the form data", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/submitter"); + await app.clickElement("text=Add Task"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=&tasks=last" + ); + + await app.goto("/submitter"); + await app.clickElement("text=No Name"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=last" + ); + + await app.goto("/submitter"); + await app.clickElement("[alt='Add Task']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&tasks.x=\d+&tasks.y=\d+&tasks=last$/ + ); + + await app.goto("/submitter"); + await app.clickElement("[alt='No Name']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&x=\d+&y=\d+&tasks=last$/ + ); + + await app.goto("/submitter"); + await app.clickElement("text=Outside"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=outside&tasks=first&tasks=second&tasks=last" + ); + }); + + test("sends file names when submitting via url encoding", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let myFile = fixture.projectDir + "/myfile.txt"; + + await app.goto("/file-upload"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); + await app.clickElement("button"); + await page.waitForSelector("#formData"); + + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" + ); + + await app.goto("/file-upload?method=post"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); + await app.clickElement("button"); + await page.waitForSelector("#formData"); + + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" + ); + }); + + test("empty file inputs resolve to File objects on the server", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/empty-file-upload"); + await app.clickSubmitButton("/empty-file-upload"); + await page.waitForSelector("#action-data"); + expect((await app.getElement("#action-data")).text()).toContain( + '{"text":"","file":{"name":"","size":0},"fileMultiple":[{"name":"","size":0}]}' + ); + }); + + test("pathless layout routes are ignored in form actions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/pathless-layout-parent/nested"); + let html = await app.getHtml(); + expect(html).toMatch("Pathless Layout Parent"); + expect(html).toMatch("Pathless Layout "); + expect(html).toMatch("Pathless Layout Index"); + + let el = getElement(html, `form`); + expect(el.attr("action")).toMatch("/pathless-layout-parent"); + + expect(await app.getHtml()).toMatch("Submitted - No"); + // This submission should ignore the index route and the pathless layout + // route above it and hit the action in routes/pathless-layout-parent.jsx + await app.clickSubmitButton("/pathless-layout-parent"); + await page.waitForSelector("text=Submitted - Yes"); + expect(await app.getHtml()).toMatch("Submitted - Yes"); + }); + } +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("Forms", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let KEYBOARD_INPUT = "KEYBOARD_INPUT"; + let CHECKBOX_BUTTON = "CHECKBOX_BUTTON"; + let ORPHAN_BUTTON = "ORPHAN_BUTTON"; + let FORM_WITH_ACTION_INPUT = "FORM_WITH_ACTION_INPUT"; + let FORM_WITH_ORPHAN = "FORM_WITH_ORPHAN"; + let LUNCH = "LUNCH"; + let CHEESESTEAK = "CHEESESTEAK"; + let LAKSA = "LAKSA"; + let SQUID_INK_HOTDOG = "SQUID_INK_HOTDOG"; + let ACTION = "action"; + let EAT = "EAT"; + + let STATIC_ROUTE_NO_ACTION = "static-route-none"; + let STATIC_ROUTE_ABSOLUTE_ACTION = "static-route-abs"; + let STATIC_ROUTE_CURRENT_ACTION = "static-route-cur"; + let STATIC_ROUTE_PARENT_ACTION = "static-route-parent"; + let STATIC_ROUTE_TOO_MANY_DOTS_ACTION = "static-route-too-many-dots"; + let INDEX_ROUTE_NO_ACTION = "index-route-none"; + let INDEX_ROUTE_NO_ACTION_POST = "index-route-none-post"; + let INDEX_ROUTE_ABSOLUTE_ACTION = "index-route-abs"; + let INDEX_ROUTE_CURRENT_ACTION = "index-route-cur"; + let INDEX_ROUTE_PARENT_ACTION = "index-route-parent"; + let INDEX_ROUTE_TOO_MANY_DOTS_ACTION = "index-route-too-many-dots"; + let DYNAMIC_ROUTE_NO_ACTION = "dynamic-route-none"; + let DYNAMIC_ROUTE_ABSOLUTE_ACTION = "dynamic-route-abs"; + let DYNAMIC_ROUTE_CURRENT_ACTION = "dynamic-route-cur"; + let DYNAMIC_ROUTE_PARENT_ACTION = "dynamic-route-parent"; + let DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION = "dynamic-route-too-many-dots"; + let LAYOUT_ROUTE_NO_ACTION = "layout-route-none"; + let LAYOUT_ROUTE_ABSOLUTE_ACTION = "layout-route-abs"; + let LAYOUT_ROUTE_CURRENT_ACTION = "layout-route-cur"; + let LAYOUT_ROUTE_PARENT_ACTION = "layout-route-parent"; + let LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION = "layout-route-too-many-dots"; + let SPLAT_ROUTE_NO_ACTION = "splat-route-none"; + let SPLAT_ROUTE_ABSOLUTE_ACTION = "splat-route-abs"; + let SPLAT_ROUTE_CURRENT_ACTION = "splat-route-cur"; + let SPLAT_ROUTE_PARENT_ACTION = "splat-route-parent"; + let SPLAT_ROUTE_TOO_MANY_DOTS_ACTION = "splat-route-too-many-dots"; + + test.beforeEach(async ({ context }) => { + await context.route(/_data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/get-submission.tsx": js` + import { useLoaderData, Form } from "@remix-run/react"; + + export function loader({ request }) { + let url = new URL(request.url); + return url.searchParams.toString() + } + + export default function() { + let data = useLoaderData(); + return ( + <> + + + + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + + + +
+ +
{data}
+ + ) + } + `, + + "app/routes/about.tsx": js` + export async function action({ request }) { + return json({ submitted: true }); + } + export default function () { + return

About

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

Blog

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

Projects

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

All projects

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

{JSON.stringify(actionData)}

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

Pathless Layout Parent

+ +
+ +

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

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

Pathless Layout

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

Pathless Layout Index

+ } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + runFormTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); // explicitly set so we don't have to check against undefined + + runFormTests(); + }); + + function runFormTests() { + test("posts to a loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + // this indirectly tests that clicking SVG children in buttons works + await app.goto("/get-submission"); + await app.clickSubmitButton("/get-submission", { wait: true }); + await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + }); + + test("posts to a loader with an ", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${FORM_WITH_ACTION_INPUT} button`); + await page.waitForSelector(`pre:has-text("${EAT}")`); + }); + + test("posts to a loader with button data with click", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement("#buttonWithValue"); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + }); + + test("posts to a loader with button data with keyboard", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await page.focus(`#${KEYBOARD_INPUT}`); + await app.waitForNetworkAfter(async () => { + await page.keyboard.press("Enter"); + // there can be a delay before the request gets kicked off (worse with JS disabled) + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + }); + + test("posts with the correct checkbox data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${CHECKBOX_BUTTON}`); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + }); + + test("posts button data from outside the form", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${ORPHAN_BUTTON}`); + await page.waitForSelector(`pre:has-text("${SQUID_INK_HOTDOG}")`); + }); + + test( + "when clicking on a submit button as a descendant of an element that " + + "stops propagation on click, still passes the clicked submit button's " + + "`name` and `value` props to the request payload", + async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/stop-propagation"); + await app.clickSubmitButton("/stop-propagation", { wait: true }); + await page.waitForSelector("#action-data"); + expect(await app.getHtml()).toMatch('{"intent":"add"}'); + } + ); + + test.describe("
action", () => { + test.describe("in a static route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/inbox"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/inbox?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/inbox"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/inbox"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + }); + + test.describe("in a dynamic route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog/abc"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + }); + + test.describe("in an index route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog?index&foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("handles search params correctly on GET submissions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + // Start with a query param + await app.goto("/blog?junk=1"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + expect(app.page.url()).toMatch(/\/blog\?junk=1$/); + + // On submission, we replace existing parameters (reflected in the + // form action) with the values from the form data. We also do not + // need to preserve the index param in the URL on GET submissions + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&foo=1"); + expect(app.page.url()).toMatch(/\/blog\?foo=1$/); + + // Does not append duplicate params on re-submissions + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&foo=1"); + expect(app.page.url()).toMatch(/\/blog\?foo=1$/); + }); + + test("handles search params correctly on POST submissions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + // Start with a query param + await app.goto("/blog?junk=1"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + expect(app.page.url()).toMatch(/\/blog\?junk=1$/); + + // Form action reflects the current params and change them on submission + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION_POST} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + await page.waitForURL(/\/blog\?index&junk=1$/); + expect(app.page.url()).toMatch(/\/blog\?index&junk=1$/); + }); + }); + + test.describe("in a layout route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/blog?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/blog"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + }); + + test.describe("in a splat route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toMatch("/projects?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toMatch("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toMatch("/projects"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toMatch("/"); + }); + }); + }); + + let FORM_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + let NATIVE_FORM_METHODS = ["GET", "POST"]; + + test.describe("uses the Form `method` attribute", () => { + FORM_METHODS.forEach((method) => { + test(`submits with ${method}`, async ({ + page, + javaScriptEnabled, + }) => { + test.fail( + !javaScriptEnabled && !NATIVE_FORM_METHODS.includes(method), + `Native doesn't support method ${method} #4420` + ); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/form-method?method=${method}`, true); + await app.clickElement(`text=Submit`); + if (method !== "GET") { + await page.waitForSelector("#action-method"); + expect(await app.getHtml("pre#action-method")).toBe( + `
${method}
` + ); + } + expect(await app.getHtml("pre#loader-method")).toBe( + `
GET
` + ); + }); + }); + }); + + test.describe("overrides the Form `method` attribute with the submitter's `formMethod` attribute", () => { + // NOTE: HTMLButtonElement only supports get/post as formMethod, which is why we don't test put/patch/delete + NATIVE_FORM_METHODS.forEach((overrideMethod) => { + // ensure the form's method is different from the submitter's + let method = overrideMethod === "GET" ? "POST" : "GET"; + test(`submits with ${overrideMethod} instead of ${method}`, async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto( + `/form-method?method=${method}&submitterFormMethod=${overrideMethod}`, + true + ); + await app.clickElement(`text=Submit with ${overrideMethod}`); + if (overrideMethod !== "GET") { + await page.waitForSelector("#action-method"); + expect(await app.getHtml("pre#action-method")).toBe( + `
${overrideMethod}
` + ); + } + expect(await app.getHtml("pre#loader-method")).toBe( + `
GET
` + ); + }); + }); + }); + + test("submits the submitter's value(s) in tree order in the form data", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/submitter"); + await app.clickElement("text=Add Task"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=&tasks=last" + ); + + await app.goto("/submitter"); + await app.clickElement("text=No Name"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=last" + ); + + await app.goto("/submitter"); + await app.clickElement("[alt='Add Task']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&tasks.x=\d+&tasks.y=\d+&tasks=last$/ + ); + + await app.goto("/submitter"); + await app.clickElement("[alt='No Name']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&x=\d+&y=\d+&tasks=last$/ + ); + + await app.goto("/submitter"); + await app.clickElement("text=Outside"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=outside&tasks=first&tasks=second&tasks=last" + ); + }); + + test("sends file names when submitting via url encoding", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let myFile = fixture.projectDir + "/myfile.txt"; + + await app.goto("/file-upload"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); + await app.clickElement("button"); + await page.waitForSelector("#formData"); + + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" + ); + + await app.goto("/file-upload?method=post"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); + await app.clickElement("button"); + await page.waitForSelector("#formData"); + + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" + ); + }); + + test("empty file inputs resolve to File objects on the server", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/empty-file-upload"); + await app.clickSubmitButton("/empty-file-upload"); + await page.waitForSelector("#action-data"); + expect((await app.getElement("#action-data")).text()).toContain( + '{"text":"","file":{"name":"","size":0},"fileMultiple":[{"name":"","size":0}]}' + ); + }); + + test("pathless layout routes are ignored in form actions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/pathless-layout-parent/nested"); + let html = await app.getHtml(); + expect(html).toMatch("Pathless Layout Parent"); + expect(html).toMatch("Pathless Layout "); + expect(html).toMatch("Pathless Layout Index"); + + let el = getElement(html, `form`); + expect(el.attr("action")).toMatch("/pathless-layout-parent"); + + expect(await app.getHtml()).toMatch("Submitted - No"); + // This submission should ignore the index route and the pathless layout + // route above it and hit the action in routes/pathless-layout-parent.jsx + await app.clickSubmitButton("/pathless-layout-parent"); + await page.waitForSelector("text=Submitted - Yes"); + expect(await app.getHtml()).toMatch("Submitted - Yes"); + }); + } + }); +}); diff --git a/integration/headers-test.ts b/integration/headers-test.ts new file mode 100644 index 0000000000..09c06b06df --- /dev/null +++ b/integration/headers-test.ts @@ -0,0 +1,421 @@ +import { test, expect } from "@playwright/test"; + +import { ServerMode } from "../build/node_modules/@remix-run/server-runtime/dist/mode.js"; +import { createFixture, js } from "./helpers/create-fixture.js"; +import type { Fixture } from "./helpers/create-fixture.js"; + +test.describe("headers export", () => { + let ROOT_HEADER_KEY = "X-Test"; + let ROOT_HEADER_VALUE = "SUCCESS"; + let ACTION_HKEY = "X-Test-Action"; + let ACTION_HVALUE = "SUCCESS"; + + let appFixture: Fixture; + + test.beforeAll(async () => { + appFixture = await createFixture( + { + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export const loader = () => json({}); + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + + export function loader() { + return json(null, { + headers: { + "${ROOT_HEADER_KEY}": "${ROOT_HEADER_VALUE}" + } + }) + } + + export function headers({ loaderHeaders }) { + return { + "${ROOT_HEADER_KEY}": loaderHeaders.get("${ROOT_HEADER_KEY}") + } + } + + export default function Index() { + return
Heyo!
+ } + `, + + "app/routes/action.tsx": js` + import { json } from "@remix-run/node"; + + export function action() { + return json(null, { + headers: { + "${ACTION_HKEY}": "${ACTION_HVALUE}" + } + }) + } + + export function headers({ actionHeaders }) { + return { + "${ACTION_HKEY}": actionHeaders.get("${ACTION_HKEY}") + } + } + + export default function Action() { return
} + `, + + "app/routes/parent.tsx": js` + export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) { + return new Headers([ + ...(parentHeaders ? Array.from(parentHeaders.entries()) : []), + ...(actionHeaders ? Array.from(actionHeaders.entries()) : []), + ...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []), + ...(errorHeaders ? Array.from(errorHeaders.entries()) : []), + ]); + } + + export function loader({ request }) { + if (new URL(request.url).searchParams.get('throw') === "parent") { + throw new Response(null, { + status: 400, + headers: { 'X-Parent-Loader': 'error' }, + }) + } + return new Response(null, { + headers: { 'X-Parent-Loader': 'success' }, + }) + } + + export async function action({ request }) { + let fd = await request.formData(); + if (fd.get('throw') === "parent") { + throw new Response(null, { + status: 400, + headers: { 'X-Parent-Action': 'error' }, + }) + } + return new Response(null, { + headers: { 'X-Parent-Action': 'success' }, + }) + } + + export default function Component() { return
} + + export function ErrorBoundary() { + return

Error!

+ } + `, + + "app/routes/parent.child.tsx": js` + export function loader({ request }) { + if (new URL(request.url).searchParams.get('throw') === "child") { + throw new Response(null, { + status: 400, + headers: { 'X-Child-Loader': 'error' }, + }) + } + return null + } + + export async function action({ request }) { + let fd = await request.formData(); + if (fd.get('throw') === "child") { + throw new Response(null, { + status: 400, + headers: { 'X-Child-Action': 'error' }, + }) + } + return null + } + + export default function Component() { return
} + `, + + "app/routes/parent.child.grandchild.tsx": js` + export function loader({ request }) { + throw new Response(null, { + status: 400, + headers: { 'X-Child-Grandchild': 'error' }, + }) + } + + export default function Component() { return
} + `, + + "app/routes/cookie.tsx": js` + import { json } from "@remix-run/server-runtime"; + import { Outlet } from "@remix-run/react"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has("parent-throw")) { + throw json(null, { headers: { "Set-Cookie": "parent-thrown-cookie=true" } }); + } + return null + }; + + export default function Parent() { + return ; + } + + export function ErrorBoundary() { + return

Caught!

; + } + `, + + "app/routes/cookie.child.tsx": js` + import { json } from "@remix-run/node"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has("throw")) { + throw json(null, { headers: { "Set-Cookie": "thrown-cookie=true" } }); + } + return json(null, { + headers: { "Set-Cookie": "normal-cookie=true" }, + }); + }; + + export default function Child() { + return

Child

; + } + `, + }, + }, + ServerMode.Test + ); + }); + + test("can use `action` headers", async () => { + let response = await appFixture.postDocument( + "/action", + new URLSearchParams() + ); + expect(response.headers.get(ACTION_HKEY)).toBe(ACTION_HVALUE); + }); + + test("can use the loader headers when all routes have loaders", async () => { + let response = await appFixture.requestDocument("/"); + expect(response.headers.get(ROOT_HEADER_KEY)).toBe(ROOT_HEADER_VALUE); + }); + + test("can use the loader headers when parents don't have loaders", async () => { + let HEADER_KEY = "X-Test"; + let HEADER_VALUE = "SUCCESS"; + + let fixture = await createFixture( + { + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + + export function loader() { + return json(null, { + headers: { + "${HEADER_KEY}": "${HEADER_VALUE}" + } + }) + } + + export function headers({ loaderHeaders }) { + return { + "${HEADER_KEY}": loaderHeaders.get("${HEADER_KEY}") + } + } + + export default function Index() { + return
Heyo!
+ } + `, + }, + }, + ServerMode.Test + ); + let response = await fixture.requestDocument("/"); + expect(response.headers.get(HEADER_KEY)).toBe(HEADER_VALUE); + }); + + test("returns headers from successful /parent GET requests", async () => { + let response = await appFixture.requestDocument("/parent"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from successful /parent/child GET requests", async () => { + let response = await appFixture.requestDocument("/parent/child"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from successful /parent POST requests", async () => { + let response = await appFixture.postDocument( + "/parent", + new URLSearchParams() + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-action", "success"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from successful /parent/child POST requests", async () => { + let response = await appFixture.postDocument( + "/parent/child", + new URLSearchParams() + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from failed /parent GET requests", async () => { + let response = await appFixture.requestDocument("/parent?throw=parent"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "error, error"], // Shows up in loaderHeaders and errorHeaders + ]) + ); + }); + + test("returns bubbled headers from failed /parent/child GET requests", async () => { + let response = await appFixture.requestDocument( + "/parent/child?throw=child" + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-child-loader", "error"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("ignores headers from successful non-rendered loaders", async () => { + let response = await appFixture.requestDocument( + "/parent/child?throw=parent" + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "error, error"], // Shows up in loaderHeaders and errorHeaders + ]) + ); + }); + + test("chooses higher thrown errors when multiple loaders throw", async () => { + let response = await appFixture.requestDocument( + "/parent/child/grandchild?throw=child" + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-child-loader", "error"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from failed /parent POST requests", async () => { + let response = await appFixture.postDocument( + "/parent?throw=parent", + new URLSearchParams("throw=parent") + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-action", "error, error"], // Shows up in actionHeaders and errorHeaders + ]) + ); + }); + + test("returns bubbled headers from failed /parent/child POST requests", async () => { + let response = await appFixture.postDocument( + "/parent/child", + new URLSearchParams("throw=child") + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-child-action", "error"], + ]) + ); + }); + + test("automatically includes cookie headers from normal responses", async () => { + let response = await appFixture.requestDocument("/cookie/child"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["set-cookie", "normal-cookie=true"], + ]) + ); + }); + + test("automatically includes cookie headers from thrown responses", async () => { + let response = await appFixture.requestDocument("/cookie/child?throw"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["set-cookie", "thrown-cookie=true"], + ]) + ); + }); + + test("does not duplicate thrown cookie headers from boundary route", async () => { + let response = await appFixture.requestDocument("/cookie?parent-throw"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["set-cookie", "parent-thrown-cookie=true"], + ]) + ); + }); +}); diff --git a/integration/helpers/cf-template/.gitignore b/integration/helpers/cf-template/.gitignore new file mode 100644 index 0000000000..f0421bd702 --- /dev/null +++ b/integration/helpers/cf-template/.gitignore @@ -0,0 +1,8 @@ +node_modules + +/.cache +/build +/dist +/public/build +/.mf +.env diff --git a/integration/helpers/cf-template/app/root.tsx b/integration/helpers/cf-template/app/root.tsx new file mode 100644 index 0000000000..68397b09d4 --- /dev/null +++ b/integration/helpers/cf-template/app/root.tsx @@ -0,0 +1,33 @@ +import type { LinksFunction } from "@remix-run/cloudflare"; +import { cssBundleHref } from "@remix-run/css-bundle"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/cf-template/app/routes/_index.tsx b/integration/helpers/cf-template/app/routes/_index.tsx new file mode 100644 index 0000000000..4aa6089f12 --- /dev/null +++ b/integration/helpers/cf-template/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "@remix-run/cloudflare"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} diff --git a/integration/helpers/cf-template/package.json b/integration/helpers/cf-template/package.json new file mode 100644 index 0000000000..1c35963883 --- /dev/null +++ b/integration/helpers/cf-template/package.json @@ -0,0 +1,32 @@ +{ + "name": "integration-cf-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "node ./node_modules/@remix-run/dev/dist/cli.js build", + "dev": "node ./node_modules/@remix-run/dev/dist/cli.js dev --manual -c \"npm start\"", + "start": "wrangler dev ./build/index.js" + }, + "dependencies": { + "@cloudflare/kv-asset-handler": "^0.3.0", + "@remix-run/cloudflare": "workspace:*", + "@remix-run/css-bundle": "workspace:*", + "@remix-run/react": "workspace:*", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20230518.0", + "@remix-run/dev": "workspace:*", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.1.0", + "wrangler": "^3.24.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/integration/helpers/cf-template/public/favicon.ico b/integration/helpers/cf-template/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/integration/helpers/cf-template/public/favicon.ico differ diff --git a/integration/helpers/cf-template/remix.config.js b/integration/helpers/cf-template/remix.config.js new file mode 100644 index 0000000000..38db27d725 --- /dev/null +++ b/integration/helpers/cf-template/remix.config.js @@ -0,0 +1,21 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +export default { + server: "./server.ts", + serverConditions: ["workerd", "worker", "browser"], + serverDependenciesToBundle: [ + // bundle everything except the virtual module for the static content manifest provided by wrangler + /^(?!.*\b__STATIC_CONTENT_MANIFEST\b).*$/, + ], + serverMainFields: ["browser", "module", "main"], + serverMinify: true, + serverModuleFormat: "esm", + serverPlatform: "neutral", + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", + // serverBuildPath: "build/index.js", + + // !!! Don't adjust this without changing the code that overwrites this + // in createFixtureProject() + ...global.INJECTED_FIXTURE_REMIX_CONFIG, +}; diff --git a/integration/helpers/cf-template/remix.env.d.ts b/integration/helpers/cf-template/remix.env.d.ts new file mode 100644 index 0000000000..b5be9ba3bb --- /dev/null +++ b/integration/helpers/cf-template/remix.env.d.ts @@ -0,0 +1,8 @@ +/// +/// +/// + +declare module "__STATIC_CONTENT_MANIFEST" { + const manifest: string; + export default manifest; +} diff --git a/integration/helpers/cf-template/server.ts b/integration/helpers/cf-template/server.ts new file mode 100644 index 0000000000..7408ed3981 --- /dev/null +++ b/integration/helpers/cf-template/server.ts @@ -0,0 +1,53 @@ +import { getAssetFromKV } from "@cloudflare/kv-asset-handler"; +import type { AppLoadContext } from "@remix-run/cloudflare"; +import { createRequestHandler, logDevReady } from "@remix-run/cloudflare"; +import * as build from "@remix-run/dev/server-build"; +import __STATIC_CONTENT_MANIFEST from "__STATIC_CONTENT_MANIFEST"; + +const MANIFEST = JSON.parse(__STATIC_CONTENT_MANIFEST); +const handleRemixRequest = createRequestHandler(build, process.env.NODE_ENV); + +if (process.env.NODE_ENV === "development") { + logDevReady(build); +} + +export default { + async fetch( + request: Request, + env: { + __STATIC_CONTENT: Fetcher; + }, + ctx: ExecutionContext + ): Promise { + try { + const url = new URL(request.url); + const ttl = url.pathname.startsWith("/build/") + ? 60 * 60 * 24 * 365 // 1 year + : 60 * 5; // 5 minutes + return await getAssetFromKV( + { + request, + waitUntil: ctx.waitUntil.bind(ctx), + } as FetchEvent, + { + ASSET_NAMESPACE: env.__STATIC_CONTENT, + ASSET_MANIFEST: MANIFEST, + cacheControl: { + browserTTL: ttl, + edgeTTL: ttl, + }, + } + ); + } catch (error) {} + + try { + const loadContext: AppLoadContext = { + env, + }; + return await handleRemixRequest(request, loadContext); + } catch (error) { + console.log(error); + return new Response("An unexpected error occurred", { status: 500 }); + } + }, +}; diff --git a/integration/helpers/cf-template/tsconfig.json b/integration/helpers/cf-template/tsconfig.json new file mode 100644 index 0000000000..28cce918b8 --- /dev/null +++ b/integration/helpers/cf-template/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/integration/helpers/cf-template/wrangler.toml b/integration/helpers/cf-template/wrangler.toml new file mode 100644 index 0000000000..b4ddc4387f --- /dev/null +++ b/integration/helpers/cf-template/wrangler.toml @@ -0,0 +1,9 @@ +name = "remix-cloudflare-workers" + +workers_dev = true +main = "./build/index.js" +# https://developers.cloudflare.com/workers/platform/compatibility-dates +compatibility_date = "2023-04-20" + +[site] + bucket = "./public" diff --git a/integration/helpers/cleanup.mjs b/integration/helpers/cleanup.mjs new file mode 100644 index 0000000000..f1b4f02054 --- /dev/null +++ b/integration/helpers/cleanup.mjs @@ -0,0 +1,27 @@ +import * as path from "node:path"; +import spawn from "cross-spawn"; + +if (process.env.CI) { + console.log("Skipping cleanup in CI"); + process.exit(); +} + +const pathsToRemove = [path.resolve(process.cwd(), ".tmp/integration")]; + +for (let pathToRemove of pathsToRemove) { + console.log(`Removing ${path.relative(process.cwd(), pathToRemove)}`); + let childProcess; + if (process.platform === "win32") { + childProcess = spawn("rmdir", ["/s", "/q", pathToRemove], { + stdio: "inherit", + }); + } else { + childProcess = spawn("rm", ["-rf", pathToRemove], { + stdio: "inherit", + }); + } + childProcess.on("error", (err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts new file mode 100644 index 0000000000..78be4f2294 --- /dev/null +++ b/integration/helpers/create-fixture.ts @@ -0,0 +1,434 @@ +import type { Writable } from "node:stream"; +import path from "node:path"; +import url from "node:url"; +import fse from "fs-extra"; +import express from "express"; +import getPort from "get-port"; +import dedent from "dedent"; +import stripIndent from "strip-indent"; +import serializeJavaScript from "serialize-javascript"; +import { sync as spawnSync, spawn } from "cross-spawn"; +import type { JsonObject } from "type-fest"; +import type { AppConfig } from "@remix-run/dev"; + +import { ServerMode } from "../../build/node_modules/@remix-run/server-runtime/dist/mode.js"; +import type { ServerBuild } from "../../build/node_modules/@remix-run/server-runtime/dist/index.js"; +import { createRequestHandler } from "../../build/node_modules/@remix-run/server-runtime/dist/index.js"; +import { createRequestHandler as createExpressHandler } from "../../build/node_modules/@remix-run/express/dist/index.js"; +import { installGlobals } from "../../build/node_modules/@remix-run/node/dist/index.js"; +import { decodeViaTurboStream } from "../../build/node_modules/@remix-run/react/dist/single-fetch.js"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const root = path.join(__dirname, "../.."); +const TMP_DIR = path.join(root, ".tmp", "integration"); + +export interface FixtureInit { + buildStdio?: Writable; + sourcemap?: boolean; + files?: { [filename: string]: string }; + template?: "cf-template" | "deno-template" | "node-template"; + config?: Partial; + useRemixServe?: boolean; + compiler?: "remix" | "vite"; + spaMode?: boolean; +} + +export type Fixture = Awaited>; +export type AppFixture = Awaited>; + +export const js = String.raw; +export const mdx = String.raw; +export const css = String.raw; +export function json(value: JsonObject) { + return JSON.stringify(value, null, 2); +} + +export async function createFixture(init: FixtureInit, mode?: ServerMode) { + installGlobals(); + let compiler = init.compiler ?? "remix"; + let projectDir = await createFixtureProject(init, mode); + let buildPath = url.pathToFileURL( + path.join( + projectDir, + compiler === "vite" ? "build/server/index.js" : "build/index.js" + ) + ).href; + + let getBrowserAsset = async (asset: string) => { + return fse.readFile( + path.join(projectDir, "public", asset.replace(/^\//, "")), + "utf8" + ); + }; + + let isSpaMode = compiler === "vite" && init.spaMode; + + if (isSpaMode) { + let requestDocument = () => { + let html = fse.readFileSync( + path.join(projectDir, "build/client/index.html") + ); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); + }; + + return { + projectDir, + build: null, + isSpaMode, + compiler, + requestDocument, + requestData: () => { + throw new Error("Cannot requestData in SPA Mode tests"); + }, + requestResource: () => { + throw new Error("Cannot requestResource in SPA Mode tests"); + }, + requestSingleFetchData: () => { + throw new Error("Cannot requestSingleFetchData in SPA Mode tests"); + }, + postDocument: () => { + throw new Error("Cannot postDocument in SPA Mode tests"); + }, + getBrowserAsset, + useRemixServe: init.useRemixServe, + }; + } + + let app: ServerBuild = await import(buildPath); + let handler = createRequestHandler(app, mode || ServerMode.Production); + + let requestDocument = async (href: string, init?: RequestInit) => { + let url = new URL(href, "test://test"); + let request = new Request(url.toString(), { + ...init, + signal: init?.signal || new AbortController().signal, + }); + return handler(request); + }; + + let requestData = async ( + href: string, + routeId: string, + init?: RequestInit + ) => { + init = init || {}; + init.signal = init.signal || new AbortController().signal; + let url = new URL(href, "test://test"); + url.searchParams.set("_data", routeId); + let request = new Request(url.toString(), init); + return handler(request); + }; + + let requestResource = async (href: string, init?: RequestInit) => { + init = init || {}; + init.signal = init.signal || new AbortController().signal; + let url = new URL(href, "test://test"); + let request = new Request(url.toString(), init); + return handler(request); + }; + + let requestSingleFetchData = async (href: string, init?: RequestInit) => { + init = init || {}; + init.signal = init.signal || new AbortController().signal; + let url = new URL(href, "test://test"); + let request = new Request(url.toString(), init); + let response = await handler(request); + let decoded = await decodeViaTurboStream(response.body!, global); + return { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: decoded.value, + }; + }; + + let postDocument = async (href: string, data: URLSearchParams | FormData) => { + return requestDocument(href, { + method: "POST", + body: data, + headers: { + "Content-Type": + data instanceof URLSearchParams + ? "application/x-www-form-urlencoded" + : "multipart/form-data", + }, + }); + }; + + return { + projectDir, + build: app, + isSpaMode, + compiler, + requestDocument, + requestData, + requestResource, + requestSingleFetchData, + postDocument, + getBrowserAsset, + useRemixServe: init.useRemixServe, + }; +} + +export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { + let startAppServer = async (): Promise<{ + port: number; + stop: VoidFunction; + }> => { + if (fixture.useRemixServe) { + return new Promise(async (accept, reject) => { + let port = await getPort(); + + let nodebin = process.argv[0]; + let serveProcess = spawn( + nodebin, + [ + "node_modules/@remix-run/serve/dist/cli.js", + fixture.compiler === "vite" + ? "server/build/index.js" + : "build/index.js", + ], + { + env: { + NODE_ENV: mode || "production", + PORT: port.toFixed(0), + }, + cwd: fixture.projectDir, + stdio: "pipe", + } + ); + // Wait for `started at http://localhost:${port}` to be printed + // and extract the port from it. + let started = false; + let stdout = ""; + let rejectTimeout = setTimeout(() => { + reject(new Error("Timed out waiting for remix-serve to start")); + }, 20000); + serveProcess.stderr.pipe(process.stderr); + serveProcess.stdout.on("data", (chunk) => { + if (started) return; + let newChunk = chunk.toString(); + stdout += newChunk; + let match: RegExpMatchArray | null = stdout.match( + /\[remix-serve\] http:\/\/localhost:(\d+)\s/ + ); + if (match) { + clearTimeout(rejectTimeout); + started = true; + let parsedPort = parseInt(match[1], 10); + + if (port !== parsedPort) { + reject( + new Error( + `Expected remix-serve to start on port ${port}, but it started on port ${parsedPort}` + ) + ); + return; + } + + accept({ + stop: () => { + serveProcess.kill(); + }, + port, + }); + } + }); + }); + } + + if (fixture.isSpaMode) { + return new Promise(async (accept) => { + let port = await getPort(); + let app = express(); + app.use(express.static(path.join(fixture.projectDir, "build/client"))); + app.get("*", (_, res, next) => + res.sendFile( + path.join(fixture.projectDir, "build/client/index.html"), + next + ) + ); + let server = app.listen(port); + accept({ stop: server.close.bind(server), port }); + }); + } + + return new Promise(async (accept) => { + let port = await getPort(); + let app = express(); + app.use( + express.static( + path.join( + fixture.projectDir, + fixture.compiler === "vite" ? "build/client" : "public" + ) + ) + ); + + app.all( + "*", + createExpressHandler({ + build: fixture.build, + mode: mode || ServerMode.Production, + }) + ); + + let server = app.listen(port); + + accept({ stop: server.close.bind(server), port }); + }); + }; + + let start = async () => { + let { stop, port } = await startAppServer(); + + let serverUrl = `http://localhost:${port}`; + + return { + serverUrl, + /** + * Shuts down the fixture app, **you need to call this + * at the end of a test** or `afterAll` if the fixture is initialized in a + * `beforeAll` block. Also make sure to `app.close()` or else you'll + * have memory leaks. + */ + close: () => { + return stop(); + }, + }; + }; + + return start(); +} + +//////////////////////////////////////////////////////////////////////////////// + +export async function createFixtureProject( + init: FixtureInit = {}, + mode?: ServerMode +): Promise { + let template = init.template ?? "node-template"; + let integrationTemplateDir = path.resolve(__dirname, template); + let projectName = `remix-${template}-${Math.random().toString(32).slice(2)}`; + let projectDir = path.join(TMP_DIR, projectName); + let compiler = init.compiler ?? "remix"; + + await fse.ensureDir(projectDir); + await fse.copy(integrationTemplateDir, projectDir); + // let remixDev = path.join( + // projectDir, + // "node_modules/@remix-run/dev/dist/cli.js" + // ); + // await fse.chmod(remixDev, 0o755); + // await fse.ensureSymlink( + // remixDev, + // path.join(projectDir, "node_modules/.bin/remix") + // ); + // + // let remixServe = path.join( + // projectDir, + // "node_modules/@remix-run/serve/dist/cli.js" + // ); + // await fse.chmod(remixServe, 0o755); + // await fse.ensureSymlink( + // remixServe, + // path.join(projectDir, "node_modules/.bin/remix-serve") + // ); + + await writeTestFiles(init, projectDir); + + // We update the config file *after* writing test files so that tests can provide a custom + // `remix.config.js` file while still supporting the type-checked `config` + // property on the fixture object. This is useful for config values that can't + // be serialized, e.g. usage of imported functions within `remix.config.js`. + let contents = fse.readFileSync( + path.join(projectDir, "remix.config.js"), + "utf-8" + ); + if ( + init.config && + !contents.includes("global.INJECTED_FIXTURE_REMIX_CONFIG") + ) { + throw new Error(dedent` + Cannot provide a \`config\` fixture option and a \`remix.config.js\` file + at the same time, unless the \`remix.config.js\` file contains a reference + to the \`global.INJECTED_FIXTURE_REMIX_CONFIG\` placeholder so it can + accept the injected config values. Either move all config values into + \`remix.config.js\` file, or spread the injected config, + e.g. \`export default { ...global.INJECTED_FIXTURE_REMIX_CONFIG }\`. + `); + } + contents = contents.replace( + "global.INJECTED_FIXTURE_REMIX_CONFIG", + `${serializeJavaScript(init.config ?? {})}` + ); + fse.writeFileSync(path.join(projectDir, "remix.config.js"), contents); + + build(projectDir, init.buildStdio, init.sourcemap, mode, compiler); + + return projectDir; +} + +function build( + projectDir: string, + buildStdio?: Writable, + sourcemap?: boolean, + mode?: ServerMode, + compiler?: "remix" | "vite" +) { + // We have a "require" instead of a dynamic import in readConfig gated + // behind mode === ServerMode.Test to make jest happy, but that doesn't + // work for ESM configs, those MUST be dynamic imports. So we need to + // force the mode to be production for ESM configs when runtime mode is + // tested. + mode = mode === ServerMode.Test ? ServerMode.Production : mode; + + let remixBin = "node_modules/@remix-run/dev/dist/cli.js"; + + let buildArgs: string[] = + compiler === "vite" + ? [remixBin, "vite:build"] + : [remixBin, "build", ...(sourcemap ? ["--sourcemap"] : [])]; + + let buildSpawn = spawnSync("node", buildArgs, { + cwd: projectDir, + env: { + ...process.env, + NODE_ENV: mode || ServerMode.Production, + }, + }); + + // These logs are helpful for debugging. Remove comments if needed. + // console.log("spawning node " + buildArgs.join(" ") + ":\n"); + // console.log(" STDOUT:"); + // console.log(" " + buildSpawn.stdout.toString("utf-8")); + // console.log(" STDERR:"); + // console.log(" " + buildSpawn.stderr.toString("utf-8")); + + if (buildStdio) { + buildStdio.write(buildSpawn.stdout.toString("utf-8")); + buildStdio.write(buildSpawn.stderr.toString("utf-8")); + buildStdio.end(); + } + + if (buildSpawn.error || buildSpawn.status) { + console.error(buildSpawn.stderr.toString("utf-8")); + throw buildSpawn.error || new Error(`Build failed, check the output above`); + } +} + +async function writeTestFiles(init: FixtureInit, dir: string) { + await Promise.all( + Object.keys(init.files ?? {}).map(async (filename) => { + let filePath = path.join(dir, filename); + await fse.ensureDir(path.dirname(filePath)); + let file = init.files![filename]; + + await fse.writeFile(filePath, stripIndent(file)); + }) + ); +} diff --git a/integration/helpers/deno-template/.gitignore b/integration/helpers/deno-template/.gitignore new file mode 100644 index 0000000000..3f7bf98da3 --- /dev/null +++ b/integration/helpers/deno-template/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/integration/helpers/deno-template/app/root.tsx b/integration/helpers/deno-template/app/root.tsx new file mode 100644 index 0000000000..60ddcdf8f8 --- /dev/null +++ b/integration/helpers/deno-template/app/root.tsx @@ -0,0 +1,34 @@ +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction } from "@remix-run/deno"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; +import * as React from "react"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/deno-template/app/routes/_index.tsx b/integration/helpers/deno-template/app/routes/_index.tsx new file mode 100644 index 0000000000..2e62fab622 --- /dev/null +++ b/integration/helpers/deno-template/app/routes/_index.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import type { MetaFunction } from "@remix-run/deno"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} diff --git a/integration/helpers/deno-template/package.json b/integration/helpers/deno-template/package.json new file mode 100644 index 0000000000..80e32157d3 --- /dev/null +++ b/integration/helpers/deno-template/package.json @@ -0,0 +1,28 @@ +{ + "name": "integration-deno-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "node ./node_modules/@remix-run/dev/dist/cli.js build", + "dev": "pnpm run \"/^dev:.*/\"", + "dev:deno": "NODE_ENV=development deno run --unstable --watch --allow-net --allow-read --allow-env ./build/index.js", + "dev:remix": "node ./node_modules/@remix-run/dev/dist/cli.js watch", + "start": "NODE_ENV=production deno run --unstable --allow-net --allow-read --allow-env ./build/index.js" + }, + "dependencies": { + "@remix-run/css-bundle": "workspace:*", + "@remix-run/deno": "workspace:*", + "@remix-run/react": "workspace:*", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/integration/helpers/deno-template/public/favicon.ico b/integration/helpers/deno-template/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/integration/helpers/deno-template/public/favicon.ico differ diff --git a/integration/helpers/deno-template/remix.config.js b/integration/helpers/deno-template/remix.config.js new file mode 100644 index 0000000000..659f7a99d2 --- /dev/null +++ b/integration/helpers/deno-template/remix.config.js @@ -0,0 +1,17 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + server: "./server.ts", + serverConditions: ["deno", "worker"], + serverDependenciesToBundle: "all", + serverMainFields: ["module", "main"], + serverModuleFormat: "esm", + serverPlatform: "neutral", + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", + // serverBuildPath: "build/index.js", + + // !!! Don't adjust this without changing the code that overwrites this + // in createFixtureProject() + ...global.INJECTED_FIXTURE_REMIX_CONFIG, +}; diff --git a/integration/helpers/deno-template/server.ts b/integration/helpers/deno-template/server.ts new file mode 100644 index 0000000000..a7caa47e20 --- /dev/null +++ b/integration/helpers/deno-template/server.ts @@ -0,0 +1,13 @@ +import { serve } from "https://deno.land/std@0.128.0/http/server.ts"; +import { createRequestHandlerWithStaticFiles } from "@remix-run/deno"; +// Import path interpreted by the Remix compiler +import * as build from "@remix-run/dev/server-build"; + +const remixHandler = createRequestHandlerWithStaticFiles({ + build, + getLoadContext: () => ({}), +}); + +const port = Number(Deno.env.get("PORT")) || 8000; +console.log(`Listening on http://localhost:${port}`); +serve(remixHandler, { port }); diff --git a/integration/helpers/killtree.ts b/integration/helpers/killtree.ts new file mode 100644 index 0000000000..01e412687f --- /dev/null +++ b/integration/helpers/killtree.ts @@ -0,0 +1,57 @@ +import execa from "execa"; +import pidtree from "pidtree"; + +const isWindows = process.platform === "win32"; + +const kill = async (pid: number) => { + if (!isAlive(pid)) return; + if (isWindows) { + await execa("taskkill", ["/F", "/PID", pid.toString()]).catch((error) => { + // taskkill 128 -> the process is already dead + if (error.exitCode === 128) return; + if (/There is no running instance of the task./.test(error.message)) + return; + console.warn(error.message); + }); + return; + } + await execa("kill", ["-9", pid.toString()]).catch((error) => { + // process is already dead + if (/No such process/.test(error.message)) return; + console.warn(error.message); + }); +}; + +const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return false; + } +}; + +export const killtree = async (pid: number) => { + let descendants = await pidtree(pid).catch(() => undefined); + if (descendants === undefined) return; + let pids = [pid, ...descendants]; + + await Promise.all(pids.map(kill)); + + return new Promise((resolve, reject) => { + let check = setInterval(() => { + pids = pids.filter(isAlive); + if (pids.length === 0) { + clearInterval(check); + resolve(); + } + }, 50); + + setTimeout(() => { + clearInterval(check); + reject( + new Error("Timeout: Processes did not exit within the specified time.") + ); + }, 2000); + }); +}; diff --git a/integration/helpers/node-template/.gitignore b/integration/helpers/node-template/.gitignore new file mode 100644 index 0000000000..3f7bf98da3 --- /dev/null +++ b/integration/helpers/node-template/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/integration/helpers/node-template/app/root.tsx b/integration/helpers/node-template/app/root.tsx new file mode 100644 index 0000000000..b46b8fb15b --- /dev/null +++ b/integration/helpers/node-template/app/root.tsx @@ -0,0 +1,33 @@ +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/node-template/app/routes/_index.tsx b/integration/helpers/node-template/app/routes/_index.tsx new file mode 100644 index 0000000000..5347369230 --- /dev/null +++ b/integration/helpers/node-template/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "@remix-run/node"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json new file mode 100644 index 0000000000..6365ed8a99 --- /dev/null +++ b/integration/helpers/node-template/package.json @@ -0,0 +1,38 @@ +{ + "name": "integration-node-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "node ./node_modules/@remix-run/dev/dist/cli.js build", + "dev": "node ./node_modules/@remix-run/dev/dist/cli.js dev", + "start": "node ./node_modules/@remix-run/serve/dist/cli.js ./build/index.js" + }, + "dependencies": { + "@remix-run/css-bundle": "workspace:*", + "@remix-run/express": "workspace:*", + "@remix-run/node": "workspace:*", + "@remix-run/react": "workspace:*", + "@remix-run/serve": "workspace:*", + "@remix-run/server-runtime": "workspace:*", + "express": "^4.17.1", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*", + "@vanilla-extract/css": "^1.10.0", + "@vanilla-extract/vite-plugin": "^3.9.2", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "getos": "^3.2.1", + "postcss-import": "^15.1.0", + "tailwindcss": "^3.3.0", + "typescript": "^5.1.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/integration/helpers/node-template/public/favicon.ico b/integration/helpers/node-template/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/integration/helpers/node-template/public/favicon.ico differ diff --git a/integration/helpers/node-template/remix.config.js b/integration/helpers/node-template/remix.config.js new file mode 100644 index 0000000000..0713bf21a8 --- /dev/null +++ b/integration/helpers/node-template/remix.config.js @@ -0,0 +1,11 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +export default { + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", + // serverBuildPath: "build/index.js", + + // !!! Don't adjust this without changing the code that overwrites this + // in createFixtureProject() + ...global.INJECTED_FIXTURE_REMIX_CONFIG, +}; diff --git a/integration/helpers/node-template/remix.env.d.ts b/integration/helpers/node-template/remix.env.d.ts new file mode 100644 index 0000000000..dcf8c45e1d --- /dev/null +++ b/integration/helpers/node-template/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/integration/helpers/node-template/tsconfig.json b/integration/helpers/node-template/tsconfig.json new file mode 100644 index 0000000000..28cce918b8 --- /dev/null +++ b/integration/helpers/node-template/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts new file mode 100644 index 0000000000..8a797d115b --- /dev/null +++ b/integration/helpers/playwright-fixture.ts @@ -0,0 +1,355 @@ +import cp from "node:child_process"; +import type { Page, Response, Request } from "@playwright/test"; +import { test } from "@playwright/test"; +import cheerio from "cheerio"; +import prettier from "prettier"; + +import type { AppFixture } from "./create-fixture.js"; + +export class PlaywrightFixture { + readonly page: Page; + readonly app: AppFixture; + + constructor(app: AppFixture, page: Page) { + this.page = page; + this.app = app; + } + + /** + * Visits the href with a document request. + * + * @param href The href you want to visit + * @param waitForHydration Wait for the page to full load/hydrate? + * - `undefined` to wait for the document `load` event + * - `true` wait for the network to be idle, so everything should be loaded + * and ready to go + * - `false` to wait only until the initial doc to be returned and the document + * to start loading (mostly useful for testing deferred responses) + */ + async goto(href: string, waitForHydration?: boolean): Promise { + let response = await this.page.goto(this.app.serverUrl + href, { + waitUntil: + waitForHydration === true + ? "networkidle" + : waitForHydration === false + ? "commit" + : "load", + }); + if (response == null) + throw new Error( + "Unexpected null response, possible about:blank request or same-URL redirect" + ); + return response; + } + + /** + * Finds a link on the page with a matching href, clicks it, and waits for + * the network to be idle before continuing. + * + * @param href The href of the link you want to click + * @param options `{ wait }` waits for the network to be idle before moving on + */ + async clickLink(href: string, options: { wait: boolean } = { wait: true }) { + let selector = `a[href="${href}"]`; + let el = await this.page.$(selector); + if (!el) { + throw new Error(`Could not find link for ${selector}`); + } + if (options.wait) { + await doAndWait(this.page, () => el!.click()); + } else { + await el.click(); + } + } + + /** + * Find the input element and fill for file uploads. + * + * @param inputSelector The selector of the input you want to fill + * @param filePaths The paths to the files you want to upload + */ + async uploadFile(inputSelector: string, ...filePaths: string[]) { + let el = await this.page.$(inputSelector); + if (!el) { + throw new Error(`Could not find input for: ${inputSelector}`); + } + await el.setInputFiles(filePaths); + } + + /** + * Finds the first submit button with `formAction` that matches the + * `action` supplied, clicks it, and optionally waits for the network to + * be idle before continuing. + * + * @param action The formAction of the button you want to click + * @param options `{ wait }` waits for the network to be idle before moving on + */ + async clickSubmitButton( + action: string, + options: { wait?: boolean; method?: string } = { wait: true } + ) { + let selector: string; + if (options.method) { + selector = `button[formAction="${action}"][formMethod="${options.method}"]`; + } else { + selector = `button[formAction="${action}"]`; + } + + let el = await this.page.$(selector); + if (!el) { + if (options.method) { + selector = `form[action="${action}"] button[type="submit"][formMethod="${options.method}"]`; + } else { + selector = `form[action="${action}"] button[type="submit"]`; + } + el = await this.page.$(selector); + if (!el) { + throw new Error(`Can't find button for: ${action}`); + } + } + if (options.wait) { + await doAndWait(this.page, () => el!.click()); + } else { + await el.click(); + } + } + + /** + * Clicks any element and waits for the network to be idle. + */ + async clickElement(selector: string) { + let el = await this.page.$(selector); + if (!el) { + throw new Error(`Can't find element for: ${selector}`); + } + await doAndWait(this.page, () => el!.click()); + } + + /** + * Perform any interaction and wait for the network to be idle: + * + * ```ts + * await app.waitForNetworkAfter(page, () => app.page.focus("#el")) + * ``` + */ + async waitForNetworkAfter(fn: () => Promise) { + await doAndWait(this.page, fn); + } + + /** + * "Clicks" the back button and optionally waits for the network to be + * idle (defaults to waiting). + */ + async goBack(options: { wait: boolean } = { wait: true }) { + if (options.wait) { + await doAndWait(this.page, () => this.page.goBack()); + } else { + await this.page.goBack(); + } + } + + /** + * "Clicks" the refresh button. + */ + async reload(options: { wait: boolean } = { wait: true }) { + if (options.wait) { + await doAndWait(this.page, () => this.page.reload()); + } else { + await this.page.reload(); + } + } + + /** + * Collects data responses from the network, usually after a link click or + * form submission. This is useful for asserting that specific loaders + * were called (or not). + */ + collectDataResponses() { + return this.collectResponses((url) => url.searchParams.has("_data")); + } + + /** + * Collects single fetch data responses from the network, usually after a + * link click or form submission. This is useful for asserting that specific + * loaders were called (or not). + */ + collectSingleFetchResponses() { + return this.collectResponses((url) => url.pathname.endsWith(".data")); + } + + /** + * Collects all responses from the network, usually after a link click or + * form submission. A filter can be provided to only collect responses + * that meet a certain criteria. + */ + collectResponses(filter?: (url: URL) => boolean) { + let responses: Response[] = []; + + this.page.on("response", (res) => { + if (!filter || filter(new URL(res.url()))) { + responses.push(res); + } + }); + + return responses; + } + + /** + * Get HTML from the page. Useful for asserting something rendered that + * you expected. + * + * @param selector CSS Selector for the element's HTML you want + */ + getHtml(selector?: string) { + return getHtml(this.page, selector); + } + + /** + * Get a cheerio instance of an element from the page. + * + * @param selector CSS Selector for the element's HTML you want + */ + async getElement(selector: string) { + return getElement(await getHtml(this.page), selector); + } + + /** + * Keeps the fixture running for as many seconds as you want so you can go + * poke around in the browser to see what's up. + * + * @param seconds How long you want the app to stay open + */ + async poke(seconds: number = 10, href: string = "/") { + let ms = seconds * 1000; + test.setTimeout(ms); + console.log( + `🙈 Poke around for ${seconds} seconds 👉 ${this.app.serverUrl}` + ); + cp.exec(`open ${this.app.serverUrl}${href}`); + return new Promise((res) => setTimeout(res, ms)); + } +} + +export async function getHtml(page: Page, selector?: string) { + let html = await page.content(); + return selector ? selectHtml(html, selector) : prettyHtml(html); +} + +export function getElement(source: string, selector: string) { + let el = cheerio(selector, source); + if (!el.length) { + throw new Error(`No element matches selector "${selector}"`); + } + return el; +} + +export function selectHtml(source: string, selector: string) { + let el = getElement(source, selector); + return prettyHtml(cheerio.html(el)).trim(); +} + +export function prettyHtml(source: string): string { + return prettier.format(source, { parser: "html" }); +} + +async function doAndWait( + page: Page, + action: () => Promise, + longPolls = 0 +) { + let DEBUG = !!process.env.DEBUG; + let networkSettledCallback: any; + let networkSettledPromise = new Promise((resolve) => { + networkSettledCallback = resolve; + }); + + let requestCounter = 0; + let actionDone = false; + let pending = new Set(); + + let maybeSettle = () => { + if (actionDone && requestCounter <= longPolls) networkSettledCallback(); + }; + + let onRequest = (request: Request) => { + ++requestCounter; + if (DEBUG) { + pending.add(request); + console.log(`+[${requestCounter}]: ${request.url()}`); + } + }; + let onRequestDone = (request: Request) => { + // Let the page handle responses asynchronously (via setTimeout(0)). + // + // Note: this might be changed to use delay, e.g. setTimeout(f, 100), + // when the page uses delay itself. + let evaluate = page.evaluate(() => { + return new Promise((resolve) => setTimeout(resolve, 0)); + }); + evaluate + .catch(() => null) + .then(() => { + --requestCounter; + maybeSettle(); + if (DEBUG) { + pending.delete(request); + console.log(`-[${requestCounter}]: ${request.url()}`); + } + }); + }; + + page.on("request", onRequest); + page.on("requestfinished", onRequestDone); + page.on("requestfailed", onRequestDone); + page.on("load", networkSettledCallback); // e.g. navigation with javascript disabled + + let timeoutId = DEBUG + ? setInterval(() => { + console.log(`${requestCounter} requests pending:`); + for (let request of pending) console.log(` ${request.url()}`); + }, 5000) + : undefined; + + let result = await action(); + actionDone = true; + maybeSettle(); + if (DEBUG) { + console.log(`action done, ${requestCounter} requests pending`); + } + await networkSettledPromise; + + // I wish I knew why but Safari seems to get all screwed up without this. + // When you run doAndWait (via clicking a blink or submitting a form) and + // then waitForSelector(). It finds the selector element but thinks it's + // hidden for some unknown reason. It's intermittent, but waiting for the + // next animation frame delaying slightly before the waitForSelector() calls + // seems to fix it 🤷‍♂️ + // + // Test timeout of 30000ms exceeded. + // + // Error: page.waitForSelector: Target closed + // =========================== logs =========================== + // waiting for locator('text=ROOT_BOUNDARY_TEXT') to be visible + // locator resolved to hidden
ROOT_BOUNDARY_TEXT
+ // locator resolved to hidden
ROOT_BOUNDARY_TEXT
+ // ... and so on until the test times out + let userAgent = await page.evaluate(() => navigator.userAgent); + if (/Safari\//i.test(userAgent) && !/Chrome\//i.test(userAgent)) { + await page.evaluate(() => new Promise((r) => requestAnimationFrame(r))); + } + + if (DEBUG) { + console.log(`action done, network settled`); + } + + page.removeListener("request", onRequest); + page.removeListener("requestfinished", onRequestDone); + page.removeListener("requestfailed", onRequestDone); + page.removeListener("load", networkSettledCallback); + + if (DEBUG && timeoutId) { + clearTimeout(timeoutId); + } + + return result; +} diff --git a/integration/helpers/vite-cloudflare-template/.gitignore b/integration/helpers/vite-cloudflare-template/.gitignore new file mode 100644 index 0000000000..80ec311f4f --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/.gitignore @@ -0,0 +1,5 @@ +node_modules + +/.cache +/build +.env diff --git a/integration/helpers/vite-cloudflare-template/app/root.tsx b/integration/helpers/vite-cloudflare-template/app/root.tsx new file mode 100644 index 0000000000..e31409ca31 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/app/root.tsx @@ -0,0 +1,25 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export default function App() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/vite-cloudflare-template/app/routes/_index.tsx b/integration/helpers/vite-cloudflare-template/app/routes/_index.tsx new file mode 100644 index 0000000000..4aa6089f12 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "@remix-run/cloudflare"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} diff --git a/integration/helpers/vite-cloudflare-template/env.d.ts b/integration/helpers/vite-cloudflare-template/env.d.ts new file mode 100644 index 0000000000..77f3942249 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/integration/helpers/vite-cloudflare-template/package.json b/integration/helpers/vite-cloudflare-template/package.json new file mode 100644 index 0000000000..9be24cd7fa --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/package.json @@ -0,0 +1,36 @@ +{ + "name": "integration-vite-cloudflare-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "node ./node_modules/@remix-run/dev/dist/cli.js vite:dev", + "build": "node ./node_modules/@remix-run/dev/dist/cli.js vite:build", + "start": "wrangler pages dev ./build/client", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/cloudflare": "2.9.0-pre.0", + "@remix-run/cloudflare-pages": "2.9.0-pre.0", + "@remix-run/react": "2.9.0-pre.0", + "isbot": "^4.1.0", + "miniflare": "^3.20231030.4", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20230518.0", + "@remix-run/dev": "workspace:*", + "@remix-run/eslint-config": "workspace:*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "typescript": "^5.1.6", + "vite": "5.1.3", + "vite-tsconfig-paths": "^4.2.1", + "wrangler": "^3.24.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/integration/helpers/vite-cloudflare-template/public/favicon.ico b/integration/helpers/vite-cloudflare-template/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/integration/helpers/vite-cloudflare-template/public/favicon.ico differ diff --git a/integration/helpers/vite-cloudflare-template/tsconfig.json b/integration/helpers/vite-cloudflare-template/tsconfig.json new file mode 100644 index 0000000000..ad5ae05598 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true + } +} diff --git a/integration/helpers/vite-cloudflare-template/vite.config.ts b/integration/helpers/vite-cloudflare-template/vite.config.ts new file mode 100644 index 0000000000..a1fcb5a7b7 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/vite.config.ts @@ -0,0 +1,7 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [remix(), tsconfigPaths()], +}); diff --git a/integration/helpers/vite-template/.gitignore b/integration/helpers/vite-template/.gitignore new file mode 100644 index 0000000000..80ec311f4f --- /dev/null +++ b/integration/helpers/vite-template/.gitignore @@ -0,0 +1,5 @@ +node_modules + +/.cache +/build +.env diff --git a/integration/helpers/vite-template/app/root.tsx b/integration/helpers/vite-template/app/root.tsx new file mode 100644 index 0000000000..e31409ca31 --- /dev/null +++ b/integration/helpers/vite-template/app/root.tsx @@ -0,0 +1,25 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export default function App() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/vite-template/app/routes/_index.tsx b/integration/helpers/vite-template/app/routes/_index.tsx new file mode 100644 index 0000000000..5347369230 --- /dev/null +++ b/integration/helpers/vite-template/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "@remix-run/node"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} diff --git a/integration/helpers/vite-template/env.d.ts b/integration/helpers/vite-template/env.d.ts new file mode 100644 index 0000000000..78ed2345c6 --- /dev/null +++ b/integration/helpers/vite-template/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/integration/helpers/vite-template/package.json b/integration/helpers/vite-template/package.json new file mode 100644 index 0000000000..2b8589256d --- /dev/null +++ b/integration/helpers/vite-template/package.json @@ -0,0 +1,42 @@ +{ + "name": "integration-vite-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "node ./node_modules/@remix-run/dev/dist/cli.js vite:dev", + "build": "node ./node_modules/@remix-run/dev/dist/cli.js vite:build", + "start": "node ./node_modules/@remix-run/serve/dist/cli.js ./build/server/index.js", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/express": "workspace:*", + "@remix-run/node": "workspace:*", + "@remix-run/react": "workspace:*", + "@remix-run/serve": "workspace:*", + "@vanilla-extract/css": "^1.10.0", + "@vanilla-extract/vite-plugin": "^3.9.2", + "express": "^4.17.1", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "serialize-javascript": "^6.0.1" + }, + "devDependencies": { + "@remix-run/dev": "workspace:*", + "@remix-run/eslint-config": "workspace:*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "eslint": "^8.38.0", + "node-fetch": "^3.3.2", + "typescript": "^5.1.6", + "vite": "5.1.0", + "vite-env-only": "^2.0.0", + "vite-tsconfig-paths": "^4.2.1", + "wrangler": "^3.24.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/integration/helpers/vite-template/public/favicon.ico b/integration/helpers/vite-template/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/integration/helpers/vite-template/public/favicon.ico differ diff --git a/integration/helpers/vite-template/tsconfig.json b/integration/helpers/vite-template/tsconfig.json new file mode 100644 index 0000000000..ad5ae05598 --- /dev/null +++ b/integration/helpers/vite-template/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true + } +} diff --git a/integration/helpers/vite-template/vite.config.ts b/integration/helpers/vite-template/vite.config.ts new file mode 100644 index 0000000000..a1fcb5a7b7 --- /dev/null +++ b/integration/helpers/vite-template/vite.config.ts @@ -0,0 +1,7 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [remix(), tsconfigPaths()], +}); diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts new file mode 100644 index 0000000000..0216d50f59 --- /dev/null +++ b/integration/helpers/vite.ts @@ -0,0 +1,386 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import path from "node:path"; +import fs from "node:fs/promises"; +import type { Readable } from "node:stream"; +import url from "node:url"; +import fse from "fs-extra"; +import stripIndent from "strip-indent"; +import waitOn from "wait-on"; +import getPort from "get-port"; +import shell from "shelljs"; +import glob from "glob"; +import dedent from "dedent"; +import type { Page } from "@playwright/test"; +import { test as base, expect } from "@playwright/test"; + +const remixBin = "node_modules/@remix-run/dev/dist/cli.js"; +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const root = path.resolve(__dirname, "../.."); +const TMP_DIR = path.join(root, ".tmp/integration"); + +export const viteConfig = { + server: async (args: { port: number; fsAllow?: string[] }) => { + let { port, fsAllow } = args; + let hmrPort = await getPort(); + let text = dedent` + server: { + port: ${port}, + strictPort: true, + hmr: { port: ${hmrPort} }, + fs: { allow: ${fsAllow ? JSON.stringify(fsAllow) : "undefined"} } + }, + `; + return text; + }, + basic: async (args: { port: number; fsAllow?: string[] }) => { + return dedent` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + ${await viteConfig.server(args)} + plugins: [remix()] + } + `; + }, +}; + +export const EXPRESS_SERVER = (args: { + port: number; + loadContext?: Record; +}) => + String.raw` + import { createRequestHandler } from "@remix-run/express"; + import { installGlobals } from "@remix-run/node"; + import express from "express"; + + installGlobals(); + + let viteDevServer = + process.env.NODE_ENV === "production" + ? undefined + : await import("vite").then((vite) => + vite.createServer({ + server: { middlewareMode: true }, + }) + ); + + const app = express(); + + if (viteDevServer) { + app.use(viteDevServer.middlewares); + } else { + app.use( + "/assets", + express.static("build/client/assets", { immutable: true, maxAge: "1y" }) + ); + } + app.use(express.static("build/client", { maxAge: "1h" })); + + app.all( + "*", + createRequestHandler({ + build: viteDevServer + ? () => viteDevServer.ssrLoadModule("virtual:remix/server-build") + : await import("./build/index.js"), + getLoadContext: () => (${JSON.stringify(args.loadContext ?? {})}), + }) + ); + + const port = ${args.port}; + app.listen(port, () => console.log('http://localhost:' + port)); + `; + +type TemplateName = "vite-template" | "vite-cloudflare-template"; + +export async function createProject( + files: Record = {}, + templateName: TemplateName = "vite-template" +) { + let projectName = `remix-${Math.random().toString(32).slice(2)}`; + let projectDir = path.join(TMP_DIR, projectName); + await fse.ensureDir(projectDir); + + // base template + let templateDir = path.resolve(__dirname, templateName); + await fse.copy(templateDir, projectDir, { errorOnExist: true }); + + // user-defined files + await Promise.all( + Object.entries(files).map(async ([filename, contents]) => { + let filepath = path.join(projectDir, filename); + await fse.ensureDir(path.dirname(filepath)); + await fse.writeFile(filepath, stripIndent(contents)); + }) + ); + + return projectDir; +} + +// Avoid "Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env +// being set" in vite-ecosystem-ci which breaks empty stderr assertions. To fix +// this we always ensure that only NO_COLOR is set after spreading process.env. +const colorEnv = { + FORCE_COLOR: undefined, + NO_COLOR: "1", +} as const; + +export const viteBuild = ({ + cwd, + env = {}, +}: { + cwd: string; + env?: Record; +}) => { + let nodeBin = process.argv[0]; + + return spawnSync(nodeBin, [remixBin, "vite:build"], { + cwd, + env: { + ...process.env, + ...colorEnv, + ...env, + }, + }); +}; + +export const viteRemixServe = async ({ + cwd, + port, + serverBundle, + basename, +}: { + cwd: string; + port: number; + serverBundle?: string; + basename?: string; +}) => { + let nodeBin = process.argv[0]; + let serveProc = spawn( + nodeBin, + [ + "node_modules/@remix-run/serve/dist/cli.js", + `build/server/${serverBundle ? serverBundle + "/" : ""}index.js`, + ], + { + cwd, + stdio: "pipe", + env: { NODE_ENV: "production", PORT: port.toFixed(0) }, + } + ); + await waitForServer(serveProc, { port, basename }); + return () => serveProc.kill(); +}; + +export const wranglerPagesDev = async ({ + cwd, + port, +}: { + cwd: string; + port: number; +}) => { + let nodeBin = process.argv[0]; + + // grab wrangler bin from remix-run/remix root node_modules since its not copied into integration project's node_modules + let wranglerBin = path.resolve("node_modules/wrangler/bin/wrangler.js"); + + let proc = spawn( + nodeBin, + [wranglerBin, "pages", "dev", "./build/client", "--port", String(port)], + { + cwd, + stdio: "pipe", + env: { NODE_ENV: "production" }, + } + ); + await waitForServer(proc, { port }); + return () => proc.kill(); +}; + +type ServerArgs = { + cwd: string; + port: number; + env?: Record; + basename?: string; +}; + +const createDev = + (nodeArgs: string[]) => + async ({ cwd, port, env, basename }: ServerArgs): Promise<() => unknown> => { + let proc = node(nodeArgs, { cwd, env }); + await waitForServer(proc, { port, basename }); + return () => proc.kill(); + }; + +export const viteDev = createDev([remixBin, "vite:dev"]); +export const customDev = createDev(["./server.mjs"]); + +// Used for testing errors thrown on build when we don't want to start and +// wait for the server +export const viteDevCmd = ({ cwd }: { cwd: string }) => { + let nodeBin = process.argv[0]; + return spawnSync(nodeBin, [remixBin, "vite:dev"], { + cwd, + env: { ...process.env }, + }); +}; + +declare module "@playwright/test" { + interface Page { + errors: Error[]; + } +} + +export type Files = (args: { port: number }) => Promise>; +type Fixtures = { + page: Page; + viteDev: ( + files: Files, + templateName?: TemplateName + ) => Promise<{ + port: number; + cwd: string; + }>; + customDev: (files: Files) => Promise<{ + port: number; + cwd: string; + }>; + viteRemixServe: (files: Files) => Promise<{ + port: number; + cwd: string; + }>; + wranglerPagesDev: (files: Files) => Promise<{ + port: number; + cwd: string; + }>; +}; + +export const test = base.extend({ + page: async ({ page }, use) => { + page.errors = []; + page.on("pageerror", (error: Error) => page.errors.push(error)); + await use(page); + }, + // eslint-disable-next-line no-empty-pattern + viteDev: async ({}, use) => { + let stop: (() => unknown) | undefined; + await use(async (files, template) => { + let port = await getPort(); + let cwd = await createProject(await files({ port }), template); + stop = await viteDev({ cwd, port }); + return { port, cwd }; + }); + stop?.(); + }, + // eslint-disable-next-line no-empty-pattern + customDev: async ({}, use) => { + let stop: (() => unknown) | undefined; + await use(async (files) => { + let port = await getPort(); + let cwd = await createProject(await files({ port })); + stop = await customDev({ cwd, port }); + return { port, cwd }; + }); + stop?.(); + }, + // eslint-disable-next-line no-empty-pattern + viteRemixServe: async ({}, use) => { + let stop: (() => unknown) | undefined; + await use(async (files) => { + let port = await getPort(); + let cwd = await createProject(await files({ port })); + let { status } = viteBuild({ cwd }); + expect(status).toBe(0); + stop = await viteRemixServe({ cwd, port }); + return { port, cwd }; + }); + stop?.(); + }, + // eslint-disable-next-line no-empty-pattern + wranglerPagesDev: async ({}, use) => { + let stop: (() => unknown) | undefined; + await use(async (files) => { + let port = await getPort(); + let cwd = await createProject( + await files({ port }), + "vite-cloudflare-template" + ); + let { status } = viteBuild({ cwd }); + expect(status).toBe(0); + stop = await wranglerPagesDev({ cwd, port }); + return { port, cwd }; + }); + stop?.(); + }, +}); + +function node( + args: string[], + options: { cwd: string; env?: Record } +) { + let nodeBin = process.argv[0]; + + let proc = spawn(nodeBin, args, { + cwd: options.cwd, + env: { + ...process.env, + ...colorEnv, + ...options.env, + }, + stdio: "pipe", + }); + return proc; +} + +async function waitForServer( + proc: ChildProcess & { stdout: Readable; stderr: Readable }, + args: { port: number; basename?: string } +) { + let devStdout = bufferize(proc.stdout); + let devStderr = bufferize(proc.stderr); + + await waitOn({ + resources: [`http://localhost:${args.port}${args.basename ?? "/"}`], + timeout: 10000, + }).catch((err) => { + let stdout = devStdout(); + let stderr = devStderr(); + proc.kill(); + throw new Error( + [ + err.message, + "", + "exit code: " + proc.exitCode, + "stdout: " + stdout ? `\n${stdout}\n` : "", + "stderr: " + stderr ? `\n${stderr}\n` : "", + ].join("\n") + ); + }); +} + +function bufferize(stream: Readable): () => string { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +} + +export function createEditor(projectDir: string) { + return async (file: string, transform: (contents: string) => string) => { + let filepath = path.join(projectDir, file); + let contents = await fs.readFile(filepath, "utf8"); + await fs.writeFile(filepath, transform(contents), "utf8"); + }; +} + +export function grep(cwd: string, pattern: RegExp): string[] { + let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", { + cwd, + absolute: true, + }); + + let lines = shell + .grep("-l", pattern, assetFiles) + .stdout.trim() + .split("\n") + .filter((line) => line.length > 0); + return lines; +} diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts new file mode 100644 index 0000000000..159b404341 --- /dev/null +++ b/integration/hmr-test.ts @@ -0,0 +1,669 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Readable } from "node:stream"; +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import execa from "execa"; +import getPort from "get-port"; + +import type { FixtureInit } from "./helpers/create-fixture.js"; +import { createFixtureProject, css, js } from "./helpers/create-fixture.js"; +import { killtree } from "./helpers/killtree.js"; + +test.setTimeout(150_000); + +let files = { + "postcss.config.cjs": js` + module.exports = { + plugins: { + "postcss-import": {}, // Testing PostCSS cache invalidation + tailwindcss: {}, + } + }; + `, + + "tailwind.config.js": js` + /** @type {import('tailwindcss').Config} */ + export default { + content: ["./app/**/*.{ts,tsx,jsx,js}"], + theme: { + extend: {}, + }, + plugins: [], + }; + `, + + "app/tailwind.css": css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + + "app/stylesWithImport.css": css` + @import "./importedStyle.css"; + `, + + "app/importedStyle.css": css` + .importedStyle { + font-weight: normal; + } + `, + + "app/sideEffectStylesWithImport.css": css` + @import "./importedSideEffectStyle.css"; + `, + + "app/importedSideEffectStyle.css": css` + .importedSideEffectStyle { + font-size: initial; + } + `, + + "app/style.module.css": css` + .test { + composes: color from "./composedStyle.module.css"; + } + `, + + "app/composedStyle.module.css": css` + .color { + color: initial; + } + `, + + "app/root.tsx": js` + import type { LinksFunction } from "@remix-run/node"; + import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react"; + import { cssBundleHref } from "@remix-run/css-bundle"; + + import Counter from "./components/counter"; + import tailwindStyles from "./tailwind.css"; + import stylesWithImport from "./stylesWithImport.css"; + import "./sideEffectStylesWithImport.css"; + + export const links: LinksFunction = () => [ + { rel: "stylesheet", href: tailwindStyles }, + { rel: "stylesheet", href: stylesWithImport }, + ...cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [], + ]; + + // dummy loader to make sure that HDR is granular + export const loader = () => { + return null; + }; + + export default function Root() { + return ( + + + + + + +
+ + + + +
+ + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { useLoaderData } from "@remix-run/react"; + export function shouldRevalidate(args) { + return true; + } + export default function Index() { + const t = useLoaderData(); + return ( +
+

Index Title

+
+ ) + } + `, + + "app/routes/about.tsx": js` + import Counter from "../components/counter"; + export default function About() { + return ( +
+

About Title

+ +
+ ) + } + `, + "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' +export const loader = () => "crazy" +export const Component = () => { + const data = useLoaderData() + return

{data}

+} + +# heyo +whatsup + + +`, + "app/components/counter.tsx": js` + import * as React from "react"; + export default function Counter({ id }) { + let [count, setCount] = React.useState(0); + return ( +

+ +

+ ); + } + `, +}; + +let customServer = (options: { appPort: number; devReady: string }) => { + return js` + import path from "node:path"; + import url from "node:url"; + import express from "express"; + import { createRequestHandler } from "@remix-run/express"; + import { ${options.devReady}, installGlobals } from "@remix-run/node"; + + installGlobals(); + + const app = express(); + app.use(express.static("public", { immutable: true, maxAge: "1y" })); + + const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); + + app.all( + "*", + createRequestHandler({ + build: await import(BUILD_PATH), + mode: process.env.NODE_ENV, + }) + ); + + let port = ${options.appPort}; + app.listen(port, async () => { + let build = await import(BUILD_PATH); + console.log('✅ app ready: http://localhost:' + port); + if (process.env.NODE_ENV === 'development') { + ${options.devReady}(build); + } + }); + `; +}; + +let HMR_TIMEOUT_MS = 30_000; + +let remix = "node ./node_modules/@remix-run/dev/dist/cli.js"; +let serve = "node ./node_modules/@remix-run/serve/dist/cli.js"; + +test("HMR for remix-serve", async ({ page }) => { + await dev(page, (appPort) => ({ + files, + devScript: `PORT=${appPort} ${remix} dev --manual -c "${serve} ./build/index.js"`, + appReadyPattern: /\[remix-serve\] /, + })); +}); + +test("HMR for custom server with broadcast", async ({ page }) => { + await dev(page, (appPort) => ({ + files: { + ...files, + "server.js": customServer({ + appPort, + devReady: "broadcastDevReady", + }), + }, + devScript: `${remix} dev -c "node ./server.js"`, + appReadyPattern: /✅ app ready: /, + })); +}); + +test("HMR for custom server with log", async ({ page }) => { + await dev(page, (appPort) => ({ + files: { + ...files, + "server.js": customServer({ + appPort, + devReady: "logDevReady", + }), + }, + devScript: `${remix} dev -c "node ./server.js"`, + appReadyPattern: /✅ app ready: /, + })); +}); + +async function dev( + page: Page, + getOptions: (appPort: number) => { + files: Record; + devScript: string; + appReadyPattern: RegExp; + } +) { + // uncomment for debugging + // page.on("console", (msg) => console.log(msg.text())); + page.on("pageerror", logConsoleError); + let dataRequests = 0; + page.on("request", (request) => { + let url = new URL(request.url()); + if (url.searchParams.has("_data")) { + dataRequests++; + } + }); + + let appPort = await getPort(); + let devPort = await getPort(); + + let options = getOptions(appPort); + + let fixture: FixtureInit = { + config: { + dev: { + port: devPort, + }, + }, + files: options.files, + }; + + let projectDir = await createFixtureProject(fixture); + + // inject dev script + let pkgJson = JSON.parse( + fs.readFileSync(path.join(projectDir, "package.json"), "utf8") + ); + pkgJson.scripts.dev = options.devScript; + fs.writeFileSync( + path.join(projectDir, "package.json"), + JSON.stringify(pkgJson, null, 2) + ); + + let devProc = execa("pnpm", ["run", "dev"], { cwd: projectDir }); + let devStdout = bufferize(devProc.stdout!); + let devStderr = bufferize(devProc.stderr!); + + try { + await wait( + () => { + if (devProc.exitCode) throw Error("Dev server exited early"); + return options.appReadyPattern.test(devStdout()); + }, + { timeoutMs: HMR_TIMEOUT_MS } + ); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: "networkidle", + }); + + // `` value as page state that + // would be wiped out by a full page refresh + // but should be persisted by hmr + let input = page.getByLabel("Root Input"); + expect(input).toBeVisible(); + await input.type("asdfasdf"); + + let counter = await page.waitForSelector("#root-counter"); + await counter.click(); + await page.waitForSelector(`#root-counter:has-text("inc 1")`); + + let indexPath = path.join(projectDir, "app", "routes", "_index.tsx"); + let originalIndex = fs.readFileSync(indexPath, "utf8"); + let counterPath = path.join(projectDir, "app", "components", "counter.tsx"); + let originalCounter = fs.readFileSync(counterPath, "utf8"); + let importedStylePath = path.join(projectDir, "app", "importedStyle.css"); + let originalImportedStyle = fs.readFileSync(importedStylePath, "utf8"); + let composedCssModulePath = path.join( + projectDir, + "app", + "composedStyle.module.css" + ); + let originalComposedCssModule = fs.readFileSync( + composedCssModulePath, + "utf8" + ); + let mdxPath = path.join(projectDir, "app", "routes", "mdx.mdx"); + let originalMdx = fs.readFileSync(mdxPath, "utf8"); + let importedSideEffectStylePath = path.join( + projectDir, + "app", + "importedSideEffectStyle.css" + ); + let originalImportedSideEffectStyle = fs.readFileSync( + importedSideEffectStylePath, + "utf8" + ); + + // make content and style changed to index route + let newComposedCssModule = ` + .color { + background: black; + color: white; + } + `; + fs.writeFileSync(composedCssModulePath, newComposedCssModule); + + // make changes to imported styles + let newImportedStyle = ` + .importedStyle { + font-weight: 800; + } + `; + fs.writeFileSync(importedStylePath, newImportedStyle); + + // // make changes to imported side effect styles + let newImportedSideEffectStyle = ` + .importedSideEffectStyle { + font-size: 32px; + } + `; + fs.writeFileSync(importedSideEffectStylePath, newImportedSideEffectStyle); + + // change text, add updated styles, add new Tailwind class ("italic") + let newIndex = ` + import { useLoaderData } from "@remix-run/react"; + import styles from "~/style.module.css"; + export function shouldRevalidate(args) { + return true; + } + export default function Index() { + const t = useLoaderData(); + return ( +
+

Changed

+
+ ) + } + `; + fs.writeFileSync(indexPath, newIndex); + + // detect HMR'd content and style changes + await page.waitForLoadState("networkidle"); + + let h1 = page.getByText("Changed"); + await h1.waitFor({ timeout: HMR_TIMEOUT_MS }); + expect(h1).toHaveCSS("color", "rgb(255, 255, 255)"); + expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)"); + expect(h1).toHaveCSS("font-style", "italic"); + expect(h1).toHaveCSS("font-weight", "800"); + expect(h1).toHaveCSS("font-size", "32px"); + + // verify that `` value was persisted (i.e. hmr, not full page refresh) + expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); + await page.waitForSelector(`#root-counter:has-text("inc 1")`); + + // undo change + fs.writeFileSync(indexPath, originalIndex); + fs.writeFileSync(importedStylePath, originalImportedStyle); + fs.writeFileSync(composedCssModulePath, originalComposedCssModule); + fs.writeFileSync( + importedSideEffectStylePath, + originalImportedSideEffectStyle + ); + await page.getByText("Index Title").waitFor({ timeout: HMR_TIMEOUT_MS }); + expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); + await page.waitForSelector(`#root-counter:has-text("inc 1")`); + + // We should not have done any revalidation yet as only UI has changed + expect(dataRequests).toBe(0); + + // add loader + let withLoader1 = ` + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export let loader = () => json({ hello: "world" }); + + export function shouldRevalidate(args) { + return true; + } + export default function Index() { + let { hello } = useLoaderData(); + return ( +
+

Hello, {hello}

+
+ ) + } + `; + fs.writeFileSync(indexPath, withLoader1); + await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(1); + await page.waitForLoadState("networkidle"); + + await page.getByText("Hello, world").waitFor({ timeout: HMR_TIMEOUT_MS }); + expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); + await page.waitForSelector(`#root-counter:has-text("inc 1")`); + + let withLoader2 = ` + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export function loader() { + return json({ hello: "planet" }) + } + + export function shouldRevalidate(args) { + return true; + } + export default function Index() { + let { hello } = useLoaderData(); + return ( +
+

Hello, {hello}

+
+ ) + } + `; + fs.writeFileSync(indexPath, withLoader2); + + await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(2); + + await page.waitForLoadState("networkidle"); + + await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); + expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); + await page.waitForSelector(`#root-counter:has-text("inc 1")`); + + // change shared component + let updatedCounter = ` + import * as React from "react"; + export default function Counter({ id }) { + let [count, setCount] = React.useState(0); + return ( +

+ +

+ ); + } + `; + fs.writeFileSync(counterPath, updatedCounter); + await page.waitForSelector(`#root-counter:has-text("dec 1")`); + counter = await page.waitForSelector("#root-counter"); + await counter.click(); + await counter.click(); + await page.waitForSelector(`#root-counter:has-text("dec -1")`); + + await page.click(`a[href="/about"]`); + let aboutCounter = await page.waitForSelector( + `#about-counter:has-text("dec 0")` + ); + await aboutCounter.click(); + await page.waitForSelector(`#about-counter:has-text("dec -1")`); + + // undo change + fs.writeFileSync(counterPath, originalCounter); + + counter = await page.waitForSelector(`#root-counter:has-text("inc -1")`); + await counter.click(); + counter = await page.waitForSelector(`#root-counter:has-text("inc 0")`); + + aboutCounter = await page.waitForSelector( + `#about-counter:has-text("inc -1")` + ); + await aboutCounter.click(); + aboutCounter = await page.waitForSelector( + `#about-counter:has-text("inc 0")` + ); + + expect(dataRequests).toBe(2); + + // mdx + await page.click(`a[href="/mdx"]`); + await page.waitForSelector(`#crazy`); + let mdx = `import { useLoaderData } from '@remix-run/react' +export const loader = () => "hot" +export const Component = () => { + const data = useLoaderData() + return

{data}

+} + +# heyo +whatsup + + +`; + fs.writeFileSync(mdxPath, mdx); + await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(4); + await page.waitForSelector(`#hot`); + + fs.writeFileSync(mdxPath, originalMdx); + await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(5); + await page.waitForSelector(`#crazy`); + + // dev server doesn't crash when rebuild fails + await page.click(`a[href="/"]`); + await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); + await page.waitForLoadState("networkidle"); + + let stderr = devStderr(); + let withSyntaxError = ` + import { useLoaderData } from "@remix-run/react"; + export function shouldRevalidate(args) { + return true; + } + eport efault functio Index() { + const t = useLoaderData(); + return ( + +

With Syntax Error

+ + ) + } + `; + fs.writeFileSync(indexPath, withSyntaxError); + await wait( + () => + devStderr() + .replace(stderr, "") + .includes('Expected ";" but found "efault"'), + { + timeoutMs: HMR_TIMEOUT_MS, + } + ); + + // React Router integration w/ React Refresh has a bug where sometimes rerenders happen with old UI and new data + // in this case causing `TypeError: Cannot destructure property`. + // Need to fix that bug, but it only shows a harmless console error in the browser in dev + page.removeListener("pageerror", logConsoleError); + // let expectedErrorCount = 0; + let expectDestructureTypeError = expectConsoleError((error) => { + let expectedMessage = new Set([ + // chrome, msedge + "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.", + // firefox + "(intermediate value)() is null", + // webkit + "Right side of assignment cannot be destructured", + ]); + let isExpected = + error.name === "TypeError" && expectedMessage.has(error.message); + // if (isExpected) expectedErrorCount += 1; + return isExpected; + }); + page.on("pageerror", expectDestructureTypeError); + + let withFix = ` + import { useLoaderData } from "@remix-run/react"; + export function shouldRevalidate(args) { + return true; + } + export default function Index() { + // const t = useLoaderData(); + return ( +
+

With Fix

+
+ ) + } + `; + fs.writeFileSync(indexPath, withFix); + await page.waitForLoadState("networkidle"); + await page.getByText("With Fix").waitFor({ timeout: HMR_TIMEOUT_MS }); + + // Restore normal console error handling + page.removeListener("pageerror", expectDestructureTypeError); + // expect(expectedErrorCount).toBe(browserName === "webkit" ? 1 : 2); + page.addListener("pageerror", logConsoleError); + } catch (e) { + console.log("stdout begin -----------------------"); + console.log(devStdout()); + console.log("stdout end -------------------------"); + + console.log("stderr begin -----------------------"); + console.log(devStderr()); + console.log("stderr end -------------------------"); + throw e; + } finally { + devProc.pid && (await killtree(devProc.pid)); + } +} + +let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +let wait = async ( + callback: () => boolean, + { timeoutMs = 1000, intervalMs = 250 } = {} +) => { + let start = Date.now(); + while (Date.now() - start <= timeoutMs) { + if (callback()) { + return; + } + await sleep(intervalMs); + } + throw Error(`wait: timeout ${timeoutMs}ms`); +}; + +let bufferize = (stream: Readable): (() => string) => { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +}; + +let logConsoleError = (error: Error) => { + console.error(`[console] ${error.name}: ${error.message}`); +}; + +let expectConsoleError = ( + isExpected: (error: Error) => boolean, + unexpected = logConsoleError +) => { + return (error: Error) => { + if (isExpected(error)) { + return; + } + unexpected(error); + }; +}; diff --git a/integration/hook-useSubmit-test.ts b/integration/hook-useSubmit-test.ts new file mode 100644 index 0000000000..ec5703eded --- /dev/null +++ b/integration/hook-useSubmit-test.ts @@ -0,0 +1,136 @@ +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("`useSubmit()` returned function", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { useLoaderData, useSubmit } from "@remix-run/react"; + + export function loader({ request }) { + let url = new URL(request.url); + return url.searchParams.toString() + } + + export default function Index() { + let submit = useSubmit(); + let handleClick = event => { + event.preventDefault() + submit(event.nativeEvent.submitter || event.currentTarget) + } + let data = useLoaderData(); + return ( + + + + + + +
{data}
+ + ) + } + `, + "app/routes/action.tsx": js` + import { json } from "@remix-run/node"; + import { useActionData, useSubmit } from "@remix-run/react"; + + export async function action({ request }) { + let contentType = request.headers.get('Content-Type'); + if (contentType.includes('application/json')) { + return json({ value: await request.json() }); + } + if (contentType.includes('text/plain')) { + return json({ value: await request.text() }); + } + let fd = await request.formData(); + return json({ value: new URLSearchParams(fd.entries()).toString() }) + } + + export default function Component() { + let submit = useSubmit(); + let data = useActionData(); + return ( + <> + + + + {data ?

data: {JSON.stringify(data)}

: null} + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("submits the submitter's value appended to the form data", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickElement("text=Prepare Third Task"); + await page.waitForLoadState("load"); + expect(await app.getHtml("pre")).toBe( + `
tasks=first&tasks=second&tasks=third
` + ); + }); + + test("submits json data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action", true); + await app.clickElement("#submit-json"); + await page.waitForSelector("#action-data"); + expect(await app.getHtml()).toMatch('data: {"value":{"key":"value"}}'); + }); + + test("submits text data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action", true); + await app.clickElement("#submit-text"); + await page.waitForSelector("#action-data"); + expect(await app.getHtml()).toMatch('data: {"value":"raw text"}'); + }); + + test("submits form data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action", true); + await app.clickElement("#submit-formData"); + await page.waitForSelector("#action-data"); + expect(await app.getHtml()).toMatch('data: {"value":"key=value"}'); + }); +}); diff --git a/integration/js-routes-test.ts b/integration/js-routes-test.ts new file mode 100644 index 0000000000..cf774bf9b3 --- /dev/null +++ b/integration/js-routes-test.ts @@ -0,0 +1,46 @@ +import { test } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe(".js route files", () => { + let appFixture: AppFixture; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture({ + files: { + "app/routes/js.js": js` + export default () =>
Rendered with .js ext
; + `, + "app/routes/jsx.jsx": js` + export default () =>
Rendered with .jsx ext
; + `, + }, + }) + ); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should render all .js routes", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/js"); + await page.waitForSelector("[data-testid='route-js']"); + test.expect(await page.content()).toContain("Rendered with .js ext"); + }); + + test("should render all .jsx routes", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/jsx"); + await page.waitForSelector("[data-testid='route-jsx']"); + test.expect(await page.content()).toContain("Rendered with .jsx ext"); + }); +}); diff --git a/integration/layout-route-test.ts b/integration/layout-route-test.ts new file mode 100644 index 0000000000..0f43512807 --- /dev/null +++ b/integration/layout-route-test.ts @@ -0,0 +1,66 @@ +import { test } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("pathless layout routes", () => { + let appFixture: AppFixture; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture({ + files: { + "app/routes/_layout.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default () =>
; + `, + "app/routes/_layout._index.tsx": js` + export default () =>
Layout index
; + `, + "app/routes/_layout.subroute.tsx": js` + export default () =>
Layout subroute
; + `, + "app/routes/sandwiches._pathless.tsx": js` + import { Outlet } from "@remix-run/react"; + + export default () =>
; + `, + "app/routes/sandwiches._pathless._index.tsx": js` + export default () =>
Sandwiches pathless index
; + `, + }, + }) + ); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should render pathless index route", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("[data-testid='layout-route']"); + await page.waitForSelector("[data-testid='layout-index']"); + }); + + test("should render pathless sub route", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/subroute"); + await page.waitForSelector("[data-testid='layout-route']"); + await page.waitForSelector("[data-testid='layout-subroute']"); + }); + + test("should render pathless index as a sub route", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/sandwiches"); + await page.waitForSelector("[data-testid='sandwiches-pathless-route']"); + await page.waitForSelector("[data-testid='sandwiches-pathless-index']"); + }); +}); diff --git a/integration/link-test.ts b/integration/link-test.ts new file mode 100644 index 0000000000..10d178627d --- /dev/null +++ b/integration/link-test.ts @@ -0,0 +1,624 @@ +import { test, expect } from "@playwright/test"; + +import { + css, + js, + createFixture, + createAppFixture, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +const fakeGists = [ + { + url: "https://api.github.com/gists/610613b54e5b34f8122d1ba4a3da21a9", + id: "610613b54e5b34f8122d1ba4a3da21a9", + files: { + "remix-server.jsx": { + filename: "remix-server.jsx", + }, + }, + owner: { + login: "ryanflorence", + id: 100200, + avatar_url: "https://avatars0.githubusercontent.com/u/100200?v=4", + }, + }, +]; + +test.describe("route module link export", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/favicon.ico": js``, + + "app/guitar.jpg": js``, + + "app/guitar-600.jpg": js``, + + "app/guitar-900.jpg": js``, + + "app/reset.css": css` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html { + font-size: 16px; + box-sizing: border-box; + } + `, + + "app/app.css": css` + body { + background-color: #eee; + color: #000; + } + `, + + "app/gists.css": css` + * { + color: dodgerblue; + } + `, + + "app/redText.css": css` + * { + color: red; + } + `, + + "app/blueText.css": css` + * { + color: blue; + } + `, + + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useRouteError, + isRouteErrorResponse + } from "@remix-run/react"; + import resetHref from "./reset.css"; + import stylesHref from "./app.css"; + import favicon from "./favicon.ico"; + + export function links() { + return [ + { rel: "stylesheet", href: resetHref }, + { rel: "stylesheet", href: stylesHref }, + { rel: "stylesheet", href: "/resources/theme-css" }, + { rel: "shortcut icon", href: favicon }, + ]; + } + + export let handle = { + breadcrumb: () => Home, + }; + + export default function Root() { + return ( + + + + + + + + + + + + ); + } + + export function ErrorBoundary() { + let error = useRouteError(); + + if (isRouteErrorResponse()) { + switch (error.status) { + case 404: + return ( + + + + 404 Not Found + + + +
+

404 Not Found

+
+ + + + ); + default: + console.warn("Unexpected catch", error); + + return ( + + + + {error.status} Uh-oh! + + + +
+

+ {error.status} {error.statusText} +

+ {error.data ? ( +
+                              {JSON.stringify(error.data, null, 2)}
+                            
+ ) : null} +
+ + + + ); + } + } else { + console.error(error); + return ( + + + + Oops! + + + +
+

App Error Boundary

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

Cool App

+
+ +
+ ); + } + `, + + "app/routes/links.tsx": js` + import { useLoaderData, Link } from "@remix-run/react"; + import redTextHref from "~/redText.css"; + import blueTextHref from "~/blueText.css"; + import guitar from "~/guitar.jpg"; + export async function loader() { + return [ + { name: "Michael Jackson", id: "mjackson" }, + { name: "Ryan Florence", id: "ryanflorence" }, + ]; + } + export function links() { + return [ + { rel: "stylesheet", href: redTextHref }, + { + rel: "stylesheet", + href: blueTextHref, + media: "(prefers-color-scheme: beef)", + }, + { page: "/gists/mjackson" }, + { + rel: "preload", + as: "image", + href: guitar, + }, + ]; + } + export default function LinksPage() { + let users = useLoaderData(); + return ( +
+

Links Page

+ {users.map((user) => ( +
  • + + {user.name} + +
  • + ))} +
    +

    + a guitar Prefetched + because it's a preload. +

    +
    + ); + } + `, + + "app/routes/responsive-image-preload.tsx": js` + import { Link } from "@remix-run/react"; + import guitar600 from "~/guitar-600.jpg"; + import guitar900 from "~/guitar-900.jpg"; + + export function links() { + return [ + { + rel: "preload", + as: "image", + imageSrcSet: guitar600 + " 600w, " + guitar900 + " 900w", + imageSizes: "100vw", + }, + ]; + } + export default function LinksPage() { + return ( +
    +

    Responsive Guitar

    +

    + a guitar{" "} + Prefetched because it's a preload. +

    +
    + ); + } + `, + + "app/routes/gists.tsx": js` + import { json } from "@remix-run/node"; + import { Link, Outlet, useLoaderData, useNavigation } from "@remix-run/react"; + import stylesHref from "~/gists.css"; + export function links() { + return [{ rel: "stylesheet", href: stylesHref }]; + } + export async function loader() { + let data = { + users: [ + { id: "ryanflorence", name: "Ryan Florence" }, + { id: "mjackson", name: "Michael Jackson" }, + ], + }; + return json(data, { + headers: { + "Cache-Control": "public, max-age=60", + }, + }); + } + export function headers({ loaderHeaders }) { + return { + "Cache-Control": loaderHeaders.get("Cache-Control"), + }; + } + export let handle = { + breadcrumb: () => Gists, + }; + export default function Gists() { + let locationPending = useNavigation().location; + let { users } = useLoaderData(); + return ( +
    +
    +

    Gists

    +
      + {users.map((user) => ( +
    • + + {user.name} {locationPending ? "..." : null} + +
    • + ))} +
    +
    + +
    + ); + } + `, + + "app/routes/gists.$username.tsx": js` + import { json, redirect } from "@remix-run/node"; + import { Link, useLoaderData, useParams } from "@remix-run/react"; + export async function loader({ params }) { + let { username } = params; + if (username === "mjijackson") { + return redirect("/gists/mjackson", 302); + } + if (username === "_why") { + return json(null, { status: 404 }); + } + return ${JSON.stringify(fakeGists)}; + } + export function headers() { + return { + "Cache-Control": "public, max-age=300", + }; + } + export function meta({ data, params }) { + let { username } = params; + return [ + { + title: data + ? data.length + " gists from " + username + : "User " + username + " not found", + }, + { name: "description", content: "View all of the gists from " + username }, + ]; + } + export let handle = { + breadcrumb: ({ params }) => ( + {params.username} + ), + }; + export default function UserGists() { + let { username } = useParams(); + let data = useLoaderData(); + return ( +
    + {data ? ( + <> +

    All gists from {username}

    + + + ) : ( +

    No gists for {username}

    + )} +
    + ); + } + `, + + "app/routes/gists._index.tsx": js` + import { useLoaderData } from "@remix-run/react"; + export async function loader() { + return ${JSON.stringify(fakeGists)}; + } + export function headers() { + return { + "Cache-Control": "public, max-age=60", + }; + } + export function meta() { + return [ + { title: "Public Gists" }, + { name: "description", content: "View the latest gists from the public" }, + ]; + } + export let handle = { + breadcrumb: () => Public, + }; + export default function GistsIndex() { + let data = useLoaderData(); + return ( +
    +

    Public Gists

    + +
    + ); + } + `, + + "app/routes/resources.theme-css.tsx": js` + import { redirect } from "@remix-run/node"; + export async function loader({ request }) { + return new Response(":root { --nc-tx-1: #ffffff; --nc-tx-2: #eeeeee; }", + { + headers: { + "Content-Type": "text/css; charset=UTF-8", + "x-has-custom": "yes", + }, + } + ); + } + + `, + + "app/routes/parent.tsx": js` + import { Outlet } from "@remix-run/react"; + + export function links() { + return [ + { "data-test-id": "red" }, + ]; + } + + export default function Component() { + return
    ; + } + + export function ErrorBoundary() { + return

    Error Boundary

    ; + } + `, + + "app/routes/parent.child.tsx": js` + import { Outlet } from "@remix-run/react"; + + export function loader() { + throw new Response(null, { status: 404 }); + } + + export function links() { + return [ + { "data-test-id": "blue" }, + ]; + } + + export default function Component() { + return
    ; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("adds responsive image preload links to the document", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/responsive-image-preload"); + await page.waitForSelector('[data-test-id="/responsive-image-preload"]'); + let locator = page.locator("link[rel=preload][as=image]"); + expect(await locator.getAttribute("imagesizes")).toBe("100vw"); + }); + + test("waits for new styles to load before transitioning", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + let cssResponses = app.collectResponses((url) => + url.pathname.endsWith(".css") + ); + + await page.click('a[href="/gists"]'); + await page.waitForSelector('[data-test-id="/gists/index"]'); + + let stylesheetResponses = cssResponses.filter((res) => { + // ignore prefetches + return res.request().resourceType() === "stylesheet"; + }); + + expect(stylesheetResponses.length).toEqual(1); + }); + + test("does not render errored child route links", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.click('a[href="/parent/child"]'); + await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); + await page.waitForSelector('[data-test-id="red"]', { state: "attached" }); + await page.waitForSelector('[data-test-id="blue"]', { + state: "detached", + }); + }); + + test.describe("no js", () => { + test.use({ javaScriptEnabled: false }); + + test("adds links to the document", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let responses = app.collectResponses((url) => + url.pathname.endsWith(".css") + ); + + await app.goto("/links"); + await page.waitForSelector('[data-test-id="/links"]'); + expect(responses.length).toEqual(4); + }); + + test("adds responsive image preload links to the document", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/responsive-image-preload"); + await page.waitForSelector('[data-test-id="/responsive-image-preload"]'); + let locator = page.locator("link[rel=preload][as=image]"); + expect(await locator.getAttribute("imagesizes")).toBe("100vw"); + }); + + test("does not render errored child route links", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); + await page.waitForSelector('[data-test-id="red"]', { state: "attached" }); + await page.waitForSelector('[data-test-id="blue"]', { + state: "detached", + }); + }); + }); + + test.describe("script imports", () => { + test("are added to the document", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + let scripts = await page.$$("script"); + expect(scripts.length).toEqual(2); + expect(await scripts[0].innerText()).toContain("__remixContext"); + let moduleScript = scripts[1]; + expect(await moduleScript.getAttribute("type")).toBe("module"); + let moduleScriptText = await moduleScript.innerText(); + expect( + Array.from(moduleScriptText.matchAll(/import "\/build\/manifest-/g)), + "invalid build manifest" + ).toHaveLength(1); + expect( + Array.from(moduleScriptText.matchAll(/import \* as route0 from "/g)), + "invalid route0" + ).toHaveLength(1); + expect( + Array.from(moduleScriptText.matchAll(/import \* as route1 from "/g)), + "invalid route1" + ).toHaveLength(1); + expect( + Array.from(moduleScriptText.matchAll(/import \* as route2 from "/g)), + "too many routes" + ).toHaveLength(0); + }); + }); +}); diff --git a/integration/loader-test.ts b/integration/loader-test.ts new file mode 100644 index 0000000000..86e64def00 --- /dev/null +++ b/integration/loader-test.ts @@ -0,0 +1,277 @@ +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("loader", () => { + let fixture: Fixture; + + let ROOT_DATA = "ROOT_DATA"; + let INDEX_DATA = "INDEX_DATA"; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export const loader = () => json("${ROOT_DATA}"); + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + + export function loader() { + return "${INDEX_DATA}" + } + + export default function Index() { + return
    + } + `, + }, + }); + }); + + test("returns responses for a specific route", async () => { + let [root, index] = await Promise.all([ + fixture.requestData("/", "root"), + fixture.requestData("/", "routes/_index"), + ]); + + expect(root.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8" + ); + + expect(await root.json()).toBe(ROOT_DATA); + expect(await index.json()).toBe(INDEX_DATA); + }); +}); + +test.describe("loader in an app", () => { + let appFixture: AppFixture; + + let HOME_PAGE_TEXT = "hello world"; + let REDIRECT_TARGET_TEXT = "redirect target"; + let FETCH_TARGET_TEXT = "fetch target"; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture({ + files: { + "app/root.tsx": js` + import { Outlet } from '@remix-run/react' + + export default function Root() { + return ( + + + ${HOME_PAGE_TEXT} + + + + ); + } + `, + "app/routes/redirect.tsx": js` + import { redirect } from "@remix-run/node"; + export const loader = () => redirect("/redirect-target"); + export default () =>
    Yo
    + `, + "app/routes/redirect-target.tsx": js` + export default () =>
    ${REDIRECT_TARGET_TEXT}
    + `, + "app/routes/fetch.tsx": js` + export function loader({ request }) { + return fetch(new URL(request.url).origin + '/fetch-target'); + } + `, + + "app/routes/fetch-target.tsx": js` + import { json } from "@remix-run/node"; + + export function loader() { + return json({ message: "${FETCH_TARGET_TEXT}" }) + } + `, + }, + }) + ); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("sends a redirect", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + expect(await app.getHtml()).toMatch(HOME_PAGE_TEXT); + expect(await app.getHtml()).toMatch(REDIRECT_TARGET_TEXT); + }); + + test("handles raw fetch responses", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto(`/fetch`); + expect((await res.json()).message).toBe(FETCH_TARGET_TEXT); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("loader", () => { + let fixture: Fixture; + + let ROOT_DATA = "ROOT_DATA"; + let INDEX_DATA = "INDEX_DATA"; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export const loader = () => json("${ROOT_DATA}"); + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + + export function loader() { + return "${INDEX_DATA}" + } + + export default function Index() { + return
    + } + `, + }, + }); + }); + + test("returns responses for single fetch routes", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { data: ROOT_DATA }, + "routes/_index": { data: INDEX_DATA }, + }); + }); + }); + + test.describe("loader in an app", () => { + let appFixture: AppFixture; + + let HOME_PAGE_TEXT = "hello world"; + let REDIRECT_TARGET_TEXT = "redirect target"; + let FETCH_TARGET_TEXT = "fetch target"; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Outlet } from '@remix-run/react' + + export default function Root() { + return ( + + + ${HOME_PAGE_TEXT} + + + + ); + } + `, + "app/routes/redirect.tsx": js` + import { redirect } from "@remix-run/node"; + export const loader = () => redirect("/redirect-target"); + export default () =>
    Yo
    + `, + "app/routes/redirect-target.tsx": js` + export default () =>
    ${REDIRECT_TARGET_TEXT}
    + `, + "app/routes/fetch.tsx": js` + export function loader({ request }) { + return fetch(new URL(request.url).origin + '/fetch-target'); + } + `, + + "app/routes/fetch-target.tsx": js` + import { json } from "@remix-run/node"; + + export function loader() { + return json({ message: "${FETCH_TARGET_TEXT}" }) + } + `, + }, + }) + ); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("sends a redirect", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + expect(await app.getHtml()).toMatch(HOME_PAGE_TEXT); + expect(await app.getHtml()).toMatch(REDIRECT_TARGET_TEXT); + }); + + test("handles raw fetch responses", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto(`/fetch`); + expect((await res.json()).message).toBe(FETCH_TARGET_TEXT); + }); + }); +}); diff --git a/integration/matches-test.ts b/integration/matches-test.ts new file mode 100644 index 0000000000..34cbb4c0b2 --- /dev/null +++ b/integration/matches-test.ts @@ -0,0 +1,200 @@ +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("useMatches", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import * as React from 'react'; + import { json } from "@remix-run/node"; + import { Link, Links, Meta, Outlet, Scripts, useMatches } from "@remix-run/react"; + export const handle = { stuff: "root handle"}; + export const loader = () => json("ROOT"); + export default function Root() { + let matches = useMatches(); + let [matchesCount, setMatchesCount] = React.useState(0); + React.useEffect(() => setMatchesCount(matchesCount + 1), [matches]); + + return ( + + + + + + + About +
    +                    {JSON.stringify(matches, null, 2)}
    +                  
    + {matchesCount > 0 ?
    {matchesCount}
    : null} + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + export const handle = { stuff: "index handle"}; + export const loader = () => json("INDEX"); + export default function Index() { + return

    Index Page

    + } + `, + + "app/routes/about.tsx": js` + import { json } from "@remix-run/node"; + export const handle = { stuff: "about handle"}; + export const loader = async () => { + await new Promise(r => setTimeout(r, 100)); + return json("ABOUT"); + } + export default function About() { + return

    About Page

    + } + `, + + "app/routes/count.tsx": js` + import * as React from 'react'; + import { useMatches } from "@remix-run/react"; + export default function Count() { + let matches = useMatches(); + let [count, setCount] = React.useState(0); + let [matchesCount, setMatchesCount] = React.useState(0); + React.useEffect(() => setMatchesCount(matchesCount + 1), [matches]); + return ( + <> +

    Count Page

    + +
    {count}
    + {matchesCount > 0 ?
    {matchesCount}
    : null} + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("grabs the handle from the route module cache", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Wait for effect + await page.waitForSelector("#matches-count-root"); + expect(await app.getHtml("#matches-count-root")).toMatch(">1<"); + expect(await app.getHtml()).toMatch("Index Page"); + expect(await app.getHtml("#matches")).toEqual(`
    +[
    +  {
    +    "id": "root",
    +    "pathname": "/",
    +    "params": {},
    +    "data": "ROOT",
    +    "handle": {
    +      "stuff": "root handle"
    +    }
    +  },
    +  {
    +    "id": "routes/_index",
    +    "pathname": "/",
    +    "params": {},
    +    "data": "INDEX",
    +    "handle": {
    +      "stuff": "index handle"
    +    }
    +  }
    +]
    `); + + // Click and don't wait so we can assert _during_ the navigation that we're + // still showing the index matches and we haven't triggered a new effect + await app.clickLink("/about", { wait: false }); + expect(await app.getHtml("#matches")).toEqual(`
    +[
    +  {
    +    "id": "root",
    +    "pathname": "/",
    +    "params": {},
    +    "data": "ROOT",
    +    "handle": {
    +      "stuff": "root handle"
    +    }
    +  },
    +  {
    +    "id": "routes/_index",
    +    "pathname": "/",
    +    "params": {},
    +    "data": "INDEX",
    +    "handle": {
    +      "stuff": "index handle"
    +    }
    +  }
    +]
    `); + expect(await app.getHtml("#matches-count-root")).toMatch(">1<"); + + // Once the new page shows up we should get update dmatches and a single + // new effect execution + await page.waitForSelector("#about"); + expect(await app.getHtml()).toMatch("About Page"); + expect(await app.getHtml("#matches-count-root")).toMatch(">2<"); + expect(await app.getHtml("#matches")).toEqual(`
    +[
    +  {
    +    "id": "root",
    +    "pathname": "/",
    +    "params": {},
    +    "data": "ROOT",
    +    "handle": {
    +      "stuff": "root handle"
    +    }
    +  },
    +  {
    +    "id": "routes/about",
    +    "pathname": "/about",
    +    "params": {},
    +    "data": "ABOUT",
    +    "handle": {
    +      "stuff": "about handle"
    +    }
    +  }
    +]
    `); + }); + + test("memoizes matches from react router", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/count"); + await page.waitForSelector("#matches-count-child"); + expect(await app.getHtml("#count")).toMatch(">0<"); + expect(await app.getHtml("#matches-count-child")).toMatch(">1<"); + await app.clickElement("#increment"); + expect(await app.getHtml("#count")).toMatch(">1<"); + expect(await app.getHtml("#matches-count-child")).toMatch(">1<"); + await app.clickElement("#increment"); + expect(await app.getHtml("#count")).toMatch(">2<"); + expect(await app.getHtml("#matches-count-child")).toMatch(">1<"); + }); +}); diff --git a/integration/mdx-test.ts b/integration/mdx-test.ts new file mode 100644 index 0000000000..22050a618c --- /dev/null +++ b/integration/mdx-test.ts @@ -0,0 +1,130 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, + mdx, + css, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("mdx", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/blog.tsx": js` + import { useMatches, Outlet } from "@remix-run/react"; + + export default function Blog() { + const matches = useMatches(); + const mdxMatch = matches[matches.length - 1]; + return ( +
    +

    {mdxMatch.data.additionalData === 10 && 'Additional Data: 10'}

    +

    {mdxMatch.handle.someData}

    + +
    + ); + } + `, + + "app/routes/blog.post.mdx": mdx`--- +meta: +- title: My First Post +- name: description + content: Isn't this awesome? +handle: + someData: abc +headers: + Cache-Control: no-cache +--- + +import stylesheetHref from "../app.css" + +export const links = () => [ + { rel: "stylesheet", href: stylesheetHref } +] + +import { useLoaderData } from '@remix-run/react'; + +export const loader = async () => { + return { mamboNumber: 5 }; +}; + +export function ComponentUsingData() { + const { mamboNumber } = useLoaderData(); + + return
    Mambo Number: {mamboNumber}
    ; +} + +# This is some markdown! + + + `.trim(), + + "app/routes/basic.mdx": mdx` +# This is some basic markdown! + `.trim(), + + "app/app.css": css` + body { + background-color: #eee; + color: #000; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("can render basic markdown", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/basic"); + + expect(await app.getHtml()).toMatch("This is some basic markdown!"); + }); + + test("supports links, meta, headers, handle, and loader", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/post"); + expect(await app.getHtml('meta[name="description"]')).toMatch( + "Isn't this awesome?" + ); + expect(await app.getHtml("title")).toMatch("My First Post"); + expect(await app.getHtml("#loader")).toMatch(/Mambo Number:.+5/s); + expect(await app.getHtml("#handle")).toMatch("abc"); + expect(await app.getHtml('link[rel="stylesheet"]')).toMatch( + /app-[\dA-Z]+\.css/ + ); + }); +}); diff --git a/integration/multiple-cookies-test.ts b/integration/multiple-cookies-test.ts new file mode 100644 index 0000000000..d1d3bb88b9 --- /dev/null +++ b/integration/multiple-cookies-test.ts @@ -0,0 +1,79 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("pathless layout routes", () => { + let appFixture: AppFixture; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { redirect, json } from "@remix-run/node"; + import { Form, useActionData } from "@remix-run/react"; + + export let loader = async () => { + let headers = new Headers(); + headers.append("Set-Cookie", "foo=bar"); + headers.append("Set-Cookie", "bar=baz"); + return json({}, { headers }); + }; + + export let action = async () => { + let headers = new Headers(); + headers.append("Set-Cookie", "another=one"); + headers.append("Set-Cookie", "how-about=two"); + return json({success: true}, { headers }); + }; + + export default function MultipleSetCookiesPage() { + let actionData = useActionData(); + return ( + <> +

    👋

    +
    + +
    + {actionData?.success &&

    Success!

    } + + ); + }; + `, + }, + }) + ); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should get multiple cookies from the loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let responses = app.collectResponses((url) => url.pathname === "/"); + await app.goto("/"); + let setCookies = await responses[0].headerValues("set-cookie"); + expect(setCookies).toEqual(["foo=bar", "bar=baz"]); + expect(responses).toHaveLength(1); + }); + + test("should get multiple cookies from the action", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + // do this after the first request so that it doesnt appear in our next assertions + let responses = app.collectResponses((url) => url.pathname === "/"); + await page.click("button[type=submit]"); + await page.waitForSelector(`[data-testid="action-success"]`); + let setCookies = await responses[0].headerValues("set-cookie"); + expect(setCookies).toEqual(["another=one", "how-about=two"]); + // one for the POST and one for the GET + expect(responses).toHaveLength(2); + }); +}); diff --git a/integration/navigation-state-test.ts b/integration/navigation-state-test.ts new file mode 100644 index 0000000000..01902e4d55 --- /dev/null +++ b/integration/navigation-state-test.ts @@ -0,0 +1,920 @@ +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"; + +const STATES = { + NORMAL_LOAD: "normal-load", + LOADING_REDIRECT: "loading-redirect", + SUBMITTING_LOADER: "submitting-loader", + SUBMITTING_LOADER_REDIRECT: "submitting-loader-redirect", + SUBMITTING_ACTION: "submitting-action", + SUBMITTING_ACTION_REDIRECT: "submitting-action-redirect", + FETCHER_REDIRECT: "fetcher-redirect", +} as const; + +const IDLE_STATE = { + state: "idle", +}; + +test.describe("navigation states", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeEach(async ({ context }) => { + await context.route(/_data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { useMemo, useRef } from "react"; + import { Outlet, Scripts, useNavigation } from "@remix-run/react"; + export default function() { + const navigation = useNavigation(); + const navigationsRef = useRef(); + const navigations = useMemo(() => { + const savedNavigations = navigationsRef.current || []; + savedNavigations.push(navigation); + navigationsRef.current = savedNavigations; + return savedNavigations; + }, [navigation]); + return ( + + Test + + + {navigation.state != "idle" && ( +

    Loading...

    + )} +

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

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

    + ${STATES.NORMAL_LOAD} +

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

    + ${STATES.LOADING_REDIRECT} +

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

    + ${STATES.SUBMITTING_LOADER} +

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

    + ${STATES.SUBMITTING_LOADER_REDIRECT} +

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

    + ${STATES.SUBMITTING_ACTION} +

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

    + ${STATES.SUBMITTING_ACTION_REDIRECT} +

    + ); + } + `, + [`app/routes/${STATES.FETCHER_REDIRECT}.jsx`]: js` + import { redirect } from "@remix-run/node"; + export function action() { + return redirect("/?redirected"); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("normal load (Loading)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickLink(`/${STATES.NORMAL_LOAD}`); + await page.waitForSelector(`#${STATES.NORMAL_LOAD}`); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: `/${STATES.NORMAL_LOAD}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + }, + IDLE_STATE, + ]); + }); + + test("normal redirect (LoadingRedirect)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickLink(`/${STATES.LOADING_REDIRECT}`); + await page.waitForURL(/\?redirected/); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: `/${STATES.LOADING_REDIRECT}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + }, + { + state: "loading", + location: { + pathname: "/", + search: "?redirected", + hash: "", + state: { + _isRedirect: true, + }, + key: expect.any(String), + }, + }, + IDLE_STATE, + ]); + }); + + test("loader submission (SubmittingLoader)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.SUBMITTING_LOADER}`); + await page.waitForSelector(`#${STATES.SUBMITTING_LOADER}`); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: `/${STATES.SUBMITTING_LOADER}`, + search: "?key=value", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "GET", + formAction: `/${STATES.SUBMITTING_LOADER}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + IDLE_STATE, + ]); + }); + + test("loader submission redirect (LoadingLoaderSubmissionRedirect)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.SUBMITTING_LOADER_REDIRECT}`); + await page.waitForURL(/\?redirected/); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: `/${STATES.SUBMITTING_LOADER_REDIRECT}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "GET", + formAction: `/${STATES.SUBMITTING_LOADER_REDIRECT}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + { + state: "loading", + location: { + pathname: "/", + search: "?redirected", + hash: "", + state: { + _isRedirect: true, + }, + key: expect.any(String), + }, + formMethod: "GET", + formAction: `/${STATES.SUBMITTING_LOADER_REDIRECT}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + IDLE_STATE, + ]); + }); + + test("action submission (SubmittingAction)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.SUBMITTING_ACTION}`); + await page.waitForSelector(`#${STATES.SUBMITTING_ACTION}`); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "submitting", + location: { + pathname: `/${STATES.SUBMITTING_ACTION}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "POST", + formAction: `/${STATES.SUBMITTING_ACTION}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + { + state: "loading", + location: { + pathname: `/${STATES.SUBMITTING_ACTION}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "POST", + formAction: `/${STATES.SUBMITTING_ACTION}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + IDLE_STATE, + ]); + }); + + test("action submission redirect (LoadingActionRedirect)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.SUBMITTING_ACTION_REDIRECT}`); + await page.waitForURL(/\?redirected/); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "submitting", + location: { + pathname: `/${STATES.SUBMITTING_ACTION_REDIRECT}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "POST", + formAction: `/${STATES.SUBMITTING_ACTION_REDIRECT}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + { + state: "loading", + location: { + pathname: "/", + search: "?redirected", + hash: "", + state: { + _isRedirect: true, + }, + key: expect.any(String), + }, + formMethod: "POST", + formAction: `/${STATES.SUBMITTING_ACTION_REDIRECT}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + IDLE_STATE, + ]); + }); + + test("fetcher action submission redirect (LoadingFetchActionRedirect)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.FETCHER_REDIRECT}`); + await page.waitForURL(/\?redirected/); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: "/", + search: "?redirected", + hash: "", + state: { + _isRedirect: true, + }, + key: expect.any(String), + }, + }, + IDLE_STATE, + ]); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("navigation states", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeEach(async ({ context }) => { + await context.route(/_data/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { useMemo, useRef } from "react"; + import { Outlet, Scripts, useNavigation } from "@remix-run/react"; + export default function() { + const navigation = useNavigation(); + const navigationsRef = useRef(); + const navigations = useMemo(() => { + const savedNavigations = navigationsRef.current || []; + savedNavigations.push(navigation); + navigationsRef.current = savedNavigations; + return savedNavigations; + }, [navigation]); + return ( + + Test + + + {navigation.state != "idle" && ( +

    Loading...

    + )} +

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

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

    + ${STATES.NORMAL_LOAD} +

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

    + ${STATES.LOADING_REDIRECT} +

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

    + ${STATES.SUBMITTING_LOADER} +

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

    + ${STATES.SUBMITTING_LOADER_REDIRECT} +

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

    + ${STATES.SUBMITTING_ACTION} +

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

    + ${STATES.SUBMITTING_ACTION_REDIRECT} +

    + ); + } + `, + [`app/routes/${STATES.FETCHER_REDIRECT}.jsx`]: js` + import { redirect } from "@remix-run/node"; + export function action() { + return redirect("/?redirected"); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("normal load (Loading)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickLink(`/${STATES.NORMAL_LOAD}`); + await page.waitForSelector(`#${STATES.NORMAL_LOAD}`); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: `/${STATES.NORMAL_LOAD}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + }, + IDLE_STATE, + ]); + }); + + test("normal redirect (LoadingRedirect)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickLink(`/${STATES.LOADING_REDIRECT}`); + await page.waitForURL(/\?redirected/); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: `/${STATES.LOADING_REDIRECT}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + }, + { + state: "loading", + location: { + pathname: "/", + search: "?redirected", + hash: "", + state: { + _isRedirect: true, + }, + key: expect.any(String), + }, + }, + IDLE_STATE, + ]); + }); + + test("loader submission (SubmittingLoader)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.SUBMITTING_LOADER}`); + await page.waitForSelector(`#${STATES.SUBMITTING_LOADER}`); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: `/${STATES.SUBMITTING_LOADER}`, + search: "?key=value", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "GET", + formAction: `/${STATES.SUBMITTING_LOADER}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + IDLE_STATE, + ]); + }); + + test("loader submission redirect (LoadingLoaderSubmissionRedirect)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.SUBMITTING_LOADER_REDIRECT}`); + await page.waitForURL(/\?redirected/); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: `/${STATES.SUBMITTING_LOADER_REDIRECT}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "GET", + formAction: `/${STATES.SUBMITTING_LOADER_REDIRECT}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + { + state: "loading", + location: { + pathname: "/", + search: "?redirected", + hash: "", + state: { + _isRedirect: true, + }, + key: expect.any(String), + }, + formMethod: "GET", + formAction: `/${STATES.SUBMITTING_LOADER_REDIRECT}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + IDLE_STATE, + ]); + }); + + test("action submission (SubmittingAction)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.SUBMITTING_ACTION}`); + await page.waitForSelector(`#${STATES.SUBMITTING_ACTION}`); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "submitting", + location: { + pathname: `/${STATES.SUBMITTING_ACTION}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "POST", + formAction: `/${STATES.SUBMITTING_ACTION}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + { + state: "loading", + location: { + pathname: `/${STATES.SUBMITTING_ACTION}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "POST", + formAction: `/${STATES.SUBMITTING_ACTION}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + IDLE_STATE, + ]); + }); + + test("action submission redirect (LoadingActionRedirect)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.SUBMITTING_ACTION_REDIRECT}`); + await page.waitForURL(/\?redirected/); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "submitting", + location: { + pathname: `/${STATES.SUBMITTING_ACTION_REDIRECT}`, + search: "", + hash: "", + state: null, + key: expect.any(String), + }, + formMethod: "POST", + formAction: `/${STATES.SUBMITTING_ACTION_REDIRECT}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + { + state: "loading", + location: { + pathname: "/", + search: "?redirected", + hash: "", + state: { + _isRedirect: true, + }, + key: expect.any(String), + }, + formMethod: "POST", + formAction: `/${STATES.SUBMITTING_ACTION_REDIRECT}`, + formEncType: "application/x-www-form-urlencoded", + formData: expect.any(Object), + }, + IDLE_STATE, + ]); + }); + + test("fetcher action submission redirect (LoadingFetchActionRedirect)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickSubmitButton(`/${STATES.FETCHER_REDIRECT}`); + await page.waitForURL(/\?redirected/); + await page.waitForSelector("#loading-indicator", { state: "hidden" }); + let navigationsCode = await app.getElement("#navigations"); + let navigationsJson = navigationsCode.text(); + let navigations = JSON.parse(navigationsJson); + expect(navigations).toEqual([ + IDLE_STATE, + { + state: "loading", + location: { + pathname: "/", + search: "?redirected", + hash: "", + state: { + _isRedirect: true, + }, + key: expect.any(String), + }, + }, + IDLE_STATE, + ]); + }); + }); +}); diff --git a/integration/package.json b/integration/package.json new file mode 100644 index 0000000000..cb20fec7e6 --- /dev/null +++ b/integration/package.json @@ -0,0 +1,49 @@ +{ + "name": "integration", + "version": "0.0.0", + "private": true, + "description": "deps needed for integration tests", + "type": "module", + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "@cloudflare/kv-asset-handler": "^0.3.0", + "@cloudflare/workers-types": "^4.20230518.0", + "@playwright/test": "^1.33.0", + "@remix-run/dev": "workspace:*", + "@remix-run/express": "workspace:*", + "@remix-run/node": "workspace:*", + "@remix-run/router": "1.16.0-pre.0", + "@remix-run/server-runtime": "workspace:*", + "@types/express": "^4.17.9", + "@vanilla-extract/css": "^1.10.0", + "@vanilla-extract/vite-plugin": "^3.9.2", + "cheerio": "^1.0.0-rc.12", + "cross-spawn": "^7.0.3", + "dedent": "^0.7.0", + "execa": "^5.1.1", + "express": "^4.17.1", + "fs-extra": "^10.0.0", + "get-port": "^5.1.1", + "glob": "8.0.3", + "globby": "^11.1.0", + "isbot": "^4.1.0", + "npm-run-all": "^4.1.5", + "pidtree": "^0.6.0", + "postcss": "^8.4.19", + "postcss-import": "^15.1.0", + "prettier": "^2.7.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "serialize-javascript": "^6.0.1", + "shelljs": "^0.8.5", + "strip-ansi": "^6.0.1", + "strip-indent": "^3.0.0", + "tailwindcss": "^3.3.0", + "type-fest": "^4.0.0", + "typescript": "^5.1.0", + "vite-tsconfig-paths": "^4.2.2", + "wrangler": "^3.28.2" + } +} diff --git a/integration/path-mapping-test.ts b/integration/path-mapping-test.ts new file mode 100644 index 0000000000..74b9479506 --- /dev/null +++ b/integration/path-mapping-test.ts @@ -0,0 +1,117 @@ +import { test, expect } from "@playwright/test"; + +import { createFixture, js, json, mdx } from "./helpers/create-fixture.js"; +import type { Fixture } from "./helpers/create-fixture.js"; + +let fixture: Fixture; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/components/my-lib/index.ts": js` + export const pizza = "this is a pizza"; + `, + + "app/routes/_index.tsx": js` + import { pizza } from "@mylib"; + 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} +
    + ) + } + `, + + "app/routes/tilde-alias.tsx": js` + import { pizza } from "~/components/my-lib"; + 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} +
    + ) + } + `, + + "app/components/component.tsx": js` + export function PizzaComponent() { + return this is a pizza + } + `, + + "app/routes/mdx.mdx": mdx` + --- + meta: + title: My First Post + description: Isn't this awesome? + headers: + Cache-Control: no-cache + --- + + import { PizzaComponent } from "@component"; + + # Hello MDX! + + This is my first post. + + + `, + + "tsconfig.json": json({ + include: ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + compilerOptions: { + lib: ["DOM", "DOM.Iterable", "ES2022"], + isolatedModules: true, + esModuleInterop: true, + jsx: "react-jsx", + moduleResolution: "node", + resolveJsonModule: true, + target: "ES2022", + strict: true, + baseUrl: ".", + paths: { + "~/*": ["./app/*"], + "@mylib": ["./app/components/my-lib/index"], + "@component": ["./app/components/component.tsx"], + }, + + // Remix takes care of building everything in \`remix build\`. + noEmit: true, + }, + }), + }, + }); +}); + +test("import internal library via alias other than ~", async () => { + // test for https://github.com/remix-run/remix/issues/2298 + let response = await fixture.requestDocument("/"); + expect(await response.text()).toMatch("this is a pizza"); +}); + +test("import internal library via ~ alias", async () => { + let response = await fixture.requestDocument("/tilde-alias"); + expect(await response.text()).toMatch("this is a pizza"); +}); + +test("works for mdx files", async () => { + let response = await fixture.requestDocument("/mdx"); + expect(await response.text()).toMatch("this is a pizza"); +}); diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts new file mode 100644 index 0000000000..62c3e39cfd --- /dev/null +++ b/integration/playwright.config.ts @@ -0,0 +1,44 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; +import { devices } from "@playwright/test"; + +const config: PlaywrightTestConfig = { + testDir: ".", + testMatch: ["**/*-test.ts"], + /* Maximum time one test can run for. */ + timeout: process.platform === "win32" ? 60_000 : 30_000, + fullyParallel: true, + expect: { + /* Maximum time expect() should wait for the condition to be met. */ + timeout: 5_000, + }, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 3 : 0, + reporter: process.env.CI ? "dot" : [["html", { open: "never" }]], + use: { actionTimeout: 0 }, + + projects: [ + { + name: "chromium", + use: devices["Desktop Chrome"], + }, + { + name: "webkit", + use: devices["Desktop Safari"], + }, + { + name: "msedge", + use: { + ...devices["Desktop Edge"], + // Desktop Edge uses chromium by default + // https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json#L1502 + channel: "msedge", + }, + }, + { + name: "firefox", + use: devices["Desktop Firefox"], + }, + ], +}; + +export default config; diff --git a/integration/postcss-test.ts b/integration/postcss-test.ts new file mode 100644 index 0000000000..fa13186e86 --- /dev/null +++ b/integration/postcss-test.ts @@ -0,0 +1,457 @@ +import { test, expect } from "@playwright/test"; +import type { Page } 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"; + +async function jsonFromBase64CssContent({ + page, + testId, +}: { + page: Page; + testId: string; +}) { + let locator = await page.locator(`[data-testid=${testId}]`); + let content = await locator.evaluate( + (el) => getComputedStyle(el, ":after").content + ); + let json = Buffer.from(content.replace(/"/g, ""), "base64").toString("utf-8"); + return JSON.parse(json); +} + +test.describe("PostCSS enabled", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "package.json": json({ + name: "remix-template-remix", + private: true, + sideEffects: false, + type: "module", + dependencies: { + "@remix-run/css-bundle": "0.0.0-local-version", + "@remix-run/node": "0.0.0-local-version", + "@remix-run/react": "0.0.0-local-version", + "@remix-run/serve": "0.0.0-local-version", + isbot: "0.0.0-local-version", + react: "0.0.0-local-version", + "react-dom": "0.0.0-local-version", + }, + devDependencies: { + "@remix-run/dev": "0.0.0-local-version", + "@types/react": "0.0.0-local-version", + "@types/react-dom": "0.0.0-local-version", + typescript: "0.0.0-local-version", + + "@vanilla-extract/css": "0.0.0-local-version", + tailwindcss: "0.0.0-local-version", + }, + engines: { + node: ">=18.0.0", + }, + }), + + // We provide a test plugin that replaces the strings + // "TEST_PADDING_VALUE" and "TEST_POSTCSS_CONTEXT". + // This lets us assert that the plugin is being run + // and that the correct context values are provided. + "postcss.config.cjs": js` + module.exports = (ctx) => ({ + plugins: [ + { + postcssPlugin: 'replace', + Declaration (decl) { + decl.value = decl.value + .replace( + /TEST_PADDING_VALUE/g, + ${JSON.stringify(TEST_PADDING_VALUE)}, + ) + .replace( + /TEST_POSTCSS_CONTEXT/g, + Buffer.from(JSON.stringify(ctx)).toString("base64"), + ); + }, + }, + ], + }); + `, + "tailwind.config.js": js` + export default { + content: ["./app/**/*.{ts,tsx,jsx,js}"], + theme: { + spacing: { + 'test': ${JSON.stringify(TEST_PADDING_VALUE)} + }, + }, + }; + `, + "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 ( + + + + + + + + + ) + } + `, + ...regularStylesSheetsFixture(), + ...cssModulesFixture(), + ...vanillaExtractFixture(), + ...cssSideEffectImportsFixture(), + ...automaticTailwindPluginInsertionFixture(), + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => appFixture.close()); + + let regularStylesSheetsFixture = () => ({ + "app/routes/regular-style-sheets-test.tsx": js` + import { Test, links as testLinks } from "~/test-components/regular-style-sheets"; + + export function links() { + return [...testLinks()]; + } + + export default function() { + return ; + } + `, + "app/test-components/regular-style-sheets/index.tsx": js` + import stylesHref from "./styles.css"; + + export function links() { + return [{ rel: 'stylesheet', href: stylesHref }]; + } + + export function Test() { + return ( +
    +

    Regular style sheets test.

    +

    PostCSS context (base64):

    +
    + ); + } + `, + "app/test-components/regular-style-sheets/styles.css": css` + .regular-style-sheets-test { + padding: TEST_PADDING_VALUE; + } + + [data-testid="regular-style-sheets-postcss-context"]:after { + content: "TEST_POSTCSS_CONTEXT"; + } + `, + }); + test("regular style sheets", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/regular-style-sheets-test"); + let locator = await page.locator("[data-testid='regular-style-sheets']"); + let padding = await locator.evaluate((el) => getComputedStyle(el).padding); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + test("regular style sheets PostCSS context", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/regular-style-sheets-test"); + let testId = "regular-style-sheets-postcss-context"; + let postcssContext = await jsonFromBase64CssContent({ page, testId }); + expect(postcssContext.remix).toEqual({ + vanillaExtract: false, + }); + }); + + let cssModulesFixture = () => ({ + "app/routes/css-modules-test.tsx": js` + import { Test } from "~/test-components/css-modules"; + + export default function() { + return ; + } + `, + "app/test-components/css-modules/index.tsx": js` + import styles from "./styles.module.css"; + + export function Test() { + return ( +
    +

    CSS Modules test.

    +

    PostCSS context (base64):

    +
    + ); + } + `, + "app/test-components/css-modules/styles.module.css": css` + .root { + padding: TEST_PADDING_VALUE; + } + + [data-testid="css-modules-postcss-context"]:after { + content: "TEST_POSTCSS_CONTEXT"; + } + `, + }); + test("CSS Modules", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/css-modules-test"); + let locator = await page.locator("[data-testid='css-modules']"); + let padding = await locator.evaluate((el) => getComputedStyle(el).padding); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + test("CSS Modules PostCSS context", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/css-modules-test"); + let testId = "css-modules-postcss-context"; + let postcssContext = await jsonFromBase64CssContent({ page, testId }); + expect(postcssContext.remix).toEqual({ + vanillaExtract: false, + }); + }); + + let vanillaExtractFixture = () => ({ + "app/routes/vanilla-extract-test.tsx": js` + import { Test } from "~/test-components/vanilla-extract"; + + export default function() { + return ; + } + `, + "app/test-components/vanilla-extract/index.tsx": js` + import * as styles from "./styles.css"; + + export function Test() { + return ( +
    +

    Vanilla Extract test.

    +

    PostCSS context (base64):

    +
    + ); + } + `, + "app/test-components/vanilla-extract/styles.css.ts": js` + import { style, globalStyle } from "@vanilla-extract/css"; + + export const root = style({ + padding: "TEST_PADDING_VALUE", + }); + + globalStyle('[data-testid="vanilla-extract-postcss-context"]:after', { + content: "TEST_POSTCSS_CONTEXT", + }) + `, + }); + test("Vanilla Extract", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/vanilla-extract-test"); + let locator = await page.locator("[data-testid='vanilla-extract']"); + let padding = await locator.evaluate((el) => getComputedStyle(el).padding); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + test("Vanilla Extract PostCSS context", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/vanilla-extract-test"); + let testId = "vanilla-extract-postcss-context"; + let postcssContext = await jsonFromBase64CssContent({ page, testId }); + expect(postcssContext.remix).toEqual({ + vanillaExtract: true, + }); + }); + + let cssSideEffectImportsFixture = () => ({ + "app/routes/css-side-effect-imports-test.tsx": js` + import { Test } from "~/test-components/css-side-effect-imports"; + + export default function() { + return ; + } + `, + "app/test-components/css-side-effect-imports/index.tsx": js` + import "./styles.css"; + + export function Test() { + return ( +
    +

    CSS side-effect imports test.

    +

    PostCSS context (base64):

    +
    + ); + } + `, + "app/test-components/css-side-effect-imports/styles.css": css` + .css-side-effect-imports-test { + padding: TEST_PADDING_VALUE; + } + + [data-testid="css-side-effect-imports-postcss-context"]:after { + content: "TEST_POSTCSS_CONTEXT"; + } + `, + }); + test("CSS side-effect imports", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/css-side-effect-imports-test"); + let locator = await page.locator("[data-testid='css-side-effect-imports']"); + let padding = await locator.evaluate((el) => getComputedStyle(el).padding); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + test("CSS side-effect imports PostCSS context", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/css-side-effect-imports-test"); + let testId = "css-side-effect-imports-postcss-context"; + let postcssContext = await jsonFromBase64CssContent({ page, testId }); + expect(postcssContext.remix).toEqual({ + vanillaExtract: false, + }); + }); + + let automaticTailwindPluginInsertionFixture = () => ({ + "app/routes/automatic-tailwind-plugin-insertion-test.tsx": js` + import { Test, links as testLinks } from "~/test-components/automatic-tailwind-plugin-insertion"; + + export function links() { + return [...testLinks()]; + } + + export default function() { + return ; + } + `, + "app/test-components/automatic-tailwind-plugin-insertion/index.tsx": js` + import stylesHref from "./styles.css"; + + export function links() { + return [{ rel: 'stylesheet', href: stylesHref }]; + } + + export function Test() { + return ( +
    + Automatic Tailwind plugin insertion test +
    + ); + } + `, + "app/test-components/automatic-tailwind-plugin-insertion/styles.css": css` + .automatic-tailwind-plugin-insertion-test { + @apply p-test; + } + `, + }); + test("automatic Tailwind plugin insertion", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/automatic-tailwind-plugin-insertion-test"); + let locator = await page.locator( + "[data-testid='automatic-tailwind-plugin-insertion']" + ); + let padding = await locator.evaluate((el) => getComputedStyle(el).padding); + expect(padding).toBe(TEST_PADDING_VALUE); + }); +}); + +test.describe("PostCSS disabled", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + postcss: false, + }, + files: { + "postcss.config.cjs": js` + module.exports = (ctx) => ({ + plugins: [ + { + postcssPlugin: 'replace', + Declaration (decl) { + decl.value = decl.value + .replace( + /TEST_PADDING_VALUE/g, + ${JSON.stringify(TEST_PADDING_VALUE)}, + ); + }, + }, + ], + }); + `, + "app/root.tsx": js` + import { Links, Outlet } from "@remix-run/react"; + export default function Root() { + return ( + + + + + + + + + ) + } + `, + "app/routes/postcss-disabled-test.tsx": js` + import { Test, links as testLinks } from "~/test-components/postcss-disabled"; + export function links() { + return [...testLinks()]; + } + export default function() { + return ; + } + `, + "app/test-components/postcss-disabled/index.tsx": js` + import stylesHref from "./styles.css"; + export function links() { + return [{ rel: 'stylesheet', href: stylesHref }]; + } + export function Test() { + return ( +
    +

    PostCSS disabled test.

    +
    + ); + } + `, + "app/test-components/postcss-disabled/styles.css": css` + .postcss-disabled-test { + padding: TEST_PADDING_VALUE; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => appFixture.close()); + + test("ignores PostCSS config", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/postcss-disabled-test"); + let locator = await page.locator("[data-testid='postcss-disabled']"); + let padding = await locator.evaluate((el) => getComputedStyle(el).padding); + expect(padding).not.toBe(TEST_PADDING_VALUE); + }); +}); diff --git a/integration/prefetch-test.ts b/integration/prefetch-test.ts new file mode 100644 index 0000000000..8f2dd987e1 --- /dev/null +++ b/integration/prefetch-test.ts @@ -0,0 +1,1147 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, + css, +} from "./helpers/create-fixture.js"; +import type { + Fixture, + FixtureInit, + AppFixture, +} from "./helpers/create-fixture.js"; +import type { RemixLinkProps } from "../build/node_modules/@remix-run/react/dist/components.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("multi fetch", () => { + // Generate the test app using the given prefetch mode + function fixtureFactory(mode: RemixLinkProps["prefetch"]): FixtureInit { + return { + files: { + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "@remix-run/react"; + + export default function Root() { + const styles = + 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + + 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; + + return ( + + + + + + + +

    Root

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

    Index

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

    With Loader

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

    Without Loader

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

    Index Page - Scroll Down

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

    Test Page

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

    {fetcher.state}

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

    Index

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

    Root

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

    Index

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

    With Nested Links

    ; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='/with-nested-links/nested']"); + await page.waitForSelector("#nav link[rel='prefetch'][as='style']", { + state: "attached", + }); + expect( + await page.locator("#nav link[rel='prefetch'][as='style']").count() + ).toBe(2); + expect( + await page.locator("#nav link[rel='prefetch'][as='image']").count() + ).toBe(2); + }); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + // Generate the test app using the given prefetch mode + function fixtureFactory(mode: RemixLinkProps["prefetch"]): FixtureInit { + return { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "@remix-run/react"; + + export default function Root() { + const styles = + 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' + + 'a:focus { color: green; } a:focus:after { content: " (focused)"; }'; + + return ( + + + + + + + +

    Root

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

    Index

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

    With Loader

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

    Without Loader

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

    Index Page - Scroll Down

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

    Test Page

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

    {fetcher.state}

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

    Index

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

    Root

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

    Index

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

    With Nested Links

    ; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.hover("a[href='/with-nested-links/nested']"); + await page.waitForSelector("#nav link[rel='prefetch'][as='style']", { + state: "attached", + }); + expect( + await page.locator("#nav link[rel='prefetch'][as='style']").count() + ).toBe(2); + expect( + await page.locator("#nav link[rel='prefetch'][as='image']").count() + ).toBe(2); + }); + }); +}); diff --git a/integration/redirects-test.ts b/integration/redirects-test.ts new file mode 100644 index 0000000000..e9723bb870 --- /dev/null +++ b/integration/redirects-test.ts @@ -0,0 +1,294 @@ +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 } from "./helpers/playwright-fixture.js"; + +test.describe("redirects", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/absolute.tsx": js` + import * as React from 'react'; + import { Outlet } from "@remix-run/react"; + + export default function Component() { + let [count, setCount] = React.useState(0); + return ( + <> + + + + ); + } + `, + + "app/routes/absolute._index.tsx": js` + import { redirect } from "@remix-run/node"; + import { Form } from "@remix-run/react"; + + export async function action({ request }) { + return redirect(new URL(request.url).origin + "/absolute/landing"); + }; + + export default function Component() { + return ( +
    + +
    + ); + } + `, + + "app/routes/absolute.landing.tsx": js` + export default function Component() { + return

    Landing

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

    Hello B!

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

    Landing

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

    Hello B!

    + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("redirects to external URLs", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.waitForNetworkAfter(() => app.goto("/loader/external")); + expect(app.page.url()).toBe("https://remix.run/"); + }); + + test("redirects to absolute URLs in the app with a SPA navigation", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/absolute`, true); + await app.clickElement("#increment"); + expect(await app.getHtml("#increment")).toMatch("Count:1"); + await app.waitForNetworkAfter(() => + app.clickSubmitButton("/absolute?index") + ); + await page.waitForSelector(`h1:has-text("Landing")`); + // No hard reload + expect(await app.getHtml("#increment")).toMatch("Count:1"); + }); + + test("supports hard redirects within the app via reloadDocument", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect-document", true); + expect(await app.getHtml("button")).toMatch("Count:0"); + await app.clickElement("button"); + expect(await app.getHtml("button")).toMatch("Count:1"); + await app.waitForNetworkAfter(() => + app.clickLink("/redirect-document/a") + ); + await page.waitForSelector('h1:has-text("Hello B!")'); + // Hard reload resets client side react state + expect(await app.getHtml("button")).toMatch("Count:0"); + }); + }); +}); diff --git a/integration/remix-serve-test.ts b/integration/remix-serve-test.ts new file mode 100644 index 0000000000..7703569731 --- /dev/null +++ b/integration/remix-serve-test.ts @@ -0,0 +1,70 @@ +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; + +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({ + useRemixServe: true, + 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(); +}); + +test("should start and perform client side navigation", 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"); +}); diff --git a/integration/rendering-test.ts b/integration/rendering-test.ts new file mode 100644 index 0000000000..7468e4276a --- /dev/null +++ b/integration/rendering-test.ts @@ -0,0 +1,72 @@ +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, selectHtml } from "./helpers/playwright-fixture.js"; + +test.describe("rendering", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
    +

    Root

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

    Index

    ; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("server renders matching routes", async () => { + let res = await fixture.requestDocument("/"); + expect(res.status).toBe(200); + expect(selectHtml(await res.text(), "#content")).toBe(`
    +

    Root

    +

    Index

    +
    `); + }); + + test("hydrates", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await app.getHtml("#content")).toBe(`
    +

    Root

    +

    Index

    +
    `); + }); +}); diff --git a/integration/request-test.ts b/integration/request-test.ts new file mode 100644 index 0000000000..64581a1599 --- /dev/null +++ b/integration/request-test.ts @@ -0,0 +1,178 @@ +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; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + import { Form, useLoaderData, useActionData } from "@remix-run/react"; + + async function requestToJson(request) { + let body = null; + + if (request.body) { + let fd = await request.formData(); + body = Object.fromEntries(fd.entries()); + } + + return json({ + method: request.method, + url: request.url, + headers: Object.fromEntries(request.headers.entries()), + body, + }); + } + export async function loader({ request }) { + return requestToJson(request); + } + export function action({ request }) { + return requestToJson(request); + } + export default function Index() { + let loaderData = useLoaderData(); + let actionData = useActionData(); + return ( +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    {JSON.stringify(loaderData)}
    + {actionData ? +
    {JSON.stringify(actionData)}
    : + null} +
    + ) + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => appFixture.close()); + +test("loader request on SSR GET requests", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickElement("#set-cookie"); + + let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML()); + expect(loaderData.method).toEqual("GET"); + expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/); + expect(loaderData.headers.cookie).toEqual(undefined); + expect(loaderData.body).toEqual(null); + + await app.clickElement("#submit-get-ssr"); + + loaderData = JSON.parse(await page.locator("#loader-data").innerHTML()); + expect(loaderData.method).toEqual("GET"); + expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/\?type=ssr$/); + expect(loaderData.headers.cookie).toEqual("cookie=nomnom"); + expect(loaderData.body).toEqual(null); +}); + +test("loader request on CSR GET requests", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickElement("#set-cookie"); + + let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML()); + expect(loaderData.method).toEqual("GET"); + expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/); + expect(loaderData.headers.cookie).toEqual(undefined); + expect(loaderData.body).toEqual(null); + + await app.clickElement("#submit-get-csr"); + + loaderData = JSON.parse(await page.locator("#loader-data").innerHTML()); + expect(loaderData.method).toEqual("GET"); + expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/\?type=csr$/); + expect(loaderData.headers.cookie).toEqual("cookie=nomnom"); + expect(loaderData.body).toEqual(null); +}); + +test("action + loader requests SSR POST requests", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickElement("#set-cookie"); + + let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML()); + expect(loaderData.method).toEqual("GET"); + expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/); + expect(loaderData.headers.cookie).toEqual(undefined); + expect(loaderData.body).toEqual(null); + + await app.clickElement("#submit-post-ssr"); + + let actionData = JSON.parse(await page.locator("#action-data").innerHTML()); + expect(actionData.method).toEqual("POST"); + expect(actionData.url).toMatch(/^http:\/\/localhost:\d+\/$/); + expect(actionData.headers.cookie).toEqual("cookie=nomnom"); + expect(actionData.body).toEqual({ type: "ssr" }); + + loaderData = JSON.parse(await page.locator("#loader-data").innerHTML()); + expect(loaderData.method).toEqual("GET"); + expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/); + expect(loaderData.headers.cookie).toEqual("cookie=nomnom"); + expect(loaderData.body).toEqual(null); +}); + +test("action + loader requests on CSR POST requests", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickElement("#set-cookie"); + + let loaderData = JSON.parse(await page.locator("#loader-data").innerHTML()); + expect(loaderData.method).toEqual("GET"); + expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/); + expect(loaderData.headers.cookie).toEqual(undefined); + expect(loaderData.body).toEqual(null); + + await app.clickElement("#submit-post-csr"); + + let actionData = JSON.parse(await page.locator("#action-data").innerHTML()); + expect(actionData.method).toEqual("POST"); + expect(actionData.url).toMatch(/^http:\/\/localhost:\d+\/$/); + expect(actionData.headers.cookie).toEqual("cookie=nomnom"); + expect(actionData.body).toEqual({ type: "csr" }); + + loaderData = JSON.parse(await page.locator("#loader-data").innerHTML()); + expect(loaderData.method).toEqual("GET"); + expect(loaderData.url).toMatch(/^http:\/\/localhost:\d+\/$/); + expect(loaderData.headers.cookie).toEqual("cookie=nomnom"); + expect(loaderData.body).toEqual(null); +}); diff --git a/integration/resource-routes-test.ts b/integration/resource-routes-test.ts new file mode 100644 index 0000000000..def0f01e15 --- /dev/null +++ b/integration/resource-routes-test.ts @@ -0,0 +1,333 @@ +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, Fixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("loader in an app", async () => { + let appFixture: AppFixture; + let fixture: Fixture; + let _consoleError: typeof console.error; + + let SVG_CONTENTS = ``; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = () => {}; + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { Form, Link } from "@remix-run/react"; + + export default () => ( + <> + Redirect + 404 route +
    + + +
    +
    + +
    + + ) + `, + "app/routes/redirected.tsx": js` + export default () =>
    You were redirected
    ; + `, + "app/routes/redirect.tsx": js` + import { redirect } from "@remix-run/node"; + + export let loader = () => redirect("/redirected"); + `, + "app/routes/redirect-to.tsx": js` + import { redirect } from "@remix-run/node"; + + export let action = async ({ request }) => { + let formData = await request.formData(); + return redirect(formData.get('destination')); + } + `, + "app/routes/redirect-destination.tsx": js` + export default () =>
    You made it!
    + `, + "app/routes/defer.tsx": js` + import { defer } from "@remix-run/node"; + + export let loader = () => defer({ data: 'whatever' }); + `, + "app/routes/data[.]json.tsx": js` + import { json } from "@remix-run/node"; + export let loader = () => json({hello: "world"}); + `, + "app/assets/icon.svg": SVG_CONTENTS, + "app/routes/[manifest.webmanifest].tsx": js` + import { json } from "@remix-run/node"; + import iconUrl from "~/assets/icon.svg"; + export function loader() { + return json( + { + icons: [ + { + src: iconUrl, + sizes: '48x48 72x72 96x96 128x128 192x192 256x256 512x512', + type: 'image/svg+xml', + }, + ], + }, + ); + } + `, + "app/routes/throw-error.tsx": js` + export let loader = () => { + throw new Error('Oh noes!') + } + `, + "app/routes/return-response.tsx": js` + export let loader = () => { + return new Response('Partial', { status: 207 }); + } + `, + "app/routes/throw-response.tsx": js` + export let loader = () => { + throw new Response('Partial', { status: 207 }); + } + `, + "app/routes/return-object.tsx": js` + export let loader = () => { + return { hello: 'world' }; + } + `, + "app/routes/throw-object.tsx": js` + export let loader = () => { + throw { but: 'why' }; + } + `, + "app/routes/no-action.tsx": js` + import { json } from "@remix-run/node"; + export let loader = () => { + return json({ ok: true }); + } + `, + "app/routes/$.tsx": js` + import { json } from "@remix-run/node"; + import { useRouteError } from "@remix-run/react"; + export function loader({ request }) { + throw json({ message: new URL(request.url).pathname + ' not found' }, { + status: 404 + }); + } + export function ErrorBoundary() { + let error = useRouteError(); + return
    {error.status + ' ' + error.data.message}
    ; + } + `, + }, + }); + appFixture = await createAppFixture(fixture, ServerMode.Test); + }); + + test.afterAll(() => { + appFixture.close(); + console.error = _consoleError; + }); + + test.describe("with JavaScript", () => { + runTests(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + function runTests() { + test("should redirect to redirected", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.click("a[href='/redirect']"); + await page.waitForSelector("[data-testid='redirected']"); + }); + + test("should handle post to destination", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.click("button[type='submit']"); + await page.waitForSelector("[data-testid='redirect-destination']"); + }); + + test("should handle reloadDocument to resource route", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/data.json"); + expect(await page.content()).toContain('{"hello":"world"}'); + }); + + test("writes imported asset to `assetDirectory`", async ({ page }) => { + new PlaywrightFixture(appFixture, page); + let data = await fixture.getBrowserAsset( + "build/_assets/icon-W7PJN5PS.svg" + ); + expect(data).toBe(SVG_CONTENTS); + }); + + test("should handle errors thrown from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/throw-error"); + expect(res.status()).toBe(500); + expect(await res.text()).toEqual( + "Unexpected Server Error\n\nError: Oh noes!" + ); + }); + + test("should let loader throw to it's own boundary without a default export", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await app.clickLink("/some-404-path"); + let html = await app.getHtml(); + expect(html).toMatch("404 /some-404-path not found"); + }); + } + + test("should handle responses returned from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/return-response"); + expect(res.status()).toBe(207); + expect(await res.text()).toEqual("Partial"); + }); + + test("should handle responses thrown from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/throw-response"); + expect(res.status()).toBe(207); + expect(await res.text()).toEqual("Partial"); + }); + + test("should handle objects returned from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/return-object"); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ hello: "world" }); + }); + + test("should handle objects thrown from resource routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/throw-object"); + expect(res.status()).toBe(500); + expect(await res.text()).toEqual( + "Unexpected Server Error\n\n[object Object]" + ); + }); + + test("should handle ErrorResponses thrown from resource routes on document requests", async () => { + let res = await fixture.postDocument("/no-action", new FormData()); + expect(res.status).toBe(405); + expect(res.statusText).toBe("Method Not Allowed"); + expect(await res.text()).toBe('{"message":"Unexpected Server Error"}'); + }); + + test("should handle ErrorResponses thrown from resource routes on client submissions", async ({ + page, + }) => { + let logs: string[] = []; + page.on("console", (msg) => logs.push(msg.text())); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton("/no-action"); + let html = await app.getHtml(); + expect(html).toMatch("405 Method Not Allowed"); + expect(logs[0]).toContain( + 'Route "routes/no-action" does not have an action' + ); + }); + + test("should error if a defer is returned from a resource route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let res = await app.goto("/defer"); + expect(res.status()).toBe(500); + expect(await res.text()).toMatch( + "You cannot return a `defer()` response from a Resource Route. " + + 'Did you forget to export a default UI component from the "routes/defer" route?' + ); + }); +}); + +test.describe("Development server", async () => { + let appFixture: AppFixture; + let fixture: Fixture; + let _consoleError: typeof console.error; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = () => {}; + + fixture = await createFixture( + { + files: { + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + export default () => Child; + `, + "app/routes/_main.tsx": js` + import { useRouteError } from "@remix-run/react"; + export function ErrorBoundary() { + return
    {useRouteError().message}
    ; + } + `, + "app/routes/_main.child.tsx": js` + export default function Component() { + throw new Error('Error from render') + } + `, + }, + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + console.error = _consoleError; + }); + + test.describe("with JavaScript", () => { + runTests(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + function runTests() { + test("should not treat an ErrorBoundary-only route as a resource route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/child"); + let html = await app.getHtml(); + expect(html).not.toMatch("has no component"); + expect(html).toMatch("Error from render"); + }); + } +}); diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts new file mode 100644 index 0000000000..8830e977a2 --- /dev/null +++ b/integration/revalidate-test.ts @@ -0,0 +1,590 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("Revalidation", () => { + let appFixture: AppFixture; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts, useNavigation } from "@remix-run/react"; + + export default function Component() { + let navigation = useNavigation(); + return ( + + + + + + + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { json } from "@remix-run/node"; + import { Outlet, useLoaderData } from "@remix-run/react"; + + export async function loader({ request }) { + let header = request.headers.get('Cookie') || ''; + let cookie = header + .split(';') + .map(c => c.trim()) + .find(c => c.startsWith('parent=')) + let strValue = (cookie || 'parent=0').split("=")[1]; + let value = parseInt(strValue, 10) + 1; + return json({ value }, { + headers: { + "Set-Cookie": "parent=" + value, + } + }) + }; + + export function shouldRevalidate({ nextUrl, formData }) { + if (nextUrl.searchParams.get('revalidate')?.split(',')?.includes('parent')) { + return true; + } + if (formData?.getAll('revalidate')?.includes('parent')) { + return true; + } + return false + } + + export default function Component() { + let data = useLoaderData(); + return ( + <> +

    {'Value:' + data.value}

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

    {'Value:' + data.value}

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

    Revalidation idle

    : +

    Revalidation busy

    } + + + ); + } + `, + }, + }) + ); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("Revalidates according to shouldRevalidate (loading navigations)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // Should call parent (first load) + await app.clickLink("/parent"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + + // Should call child (first load) but not parent (no param) + await app.clickLink("/parent/child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call neither + await app.clickLink("/parent/child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call both + await app.clickLink("/parent/child?revalidate=parent,child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:2"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call parent only + await app.clickLink("/parent/child?revalidate=parent"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call child only + await app.clickLink("/parent/child?revalidate=child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + }); + + test("Revalidates according to shouldRevalidate (submission navigations)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // Should call both (first load) + await app.clickLink("/parent/child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call neither + await app.clickElement("#submit-neither"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call both + await app.clickElement("#submit-both"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:2"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call parent only + await app.clickElement("#submit-parent"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call child only + await app.clickElement("#submit-child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + }); + + test("Revalidates on demand with useRevalidator", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // Should call both (first load) + await app.clickLink("/parent/child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call neither on manual revalidate (no params) + await app.clickElement("#revalidate"); + await page.waitForSelector("#revalidation-idle", { state: "visible" }); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call both + await app.clickLink("/parent/child?revalidate=parent,child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:2"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call both on manual revalidate + await app.clickElement("#revalidate"); + await page.waitForSelector("#revalidation-idle", { state: "visible" }); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + + // Should call parent only + await app.clickLink("/parent/child?revalidate=parent"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:4"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + + // Should call parent only on manual revalidate + await app.clickElement("#revalidate"); + await page.waitForSelector("#revalidation-idle", { state: "visible" }); + expect(await app.getHtml("#parent-data")).toMatch("Value:5"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + + // Should call child only + await app.clickLink("/parent/child?revalidate=child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:5"); + expect(await app.getHtml("#child-data")).toMatch("Value:4"); + + // Should call child only on manual revalidate + await app.clickElement("#revalidate"); + await page.waitForSelector("#revalidation-idle", { state: "visible" }); + expect(await app.getHtml("#parent-data")).toMatch("Value:5"); + expect(await app.getHtml("#child-data")).toMatch("Value:5"); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("Revalidation", () => { + let appFixture: AppFixture; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts, useNavigation } from "@remix-run/react"; + + export default function Component() { + let navigation = useNavigation(); + return ( + + + + + + + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { json } from "@remix-run/node"; + import { Outlet, useLoaderData } from "@remix-run/react"; + + export async function loader({ request }) { + let header = request.headers.get('Cookie') || ''; + let cookie = header + .split(';') + .map(c => c.trim()) + .find(c => c.startsWith('parent=')) + let strValue = (cookie || 'parent=0').split("=")[1]; + let value = parseInt(strValue, 10) + 1; + return json({ value }, { + headers: { + "Set-Cookie": "parent=" + value, + } + }) + }; + + export function shouldRevalidate({ nextUrl, formData }) { + if (nextUrl.searchParams.get('revalidate')?.split(',')?.includes('parent')) { + return true; + } + if (formData?.getAll('revalidate')?.includes('parent')) { + return true; + } + return false + } + + export default function Component() { + let data = useLoaderData(); + return ( + <> +

    {'Value:' + data.value}

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

    {'Value:' + data.value}

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

    Revalidation idle

    : +

    Revalidation busy

    } + + + ); + } + `, + }, + }) + ); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("Revalidates according to shouldRevalidate (loading navigations)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // Should call parent (first load) + await app.clickLink("/parent"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + + // Should call child (first load) but not parent (no param) + await app.clickLink("/parent/child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call neither + await app.clickLink("/parent/child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call both + await app.clickLink("/parent/child?revalidate=parent,child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:2"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call parent only + await app.clickLink("/parent/child?revalidate=parent"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call child only + await app.clickLink("/parent/child?revalidate=child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + }); + + test("Revalidates according to shouldRevalidate (submission navigations)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // Should call both (first load) + await app.clickLink("/parent/child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call neither + await app.clickElement("#submit-neither"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call both + await app.clickElement("#submit-both"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:2"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call parent only + await app.clickElement("#submit-parent"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call child only + await app.clickElement("#submit-child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + }); + + test("Revalidates on demand with useRevalidator", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // Should call both (first load) + await app.clickLink("/parent/child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call neither on manual revalidate (no params) + await app.clickElement("#revalidate"); + await page.waitForSelector("#revalidation-idle", { state: "visible" }); + expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + expect(await app.getHtml("#child-data")).toMatch("Value:1"); + + // Should call both + await app.clickLink("/parent/child?revalidate=parent,child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:2"); + expect(await app.getHtml("#child-data")).toMatch("Value:2"); + + // Should call both on manual revalidate + await app.clickElement("#revalidate"); + await page.waitForSelector("#revalidation-idle", { state: "visible" }); + expect(await app.getHtml("#parent-data")).toMatch("Value:3"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + + // Should call parent only + await app.clickLink("/parent/child?revalidate=parent"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:4"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + + // Should call parent only on manual revalidate + await app.clickElement("#revalidate"); + await page.waitForSelector("#revalidation-idle", { state: "visible" }); + expect(await app.getHtml("#parent-data")).toMatch("Value:5"); + expect(await app.getHtml("#child-data")).toMatch("Value:3"); + + // Should call child only + await app.clickLink("/parent/child?revalidate=child"); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#parent-data")).toMatch("Value:5"); + expect(await app.getHtml("#child-data")).toMatch("Value:4"); + + // Should call child only on manual revalidate + await app.clickElement("#revalidate"); + await page.waitForSelector("#revalidation-idle", { state: "visible" }); + expect(await app.getHtml("#parent-data")).toMatch("Value:5"); + expect(await app.getHtml("#child-data")).toMatch("Value:5"); + }); + }); +}); diff --git a/integration/root-route-test.ts b/integration/root-route-test.ts new file mode 100644 index 0000000000..b72ee98b1e --- /dev/null +++ b/integration/root-route-test.ts @@ -0,0 +1,156 @@ +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 { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("root route", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture.close(); + }); + + test("matches the sole root route on /", async ({ page }) => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + export default function Root() { + return ( + + +

    Hello Root!

    + + + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("h1"); + expect(await app.getHtml("h1")).toMatch("Hello Root!"); + }); + + test("renders the Layout around the component", async ({ page }) => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + export function Layout({ children }) { + return ( + + + Layout Title + + + {children} + + + ); + } + export default function Root() { + return

    Hello Root!

    ; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("h1"); + expect(await app.getHtml("title")).toMatch("Layout Title"); + expect(await app.getHtml("h1")).toMatch("Hello Root!"); + }); + + test("renders the Layout around the ErrorBoundary", async ({ page }) => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + fixture = await createFixture( + { + files: { + "app/root.tsx": js` + import { useRouteError } from '@remix-run/react'; + export function Layout({ children }) { + return ( + + + Layout Title + + + {children} + + + ); + } + export default function Root() { + throw new Error('broken render') + } + export function ErrorBoundary() { + return

    {useRouteError().message}

    ; + } + `, + }, + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("p"); + expect(await app.getHtml("title")).toMatch("Layout Title"); + expect(await app.getHtml("p")).toMatch("broken render"); + + console.error = oldConsoleError; + }); + + test("renders the Layout around the default ErrorBoundary", async ({ + page, + }) => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + fixture = await createFixture( + { + files: { + "app/root.tsx": js` + export function Layout({ children }) { + return ( + + + Layout Title + + + {children} + + + ); + } + export default function Root() { + throw new Error('broken render') + } + `, + }, + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("h1"); + expect(await app.getHtml("title")).toMatch("Layout Title"); + expect(await app.getHtml("h1")).toMatch("Application Error"); + + console.error = oldConsoleError; + }); +}); diff --git a/integration/route-collisions-test.ts b/integration/route-collisions-test.ts new file mode 100644 index 0000000000..52bc3c445d --- /dev/null +++ b/integration/route-collisions-test.ts @@ -0,0 +1,144 @@ +import { PassThrough } from "node:stream"; +import { test, expect } from "@playwright/test"; + +import { createFixture, js } from "./helpers/create-fixture.js"; + +let ROOT_FILE_CONTENTS = js` + import { Outlet, Scripts } from "@remix-run/react"; + + export default function App() { + return ( + + + + + + + ); + } +`; + +let LAYOUT_FILE_CONTENTS = js` + import { Outlet } from "@remix-run/react"; + + export default function Layout() { + return + } +`; + +let LEAF_FILE_CONTENTS = js` + export default function Foo() { + return

    Foo

    ; + } +`; + +test.describe("build failures", () => { + let originalConsoleLog = console.log; + let originalConsoleWarn = console.warn; + let originalConsoleError = console.error; + + test.beforeAll(async () => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + }); + + test.afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + async function setup(files: Record) { + let buildStdio = new PassThrough(); + let buildOutput: string; + await createFixture({ + buildStdio, + files, + }); + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + return buildOutput; + } + + test("detects path collisions inside pathless layout routes", async () => { + let buildOutput = await setup({ + "app/root.tsx": ROOT_FILE_CONTENTS, + "app/routes/foo.jsx": LEAF_FILE_CONTENTS, + "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS, + "app/routes/_pathless.foo.jsx": LEAF_FILE_CONTENTS, + }); + expect(buildOutput).toContain(`⚠️ Route Path Collision: "/foo"`); + expect(buildOutput).toContain(`🟢 routes/_pathless.foo.jsx`); + expect(buildOutput).toContain(`⭕️️ routes/foo.jsx`); + }); + + test("detects path collisions across pathless layout routes", async () => { + let buildOutput = await setup({ + "app/root.tsx": ROOT_FILE_CONTENTS, + "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS, + "app/routes/_pathless.foo.jsx": LEAF_FILE_CONTENTS, + "app/routes/_pathless2.jsx": LAYOUT_FILE_CONTENTS, + "app/routes/_pathless2.foo.jsx": LEAF_FILE_CONTENTS, + }); + expect(buildOutput).toContain(`⚠️ Route Path Collision: "/foo"`); + expect(buildOutput).toContain(`🟢 routes/_pathless2.foo.jsx`); + expect(buildOutput).toContain(`⭕️️ routes/_pathless.foo.jsx`); + }); + + test("detects path collisions inside multiple pathless layout routes", async () => { + let buildOutput = await setup({ + "app/root.tsx": ROOT_FILE_CONTENTS, + "app/routes/foo.jsx": LEAF_FILE_CONTENTS, + "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS, + "app/routes/_pathless._again.jsx": LAYOUT_FILE_CONTENTS, + "app/routes/_pathless._again.foo.jsx": LEAF_FILE_CONTENTS, + }); + expect(buildOutput).toContain(`⚠️ Route Path Collision: "/foo"`); + expect(buildOutput).toContain(`🟢 routes/_pathless._again.foo.jsx`); + expect(buildOutput).toContain(`⭕️️ routes/foo.jsx`); + }); + + test("detects path collisions of index files inside pathless layouts", async () => { + let buildOutput = await setup({ + "app/root.tsx": ROOT_FILE_CONTENTS, + "app/routes/_index.jsx": LEAF_FILE_CONTENTS, + "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS, + "app/routes/_pathless._index.jsx": LEAF_FILE_CONTENTS, + }); + expect(buildOutput).toContain(`⚠️ Route Path Collision: "/"`); + expect(buildOutput).toContain(`🟢 routes/_pathless._index.jsx`); + expect(buildOutput).toContain(`⭕️️ routes/_index.jsx`); + }); + + test("detects path collisions of index files across multiple pathless layouts", async () => { + let buildOutput = await setup({ + "app/root.tsx": ROOT_FILE_CONTENTS, + "app/routes/nested._pathless.jsx": LAYOUT_FILE_CONTENTS, + "app/routes/nested._pathless._index.jsx": LEAF_FILE_CONTENTS, + "app/routes/nested._oops.jsx": LAYOUT_FILE_CONTENTS, + "app/routes/nested._oops._index.jsx": LEAF_FILE_CONTENTS, + }); + expect(buildOutput).toContain(`⚠️ Route Path Collision: "/nested"`); + expect(buildOutput).toContain(`🟢 routes/nested._pathless._index.jsx`); + expect(buildOutput).toContain(`⭕️️ routes/nested._oops._index.jsx`); + }); + + test("detects path collisions of param routes inside pathless layouts", async () => { + let buildOutput = await setup({ + "app/root.tsx": ROOT_FILE_CONTENTS, + "app/routes/$param.jsx": LEAF_FILE_CONTENTS, + "app/routes/_pathless.jsx": LAYOUT_FILE_CONTENTS, + "app/routes/_pathless.$param.jsx": LEAF_FILE_CONTENTS, + }); + expect(buildOutput).toContain(`⚠️ Route Path Collision: "/:param"`); + expect(buildOutput).toContain(`🟢 routes/_pathless.$param.jsx`); + expect(buildOutput).toContain(`⭕️️ routes/$param.jsx`); + }); +}); diff --git a/integration/scroll-test.ts b/integration/scroll-test.ts new file mode 100644 index 0000000000..d5be7c4b58 --- /dev/null +++ b/integration/scroll-test.ts @@ -0,0 +1,125 @@ +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; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { redirect } from "@remix-run/node"; + import { Form } from "@remix-run/react"; + + export function action() { + return redirect("/test"); + }; + + export default function Component() { + return ( + <> +

    Index Page - Scroll Down

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

    Redirected!

    +

    I should not be visible!!

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

    Hash Scrolling

    + hash link to hello-world + hash link to hello 🌎 +
    Spacer Div
    +

    hello-world scroll target

    +

    hello 🌎 scroll target

    + + ); + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => { + appFixture.close(); +}); + +test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); +}); + +test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runTests(); +}); + +function runTests() { + test("page scroll should be at the top on the new page", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Scroll to the bottom and submit + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + let scroll = await page.evaluate(() => window.scrollY); + expect(scroll).toBeGreaterThan(0); + await app.clickSubmitButton("/?index"); + await page.waitForSelector("#redirected"); + + // Ensure we scrolled back to the top + scroll = await page.evaluate(() => window.scrollY); + expect(scroll).toBe(0); + }); + + test("should scroll to hash locations", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/hash"); + let scroll = await page.evaluate(() => window.scrollY); + expect(scroll).toBe(0); + await app.clickLink("/hash#hello-world"); + await new Promise((r) => setTimeout(r, 0)); + scroll = await page.evaluate(() => window.scrollY); + expect(scroll).toBeGreaterThan(0); + }); + + test("should scroll to hash locations with URL encoded characters", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/hash"); + let scroll = await page.evaluate(() => window.scrollY); + expect(scroll).toBe(0); + await app.clickLink("/hash#hello 🌎"); + await new Promise((r) => setTimeout(r, 0)); + scroll = await page.evaluate(() => window.scrollY); + expect(scroll).toBeGreaterThan(0); + }); +} diff --git a/integration/server-code-in-browser-message-test.ts b/integration/server-code-in-browser-message-test.ts new file mode 100644 index 0000000000..f144509908 --- /dev/null +++ b/integration/server-code-in-browser-message-test.ts @@ -0,0 +1,64 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, + json, +} 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.beforeAll(async () => { + fixture = await createFixture({ + files: { + "node_modules/has-side-effects/package.json": json({ + name: "has-side-effects", + version: "1.0.0", + main: "index.js", + }), + + "node_modules/has-side-effects/index.js": js` + let message; + (() => { message = process.env.___SOMETHING___ || "hello, world"; })(); + module.exports = () => message; + `, + + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + import { useLoaderData, Link } from "@remix-run/react"; + import sideEffectModules from "has-side-effects"; + + export let loader = () => json(sideEffectModules()); + + export default function Index() { + let data = useLoaderData(); + + return ( +
    + {data} + Other Route +
    + ) + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => { + appFixture.close(); +}); + +test.skip("should log relevant error message", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await app.getHtml()).toMatch( + "https://remix.run/pages/gotchas#server-code-in-client-bundles" + ); +}); diff --git a/integration/server-entry-test.ts b/integration/server-entry-test.ts new file mode 100644 index 0000000000..5e59330652 --- /dev/null +++ b/integration/server-entry-test.ts @@ -0,0 +1,64 @@ +import { test, expect } from "@playwright/test"; + +import { createFixture, js } from "./helpers/create-fixture.js"; +import type { Fixture } from "./helpers/create-fixture.js"; +import { selectHtml } from "./helpers/playwright-fixture.js"; + +test.describe("Custom Server Entry", () => { + let fixture: Fixture; + + let DATA_HEADER_NAME = "X-Macaroni-Salad"; + let DATA_HEADER_VALUE = "Smoked Mozarella"; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/entry.server.tsx": js` + export default function handleRequest() { + return new Response(""); + } + + export function handleDataRequest(response) { + response.headers.set("${DATA_HEADER_NAME}", "${DATA_HEADER_VALUE}"); + return response; + } + `, + + "app/routes/_index.tsx": js` + export function loader() { + return "" + } + export default function () { + return
    + } + `, + }, + }); + }); + + test("can manipulate a data response", async () => { + let response = await fixture.requestData("/", "routes/_index"); + expect(response.headers.get(DATA_HEADER_NAME)).toBe(DATA_HEADER_VALUE); + }); +}); + +test.describe("Default Server Entry", () => { + let fixture: Fixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + export default function () { + return

    Hello World

    + } + `, + }, + }); + }); + + test("renders", async () => { + let response = await fixture.requestDocument("/"); + expect(selectHtml(await response.text(), "p")).toBe("

    Hello World

    "); + }); +}); diff --git a/integration/server-source-maps-test.ts b/integration/server-source-maps-test.ts new file mode 100644 index 0000000000..b1cea1a50b --- /dev/null +++ b/integration/server-source-maps-test.ts @@ -0,0 +1,50 @@ +import { test, expect } from "@playwright/test"; +import path from "node:path"; +import fsp from "node:fs/promises"; + +import { createFixture, js } from "./helpers/create-fixture.js"; +import type { Fixture } from "./helpers/create-fixture.js"; + +let fixture: Fixture; + +test.beforeAll(async () => { + fixture = await createFixture({ + sourcemap: true, + files: { + "app/routes/_index.tsx": js` + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export function loader() { + try { + throw new Error("💩"); + } catch { + return json(err.stack); + } + } + + export default function Index() { + let data = useLoaderData(); + return ( +
    +              {data}
    +            
    + ) + } + `, + }, + }); +}); + +test("re-writes stack traces to point to the correct file", async () => { + let buildIndex = await fsp.readFile( + path.join(fixture.projectDir, "build/index.js"), + "utf-8" + ); + expect(buildIndex).toMatch("//# sourceMappingURL=index.js.map"); + let buildIndexSourcemap = await fsp.readFile( + path.join(fixture.projectDir, "build/index.js.map"), + "utf-8" + ); + expect(buildIndexSourcemap).not.toMatch("route:"); +}); diff --git a/integration/set-cookie-revalidation-test.ts b/integration/set-cookie-revalidation-test.ts new file mode 100644 index 0000000000..01f6b2c056 --- /dev/null +++ b/integration/set-cookie-revalidation-test.ts @@ -0,0 +1,267 @@ +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 BANNER_MESSAGE = "you do not have permission to view /protected"; + +test.describe("set-cookie revalidation", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/session.server.ts": js` + import { createCookieSessionStorage } from "@remix-run/node"; + + export let MESSAGE_KEY = "message"; + + export let sessionStorage = createCookieSessionStorage({ + cookie: { + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: ["cookie-secret"], + } + }) + `, + + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "@remix-run/react"; + + import { sessionStorage, MESSAGE_KEY } from "~/session.server"; + + export const loader = async ({ request }) => { + let session = await sessionStorage.getSession(request.headers.get("Cookie")); + let message = session.get(MESSAGE_KEY) || null; + + return json(message, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session), + }, + }); + }; + + export default function Root() { + const message = useLoaderData(); + + return ( + + + + + + + {!!message &&

    {message}

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

    + protected +

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

    login

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

    protected

    ; + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should revalidate when cookie is set on redirect from loader", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/protected"); + await page.waitForSelector(`#message:has-text("${BANNER_MESSAGE}")`); + expect(await app.getHtml()).toMatch(BANNER_MESSAGE); + }); +}); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("set-cookie revalidation", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/session.server.ts": js` + import { createCookieSessionStorage } from "@remix-run/node"; + + export let MESSAGE_KEY = "message"; + + export let sessionStorage = createCookieSessionStorage({ + cookie: { + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: ["cookie-secret"], + } + }) + `, + + "app/root.tsx": js` + import { json } from "@remix-run/node"; + import { + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + } from "@remix-run/react"; + + import { sessionStorage, MESSAGE_KEY } from "~/session.server"; + + export const loader = async ({ request }) => { + let session = await sessionStorage.getSession(request.headers.get("Cookie")); + let message = session.get(MESSAGE_KEY) || null; + + return json(message, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session), + }, + }); + }; + + export default function Root() { + const message = useLoaderData(); + + return ( + + + + + + + {!!message &&

    {message}

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

    + protected +

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

    login

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

    protected

    ; + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should revalidate when cookie is set on redirect from loader", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/protected"); + await page.waitForSelector(`#message:has-text("${BANNER_MESSAGE}")`); + expect(await app.getHtml()).toMatch(BANNER_MESSAGE); + }); + }); +}); diff --git a/integration/shared-route-imports-test.ts b/integration/shared-route-imports-test.ts new file mode 100644 index 0000000000..00f1035879 --- /dev/null +++ b/integration/shared-route-imports-test.ts @@ -0,0 +1,189 @@ +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.describe("v1 compiler", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/parent.tsx": js` + import { createContext, useContext } from "react"; + import { Outlet } from "@remix-run/react"; + + const ParentContext = createContext("❌"); + + export function useParentContext() { + return useContext(ParentContext); + } + + export default function Index() { + return ( + + + + ) + } + `, + + "app/routes/parent.child.tsx": js` + import { useParentContext } from "./parent"; + + export default function Index() { + return

    {useParentContext()}

    ; + } + `, + + "app/routes/markdown-parent.mdx": `import { createContext, useContext } from 'react'; +import { Outlet } from '@remix-run/react'; + +export const ParentContext = createContext("❌"); + +export function useParentContext() { + return useContext(ParentContext); +} + +export function ParentProvider() { + return ( + + + + ); +} + + +`, + "app/routes/markdown-parent.child.mdx": `import { useParentContext } from "./markdown-parent.mdx"; + +export function UseParentContext() { + return

    {useParentContext()}

    ; +} + + +`, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should render context value from context provider", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child", true); + + await page.waitForSelector("p:has-text('✅')"); + }); + + test("should render context value from context provider exported from mdx", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/markdown-parent/child", true); + + await page.waitForSelector("p:has-text('✅')"); + }); +}); + +test.describe("v2 compiler", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/parent.tsx": js` + import { createContext, useContext } from "react"; + import { Outlet } from "@remix-run/react"; + + const ParentContext = createContext("❌"); + + export function useParentContext() { + return useContext(ParentContext); + } + + export default function Index() { + return ( + + + + ) + } + `, + + "app/routes/parent.child.tsx": js` + import { useParentContext } from "./parent"; + + export default function Index() { + return

    {useParentContext()}

    ; + } + `, + + "app/routes/markdown-parent.mdx": `import { createContext, useContext } from 'react'; +import { Outlet } from '@remix-run/react'; + +export const ParentContext = createContext("❌"); + +export function useParentContext() { + return useContext(ParentContext); +} + +export function ParentProvider() { + return ( + + + + ); +} + + +`, + "app/routes/markdown-parent.child.mdx": `import { useParentContext } from "./markdown-parent.mdx"; + +export function UseParentContext() { + const value = useParentContext(); + return ( +

    {value}

    + ); +} + + +`, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should render context value from context provider", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child", true); + + await page.waitForSelector("p:has-text('✅')"); + }); + + test("should render context value from context provider exported from mdx", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/markdown-parent/child", true); + + await page.waitForSelector("p:has-text('✅')"); + }); +}); diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts new file mode 100644 index 0000000000..c571d63f46 --- /dev/null +++ b/integration/single-fetch-test.ts @@ -0,0 +1,2303 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { ServerMode } from "../build/node_modules/@remix-run/server-runtime/dist/mode.js"; + +const ISO_DATE = "2024-03-12T12:00:00.000Z"; + +const files = { + "app/root.tsx": js` + import { Form, Link, Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export function loader() { + return { + message: "ROOT", + }; + } + + export default function Root() { + return ( + + + + + + + Home
    + Data
    + /a/b/c
    +
    + +
    + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

    Index

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

    Data

    +

    {data.message}

    +

    {data.date.toISOString()}

    + {actionData ?

    {actionData.key}

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

    {data.count}

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

    idle

    : null} + {actionData ?

    yes

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

    {data.count}

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

    idle

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

    Error

    + Back +
    + ); + } + `, + }, + }); + + let urls: string[] = []; + page.on("request", (req) => { + if (req.method() === "GET" && req.url().includes(".data")) { + urls.push(req.url()); + } + }); + + console.error = () => {}; + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="return"][value="5xx"]'); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="return"][value="4xx"]'); + await page.waitForSelector("#idle"); + expect(await app.getHtml("#data")).toContain("1"); + expect(urls).toEqual([]); + + await page.click('button[name="throw"][value="5xx"]'); + await page.waitForSelector("#error"); + expect(urls).toEqual([]); + + await app.clickLink("/action"); + await page.waitForSelector("#data"); + expect(await app.getHtml("#data")).toContain("2"); + urls = []; + + await page.click('button[name="throw"][value="4xx"]'); + await page.waitForSelector("#error"); + expect(urls).toEqual([]); + }); + + test("returns headers correctly for singular loader and action calls", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/headers.tsx": js` + export function action({ request, response }) { + if (new URL(request.url).searchParams.has("error")) { + response.headers.set("x-action-error", "true"); + throw response; + } + response.headers.set("x-action", "true"); + return null; + } + + export function loader({ request, response }) { + if (new URL(request.url).searchParams.has("error")) { + response.headers.set("x-loader-error", "true"); + throw response; + } + response.headers.set("x-loader", "true"); + return null; + } + + export default function Comp() { + return null; + } + `, + }, + }); + + // Loader + let docResponse = await fixture.requestDocument("/headers"); + let dataResponse = await fixture.requestSingleFetchData("/headers.data"); + expect(docResponse.headers.get("x-loader")).toEqual("true"); + expect(dataResponse.headers.get("x-loader")).toEqual("true"); + + // Action + docResponse = await fixture.requestDocument("/headers", { + method: "post", + body: null, + }); + dataResponse = await fixture.requestSingleFetchData("/headers.data", { + method: "post", + body: null, + }); + expect(docResponse.headers.get("x-action")).toEqual("true"); + expect(dataResponse.headers.get("x-action")).toEqual("true"); + + console.error = () => {}; + + // Loader Error + docResponse = await fixture.requestDocument("/headers?error"); + dataResponse = await fixture.requestSingleFetchData("/headers.data?error"); + expect(docResponse.headers.get("x-loader-error")).toEqual("true"); + expect(dataResponse.headers.get("x-loader-error")).toEqual("true"); + + // Action Error + docResponse = await fixture.requestDocument("/headers?error", { + method: "post", + body: null, + }); + dataResponse = await fixture.requestSingleFetchData("/headers.data?error", { + method: "post", + body: null, + }); + expect(docResponse.headers.get("x-action-error")).toEqual("true"); + expect(dataResponse.headers.get("x-action-error")).toEqual("true"); + }); + + test("merges headers from nested routes", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + export function loader({ request, response }) { + response.headers.set('x-one', 'a set'); + response.headers.append('x-one', 'a append'); + response.headers.set('x-two', 'a set'); + response.headers.append('x-three', 'a append'); + response.headers.set('x-four', 'a set'); + return null; + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.tsx": js` + export function loader({ request, response }) { + response.headers.set('x-one', 'b set'); + response.headers.append('x-one', 'b append'); + response.headers.set('x-two', 'b set'); + response.headers.append('x-three', 'b append'); + response.headers.delete('x-four'); + return null; + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.c.tsx": js` + export function action({ request, response }) { + response.headers.set('x-one', 'c action set'); + response.headers.append('x-one', 'c action append'); + response.headers.set('x-two', 'c action set'); + response.headers.append('x-three', 'c action append'); + response.headers.set('x-four', 'c action set'); + return null; + } + + export function loader({ request, response }) { + response.headers.set('x-one', 'c set'); + response.headers.append('x-one', 'c append'); + response.headers.set('x-two', 'c set'); + response.headers.append('x-three', 'c append'); + return null; + } + + export default function Comp() { + return null; + } + `, + }, + }); + + // x-one uses both set and append + // x-two only uses set + // x-three only uses append + // x-four deletes + let res: Awaited< + ReturnType< + typeof fixture.requestDocument | typeof fixture.requestSingleFetchData + > + >; + res = await fixture.requestDocument("/a"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestSingleFetchData("/a.data"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestDocument("/a/b"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("a append, b append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestSingleFetchData("/a/b.data"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("a append, b append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestDocument("/a/b/c"); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("a append, b append, c append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestSingleFetchData("/a/b/c.data"); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("a append, b append, c append"); + expect(res.headers.get("x-four")).toEqual(null); + + // Fine-grained revalidation + res = await fixture.requestDocument("/a/b/c.data?_routes=routes%2Fa"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestDocument( + "/a/b.data?_routes=routes%2Fa,routes%2Fa.b" + ); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("a append, b append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestDocument("/a/b/c.data?_routes=routes%2Fa.b.c"); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("c append"); + expect(res.headers.get("x-four")).toEqual(null); + + res = await fixture.requestDocument( + "/a/b/c.data?_routes=routes%2Fa,routes%2Fa.b.c" + ); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual("a append, c append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + // Action only - single fetch request + res = await fixture.requestSingleFetchData("/a/b/c.data", { + method: "post", + body: null, + }); + expect(res.headers.get("x-one")).toEqual("c action set, c action append"); + expect(res.headers.get("x-two")).toEqual("c action set"); + expect(res.headers.get("x-three")).toEqual("c action append"); + expect(res.headers.get("x-four")).toEqual("c action set"); + + // Actions and Loaders - Document request + res = await fixture.requestDocument("/a/b/c", { + method: "post", + body: null, + }); + expect(res.headers.get("x-one")).toEqual("c set, c append"); + expect(res.headers.get("x-two")).toEqual("c set"); + expect(res.headers.get("x-three")).toEqual( + "c action append, a append, b append, c append" + ); + expect(res.headers.get("x-four")).toEqual(null); + }); + + test("merges status codes from nested routes", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + export function loader({ request, response }) { + if (new URL(request.url).searchParams.has("error")) { + response.status = 401 + } else { + response.status = 201 + } + return null; + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.tsx": js` + export function loader({ request, response }) { + response.status = 202 + return null; + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.c.tsx": js` + export function action({ request, response }) { + response.status = 206 + return null; + } + + export function loader({ request, response }) { + response.status = 203 + return null; + } + + export default function Comp() { + return null; + } + `, + }, + }); + + // Loaders + let res: Awaited< + ReturnType< + typeof fixture.requestDocument | typeof fixture.requestSingleFetchData + > + >; + res = await fixture.requestDocument("/a"); + expect(res.status).toEqual(201); + + res = await fixture.requestSingleFetchData("/a.data"); + expect(res.status).toEqual(201); + + res = await fixture.requestDocument("/a/b"); + expect(res.status).toEqual(202); + + res = await fixture.requestSingleFetchData("/a/b.data"); + expect(res.status).toEqual(202); + + res = await fixture.requestDocument("/a/b/c"); + expect(res.status).toEqual(203); + + res = await fixture.requestSingleFetchData("/a/b/c.data"); + expect(res.status).toEqual(203); + + // Errors + res = await fixture.requestDocument("/a?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestSingleFetchData("/a.data?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestDocument("/a/b?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestSingleFetchData("/a/b.data?error"); + expect(res.status).toEqual(401); + + // Actions + res = await fixture.requestDocument("/a/b/c", { + method: "post", + body: null, + }); + expect(res.status).toEqual(206); + + res = await fixture.requestSingleFetchData("/a/b/c.data", { + method: "post", + body: null, + }); + expect(res.status).toEqual(206); + }); + + test("merges headers from nested routes when raw Responses are returned", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + export function loader({ request}) { + let headers = new Headers(); + headers.set('x-one', 'a set'); + headers.append('x-one', 'a append'); + headers.set('x-two', 'a set'); + headers.append('x-three', 'a append'); + headers.set('x-four', 'a set'); + return new Response(null, { headers }); + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.tsx": js` + export function action({ request, response }) { + let headers = new Headers(); + headers.set('x-one', 'b action set'); + headers.append('x-one', 'b action append'); + headers.set('x-two', 'b action set'); + headers.append('x-three', 'b action append'); + headers.set('x-four', 'b action set'); + return new Response(null, { headers }); + } + + export function loader({ request, response }) { + let headers = new Headers(); + headers.set('x-one', 'b set'); + headers.append('x-one', 'b append'); + headers.set('x-two', 'b set'); + headers.append('x-three', 'b append'); + headers.delete('x-four'); + return new Response(null, { headers }); + } + + export default function Comp() { + return null; + } + `, + }, + }); + + // x-one uses both set and append + // x-two only uses set + // x-three only uses append + // x-four deletes + let res: Awaited< + ReturnType< + typeof fixture.requestDocument | typeof fixture.requestSingleFetchData + > + >; + res = await fixture.requestDocument("/a"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestSingleFetchData("/a.data"); + expect(res.headers.get("x-one")).toEqual("a set, a append"); + expect(res.headers.get("x-two")).toEqual("a set"); + expect(res.headers.get("x-three")).toEqual("a append"); + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestDocument("/a/b"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("b append"); // Blows away "a append" + expect(res.headers.get("x-four")).toEqual("a set"); + + res = await fixture.requestSingleFetchData("/a/b.data"); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("b append"); // Blows away "a append" + expect(res.headers.get("x-four")).toEqual("a set"); + + // Action only - single fetch request + res = await fixture.requestSingleFetchData("/a/b.data", { + method: "post", + body: null, + }); + expect(res.headers.get("x-one")).toEqual("b action set, b action append"); + expect(res.headers.get("x-two")).toEqual("b action set"); + expect(res.headers.get("x-three")).toEqual("b action append"); + expect(res.headers.get("x-four")).toEqual("b action set"); + + // Actions and Loaders - Document request + res = await fixture.requestDocument("/a/b", { + method: "post", + body: null, + }); + expect(res.headers.get("x-one")).toEqual("b set, b append"); + expect(res.headers.get("x-two")).toEqual("b set"); + expect(res.headers.get("x-three")).toEqual("b append"); // Blows away prior appends + expect(res.headers.get("x-four")).toEqual("a set"); // Can't delete via Response + }); + + test("merges status codes from nested routes when raw Responses are used", async () => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/a.tsx": js` + export function loader({ request, response }) { + if (new URL(request.url).searchParams.has("error")) { + return new Response(null, { status: 401 }); + } else { + return new Response(null, { status: 201 }); + } + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.tsx": js` + export function loader({ request, response }) { + return new Response(null, { status: 202 }); + } + + export default function Comp() { + return null; + } + `, + "app/routes/a.b.c.tsx": js` + export function action({ request, response }) { + return new Response(null, { status: 206 }); + } + + export function loader({ request, response }) { + return new Response(null, { status: 203 }); + } + + export default function Comp() { + return null; + } + `, + }, + }); + + // Loaders + let res: Awaited< + ReturnType< + typeof fixture.requestDocument | typeof fixture.requestSingleFetchData + > + >; + res = await fixture.requestDocument("/a"); + expect(res.status).toEqual(201); + + res = await fixture.requestSingleFetchData("/a.data"); + expect(res.status).toEqual(201); + + res = await fixture.requestDocument("/a/b"); + expect(res.status).toEqual(202); + + res = await fixture.requestSingleFetchData("/a/b.data"); + expect(res.status).toEqual(202); + + res = await fixture.requestDocument("/a/b/c"); + expect(res.status).toEqual(203); + + res = await fixture.requestSingleFetchData("/a/b/c.data"); + expect(res.status).toEqual(203); + + // Errors + res = await fixture.requestDocument("/a?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestSingleFetchData("/a.data?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestDocument("/a/b?error"); + expect(res.status).toEqual(401); + + res = await fixture.requestSingleFetchData("/a/b.data?error"); + expect(res.status).toEqual(401); + + // Actions + res = await fixture.requestDocument("/a/b/c", { + method: "post", + body: null, + }); + expect(res.status).toEqual(206); + + res = await fixture.requestSingleFetchData("/a/b/c.data", { + method: "post", + body: null, + }); + expect(res.status).toEqual(206); + }); + + test("processes thrown loader redirects via responseStub", async ({ + page, + }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function loader({ request, response }) { + response.status = 302; + response.headers.set('Location', '/target'); + throw response; + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

    Target

    + } + `, + }, + }); + + console.error = () => {}; + + let res = await fixture.requestDocument("/data"); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + + test("processes returned loader redirects via responseStub", async ({ + page, + }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function loader({ request, response }) { + response.status = 302; + response.headers.set('Location', '/target'); + return null + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

    Target

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

    Target

    + } + `, + }, + }); + + console.error = () => {}; + + let res = await fixture.requestDocument("/data"); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + + test("processes returned loader redirects via Response", async ({ page }) => { + let fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function loader() { + return redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

    Target

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

    Target

    + } + `, + }, + }, + ServerMode.Development + ); + + console.error = () => {}; + + let res = await fixture.requestDocument("/data", { + method: "post", + body: null, + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton("/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + + test("processes returned action redirects via responseStub", async ({ + page, + }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function action({ response }) { + response.status = 302; + response.headers.set('Location', '/target'); + return null + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

    Target

    + } + `, + }, + }, + ServerMode.Development + ); + + let res = await fixture.requestDocument("/data", { + method: "post", + body: null, + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton("/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + + test("processes thrown action redirects via Response", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function action() { + throw redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

    Target

    + } + `, + }, + }, + ServerMode.Development + ); + + console.error = () => {}; + + let res = await fixture.requestDocument("/data", { + method: "post", + body: null, + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/target"); + expect(await res.text()).toBe(""); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton("/data"); + await page.waitForSelector("#target"); + expect(await app.getHtml("#target")).toContain("Target"); + }); + + test("processes returned action redirects via Response", async ({ page }) => { + let fixture = await createFixture( + { + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + ...files, + "app/routes/data.tsx": js` + import { redirect } from '@remix-run/node'; + export function action() { + return redirect('/target'); + } + export default function Component() { + return null + } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

    Target

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

    Target

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

    Target

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

    A

    +

    {data.message}

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

    B

    +

    {data.message}

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

    C

    +

    {data.message}

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

    A

    +

    {data.message}

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

    B

    +

    {data.message}

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

    C

    +

    {data.message}

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

    A

    +

    {data.message}

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

    B

    +

    {data.message}

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

    C

    +

    {data.message}

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

    A

    +

    {data.message}

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

    B

    +

    {data.message}

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

    C

    +

    {data.message}

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

    A

    +

    {data.message}

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

    B

    +

    {data.message}

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

    C

    +

    {data.message}

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

    A

    +

    {data.message}

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

    B

    +

    {data.message}

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

    C

    +

    {data.message}

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

    A

    +

    {data.message}

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

    B

    +

    {data.message}

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

    C

    +

    {data.message}

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

    A

    +

    {data.message}

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

    B

    +

    {data.message}

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

    C

    +

    {data.message}

    + + ); + } + `, + }, + }, + ServerMode.Development + ); + + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + // No prefetching due to clientLoaders + expect(await app.page.locator("nav link[as='fetch']").count()).toEqual(0); + }); + }); +}); diff --git a/integration/splat-routes-test.ts b/integration/splat-routes-test.ts new file mode 100644 index 0000000000..c32355f491 --- /dev/null +++ b/integration/splat-routes-test.ts @@ -0,0 +1,129 @@ +import { test, expect } from "@playwright/test"; + +import { createFixture, js } from "./helpers/create-fixture.js"; +import type { Fixture } from "./helpers/create-fixture.js"; + +test.describe("rendering", () => { + let fixture: Fixture; + + let ROOT_$ = "FLAT"; + let ROOT_INDEX = "ROOT_INDEX"; + let FLAT_$ = "FLAT"; + let PARENT = "PARENT"; + let NESTED_$ = "NESTED_$"; + let NESTED_INDEX = "NESTED_INDEX"; + let PARENTLESS_$ = "PARENTLESS_$"; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function() { + return

    ${ROOT_INDEX}

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

    ${ROOT_$}

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

    ${FLAT_$}

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

    ${PARENT}

    + +
    + ) + } + `, + + "app/routes/nested.$.tsx": js` + export default function() { + return

    ${NESTED_$}

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

    ${NESTED_INDEX}

    + } + `, + + "app/routes/parentless.$.tsx": js` + export default function() { + return

    ${PARENTLESS_$}

    + } + `, + }, + }); + }); + + test("flat exact match", async () => { + let res = await fixture.requestDocument("/flat"); + expect(await res.text()).toMatch(FLAT_$); + }); + + test("flat deep match", async () => { + let res = await fixture.requestDocument("/flat/swig"); + expect(await res.text()).toMatch(FLAT_$); + }); + + test("prioritizes index over root splat", async () => { + let res = await fixture.requestDocument("/"); + expect(await res.text()).toMatch(ROOT_INDEX); + }); + + test("matches root splat", async () => { + let res = await fixture.requestDocument("/twisted/sugar"); + expect(await res.text()).toMatch(ROOT_$); + }); + + test("prioritizes index over splat for parent route match", async () => { + let res = await fixture.requestDocument("/nested"); + expect(await res.text()).toMatch(NESTED_INDEX); + }); + + test("nested child", async () => { + let res = await fixture.requestDocument("/nested/sodalicious"); + expect(await res.text()).toMatch(NESTED_$); + }); + + test("parentless exact match", async () => { + let res = await fixture.requestDocument("/parentless"); + expect(await res.text()).toMatch(PARENTLESS_$); + }); + + test("parentless deep match", async () => { + let res = await fixture.requestDocument("/parentless/chip"); + expect(await res.text()).toMatch(PARENTLESS_$); + }); +}); diff --git a/integration/svg-in-node-modules-test.ts b/integration/svg-in-node-modules-test.ts new file mode 100644 index 0000000000..1aaaba4951 --- /dev/null +++ b/integration/svg-in-node-modules-test.ts @@ -0,0 +1,52 @@ +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; + +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/routes/_index.tsx": js` + import imgSrc from "getos/imgs/logo.svg"; + + export default function () { + return ( +
    + example img +
    + ) + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => { + appFixture.close(); +}); + +test("renders SVG images imported from node_modules", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + // You can test any request your app might get using `fixture`. + await app.goto("/"); + expect(await page.getByTestId("example-svg").getAttribute("src")).toMatch( + /\/build\/_assets\/logo-.*\.svg/ + ); +}); diff --git a/integration/tailwind-test.ts b/integration/tailwind-test.ts new file mode 100644 index 0000000000..4f2f91ce15 --- /dev/null +++ b/integration/tailwind-test.ts @@ -0,0 +1,428 @@ +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"; + +let extensions = ["mjs", "cjs", "js", "ts"] as const; + +function runTests(ext: typeof extensions[number]) { + let fixture: Fixture; + let appFixture: AppFixture; + + let tailwindConfigName = `tailwind.config.${ext}`; + + let tailwindConfig = ["mjs", "ts", "js"].includes(ext) + ? js` + export default { + content: ["./app/**/*.{ts,tsx,jsx,js}"], + theme: { + spacing: { + 'test': ${JSON.stringify(TEST_PADDING_VALUE)} + }, + }, + } + ` + : js` + module.exports = { + content: ["./app/**/*.{ts,tsx,jsx,js}"], + theme: { + spacing: { + 'test': ${JSON.stringify(TEST_PADDING_VALUE)} + }, + }, + } + `; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "package.json": json({ + name: "remix-template-remix", + private: true, + sideEffects: false, + type: "module", + dependencies: { + "@remix-run/css-bundle": "0.0.0-local-version", + "@remix-run/node": "0.0.0-local-version", + "@remix-run/react": "0.0.0-local-version", + "@remix-run/serve": "0.0.0-local-version", + isbot: "0.0.0-local-version", + react: "0.0.0-local-version", + "react-dom": "0.0.0-local-version", + }, + devDependencies: { + "@remix-run/dev": "0.0.0-local-version", + "@types/react": "0.0.0-local-version", + "@types/react-dom": "0.0.0-local-version", + typescript: "0.0.0-local-version", + + "@vanilla-extract/css": "0.0.0-local-version", + tailwindcss: "0.0.0-local-version", + }, + engines: { + node: ">=18.0.0", + }, + }), + + [tailwindConfigName]: tailwindConfig, + + "app/tailwind.css": css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + + "app/root.tsx": js` + import { Links, Outlet } from "@remix-run/react"; + import { cssBundleHref } from "@remix-run/css-bundle"; + import tailwindHref from "./tailwind.css" + export function links() { + return [ + { rel: "stylesheet", href: tailwindHref }, + { rel: "stylesheet", href: cssBundleHref } + ]; + } + export default function Root() { + return ( + + + + + + + + + ) + } + `, + ...basicUsageFixture(), + ...regularStylesSheetsFixture(), + ...cssModulesFixture(), + ...vanillaExtractClassCompositionFixture(), + ...vanillaExtractTailwindFunctionsFixture(), + ...cssSideEffectImportsFixture(), + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => appFixture.close()); + + let basicUsageFixture = () => ({ + "app/routes/basic-usage-test.tsx": js` + export default function() { + return ( +
    + Basic usage test +
    + ); + } + `, + }); + + test("basic usage", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/basic-usage-test"); + let locator = page.getByTestId("basic-usage"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let regularStylesSheetsFixture = () => ({ + "app/routes/regular-style-sheets-test.tsx": js` + import { Test, links as testLinks } from "~/test-components/regular-style-sheets"; + + export function links() { + return [...testLinks()]; + } + + export default function() { + return ; + } + `, + + "app/test-components/regular-style-sheets/index.tsx": js` + import stylesHref from "./styles.css"; + + export function links() { + return [{ rel: 'stylesheet', href: stylesHref }]; + } + + export function Test() { + return ( +
    + Regular style sheets test +
    + ); + } + `, + + "app/test-components/regular-style-sheets/styles.css": css` + .regular-style-sheets-test { + @apply p-test; + } + `, + }); + + test("regular style sheets", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/regular-style-sheets-test"); + let locator = page.getByTestId("regular-style-sheets"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let cssModulesFixture = () => ({ + "app/routes/css-modules-test.tsx": js` + import { Test } from "~/test-components/css-modules"; + + export default function() { + return ; + } + `, + + "app/test-components/css-modules/index.tsx": js` + import styles from "./styles.module.css"; + + export function Test() { + return ( +
    + CSS modules test +
    + ); + } + `, + + "app/test-components/css-modules/styles.module.css": css` + .root { + @apply p-test; + } + `, + }); + + test("CSS Modules", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/css-modules-test"); + let locator = page.getByTestId("css-modules"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let vanillaExtractClassCompositionFixture = () => ({ + "app/routes/vanilla-extract-class-composition-test.tsx": js` + import { Test } from "~/test-components/vanilla-extract-class-composition"; + + export default function() { + return ; + } + `, + + "app/test-components/vanilla-extract-class-composition/index.tsx": js` + import * as styles from "./styles.css"; + + export function Test() { + return ( +
    + Vanilla Extract class composition test +
    + ); + } + `, + + "app/test-components/vanilla-extract-class-composition/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const root = style([ + { background: 'peachpuff' }, + "p-test", + ]); + `, + }); + + test("Vanilla Extract class composition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/vanilla-extract-class-composition-test"); + let locator = page.getByTestId("vanilla-extract-class-composition"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let vanillaExtractTailwindFunctionsFixture = () => ({ + "app/routes/vanilla-extract-tailwind-functions-test.tsx": js` + import { Test } from "~/test-components/vanilla-extract-tailwind-functions"; + + export default function() { + return ; + } + `, + + "app/test-components/vanilla-extract-tailwind-functions/index.tsx": js` + import * as styles from "./styles.css"; + + export function Test() { + return ( +
    + Vanilla Extract Tailwind functions test +
    + ); + } + `, + + "app/test-components/vanilla-extract-tailwind-functions/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const root = style({ + background: 'peachpuff', + padding: 'theme(spacing.test)', + }); + `, + }); + + test("Vanilla Extract Tailwind functions", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/vanilla-extract-tailwind-functions-test"); + let locator = page.getByTestId("vanilla-extract-tailwind-functions"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let cssSideEffectImportsFixture = () => ({ + "app/routes/css-side-effect-imports-test.tsx": js` + import { Test } from "~/test-components/css-side-effect-imports"; + + export default function() { + return ; + } + `, + + "app/test-components/css-side-effect-imports/index.tsx": js` + import "./styles.css"; + + export function Test() { + return ( +
    + CSS side-effect imports test +
    + ); + } + `, + + "app/test-components/css-side-effect-imports/styles.css": css` + .css-side-effect-imports-test { + @apply p-test; + } + `, + }); + + test("CSS side-effect imports", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/css-side-effect-imports-test"); + let locator = page.getByTestId("css-side-effect-imports"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); +} + +test.describe("Tailwind enabled", () => { + for (let ext of extensions) { + test.describe(`tailwind.config.${ext}`, () => { + runTests(ext); + }); + } +}); + +test.describe("Tailwind disabled", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + tailwind: false, + }, + files: { + "tailwind.config.js": js` + module.exports = { + content: ["./app/**/*.{ts,tsx,jsx,js}"], + theme: { + spacing: { + 'test': ${JSON.stringify(TEST_PADDING_VALUE)} + }, + }, + }; + `, + + "app/tailwind.css": css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + + "app/root.tsx": js` + import { Links, Outlet } from "@remix-run/react"; + import tailwindHref from "./tailwind.css" + export function links() { + return [ + { rel: "stylesheet", href: tailwindHref }, + ]; + } + export default function Root() { + return ( + + + + + + + + + ) + } + `, + "app/routes/tailwind-disabled-test.tsx": js` + export default function() { + return ( +
    + Tailwind disabled test +
    + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => appFixture.close()); + + test("ignores Tailwind config", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/tailwind-disabled-test"); + let locator = page.getByTestId("tailwind-disabled"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).not.toBe(TEST_PADDING_VALUE); + }); +}); diff --git a/integration/transition-test.ts b/integration/transition-test.ts new file mode 100644 index 0000000000..15e16ee5f4 --- /dev/null +++ b/integration/transition-test.ts @@ -0,0 +1,319 @@ +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("rendering", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let PAGE = "page"; + let PAGE_TEXT = "PAGE_TEXT"; + let PAGE_INDEX_TEXT = "PAGE_INDEX_TEXT"; + let CHILD = "child"; + let CHILD_TEXT = "CHILD_TEXT"; + let REDIRECT = "redirect"; + let REDIRECT_HASH = "redirect-hash"; + let REDIRECT_TARGET = "page"; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
    + +
    + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link } from "@remix-run/react"; + export default function() { + return ( +
    +

    Index

    + ${PAGE} + ${REDIRECT} + ${REDIRECT_HASH} +
    + ); + } + `, + + [`app/routes/${PAGE}.jsx`]: js` + import { Outlet, useLoaderData } from "@remix-run/react"; + + export function loader() { + return "${PAGE_TEXT}" + } + + export default function() { + let text = useLoaderData(); + return ( + <> +

    {text}

    + + + ); + } + `, + + [`app/routes/${PAGE}._index.jsx`]: js` + import { useLoaderData, Link } from "@remix-run/react"; + + export function loader() { + return "${PAGE_INDEX_TEXT}" + } + + export default function() { + let text = useLoaderData(); + return ( + <> +

    {text}

    + ${CHILD} + + ); + } + `, + + [`app/routes/${PAGE}.${CHILD}.jsx`]: js` + import { useLoaderData } from "@remix-run/react"; + + export function loader() { + return "${CHILD_TEXT}" + } + + export default function() { + let text = useLoaderData(); + return

    {text}

    ; + } + `, + + [`app/routes/${REDIRECT}.jsx`]: js` + import { redirect } from "@remix-run/node"; + export function loader() { + return redirect("/${REDIRECT_TARGET}") + } + export default function() { + return null; + } + `, + + [`app/routes/${REDIRECT_HASH}.jsx`]: js` + import { redirect } from "@remix-run/node"; + export function loader() { + return redirect("/${REDIRECT_TARGET}#my-hash") + } + export default function() { + return null; + } + `, + + "app/routes/gh-1691.tsx": js` + import { json, redirect } from "@remix-run/node"; + import { useFetcher} from "@remix-run/react"; + + export const action = async ( ) => { + return redirect("/gh-1691"); + }; + + export const loader = async () => { + return json({}); + }; + + export default function GitHubIssue1691() { + const fetcher = useFetcher(); + + return ( +
    + {fetcher.state} + + + + +
    + ); + } + `, + + "app/routes/parent.tsx": js` + import { Outlet, useLoaderData } from "@remix-run/react"; + + if (!global.counts) { + global.count = 0; + global.counts = new Set(); + } + export const loader = async ({ request, context }) => { + let count = global.count; + if (!global.counts.has(context)) { + counts.add(context); + count = ++global.count; + } + return { count }; + }; + + export default function Parent() { + const data = useLoaderData(); + return ( +
    +
    {data.count}
    + +
    + ); + } + `, + + "app/routes/parent.child.tsx": js` + import { redirect } from "@remix-run/node"; + import { useFetcher} from "@remix-run/react"; + + export const action = async ({ request }) => { + return redirect("/parent"); + }; + + export default function Child() { + const fetcher = useFetcher(); + + return ( + + + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("calls all loaders for new routes", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + let responses = app.collectDataResponses(); + await app.clickLink(`/${PAGE}`); + await page.waitForLoadState("networkidle"); + + expect( + responses + .map((res) => new URL(res.url()).searchParams.get("_data")) + .sort() + ).toEqual([`routes/${PAGE}`, `routes/${PAGE}._index`].sort()); + + await page.waitForSelector(`h2:has-text("${PAGE_TEXT}")`); + await page.waitForSelector(`h3:has-text("${PAGE_INDEX_TEXT}")`); + }); + + test("calls only loaders for changing routes", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/${PAGE}`); + let responses = app.collectDataResponses(); + await app.clickLink(`/${PAGE}/${CHILD}`); + await page.waitForLoadState("networkidle"); + + expect( + responses.map((res) => new URL(res.url()).searchParams.get("_data")) + ).toEqual([`routes/${PAGE}.${CHILD}`]); + + await page.waitForSelector(`h2:has-text("${PAGE_TEXT}")`); + await page.waitForSelector(`h3:has-text("${CHILD_TEXT}")`); + }); + + test("loader redirect", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + let responses = app.collectDataResponses(); + + await app.clickLink(`/${REDIRECT}`); + await page.waitForURL(/\/page/); + await page.waitForLoadState("networkidle"); + + expect( + responses + .map((res) => new URL(res.url()).searchParams.get("_data")) + .sort() + ).toEqual( + [`routes/${REDIRECT}`, `routes/${PAGE}`, `routes/${PAGE}._index`].sort() + ); + + await page.waitForSelector(`h2:has-text("${PAGE_TEXT}")`); + await page.waitForSelector(`h3:has-text("${PAGE_INDEX_TEXT}")`); + }); + + test("loader redirect with hash", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await app.clickLink(`/${REDIRECT_HASH}`); + + await page.waitForURL(/\/page#my-hash/); + let url = new URL(page.url()); + expect(url.pathname).toBe(`/${REDIRECT_TARGET}`); + expect(url.hash).toBe(`#my-hash`); + }); + + test("calls changing routes on POP", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/${PAGE}`); + await app.clickLink(`/${PAGE}/${CHILD}`); + + let responses = app.collectDataResponses(); + await app.goBack(); + await page.waitForLoadState("networkidle"); + + expect( + responses.map((res) => new URL(res.url()).searchParams.get("_data")) + ).toEqual([`routes/${PAGE}._index`]); + + await page.waitForSelector(`h2:has-text("${PAGE_TEXT}")`); + await page.waitForSelector(`h3:has-text("${PAGE_INDEX_TEXT}")`); + }); + + test("useFetcher state should return to the idle when redirect from an action", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/gh-1691"); + expect(await app.getHtml("span")).toMatch("idle"); + + await app.waitForNetworkAfter(async () => { + await app.clickSubmitButton("/gh-1691"); + }); + await page.waitForSelector(`span:has-text("idle")`); + }); + + test("fetcher action redirects re-call parent loaders", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + await page.waitForSelector(`#parent:has-text("1")`); + + await app.clickElement("#fetcher-submit-redirect"); + await page.waitForSelector(`#parent:has-text("2")`); + }); +}); diff --git a/integration/tsconfig.json b/integration/tsconfig.json new file mode 100644 index 0000000000..50001a6f7f --- /dev/null +++ b/integration/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["**/*.ts"], + "exclude": ["helpers/*-template"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + "module": "ES2022", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "resolveJsonModule": true, + + "noEmit": true, + + "rootDir": "." + } +} diff --git a/integration/upload-test.ts b/integration/upload-test.ts new file mode 100644 index 0000000000..1918b7c299 --- /dev/null +++ b/integration/upload-test.ts @@ -0,0 +1,339 @@ +import * as path from "node:path"; +import * as url from "node:url"; +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; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); + +test.beforeAll(async () => { + fixture = await createFixture({ + config: { + browserNodeBuiltinsPolyfill: { + modules: { + url: true, + }, + }, + }, + files: { + "app/routes/file-upload-handler.tsx": js` + import * as path from "node:path"; + import * as url from "node:url"; + import { + json, + unstable_composeUploadHandlers as composeUploadHandlers, + unstable_createFileUploadHandler as createFileUploadHandler, + unstable_createMemoryUploadHandler as createMemoryUploadHandler, + unstable_parseMultipartFormData as parseMultipartFormData, + MaxPartSizeExceededError, + } from "@remix-run/node"; + import { Form, useActionData } from "@remix-run/react"; + + const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); + export let action = async ({ request }) => { + let uploadHandler = composeUploadHandlers( + createFileUploadHandler({ + directory: path.resolve(__dirname, "..", "uploads"), + maxPartSize: 13, + avoidFileConflicts: false, + file: ({ filename }) => filename, + }), + createMemoryUploadHandler(), + ); + + try { + let formData = await parseMultipartFormData(request, uploadHandler); + + if (formData.get("test") !== "hidden") { + return { message: "hidden field not in form data" }; + } + + let file = formData.get("file"); + let size = typeof file !== "string" && file ? file.size : 0; + + return json({ message: "SUCCESS", size }); + } catch (error) { + if (error instanceof MaxPartSizeExceededError) { + return json( + { message: "FILE_TOO_LARGE", size: error.maxBytes }, + { status: 413, headers: { "Connection": "close" } } + ); + } + return json({ message: "ERROR" }, 500); + } + }; + + export default function FileUpload() { + let { message, size } = useActionData() || {}; + return ( +
    +
    + + +
    + +
    + + {message &&

    {message}

    } + {size &&

    {size}

    } +
    +
    + ); + } + `, + + "app/routes/memory-upload-handler.tsx": js` + import { + json, + unstable_createMemoryUploadHandler as createMemoryUploadHandler, + unstable_parseMultipartFormData as parseMultipartFormData, + MaxPartSizeExceededError, + } from "@remix-run/node"; + import { Form, useActionData } from "@remix-run/react"; + + export let action = async ({ request }) => { + let uploadHandler = createMemoryUploadHandler({ + maxPartSize: 13, + }); + + try { + let formData = await parseMultipartFormData(request, uploadHandler); + + if (formData.get("test") !== "hidden") { + return { message: "hidden field not in form data" }; + } + + let file = formData.get("file"); + let size = typeof file !== "string" && file ? file.size : 0; + + return json({ message: "SUCCESS", size }); + } catch (error) { + if (error instanceof MaxPartSizeExceededError) { + return json( + { message: "FILE_TOO_LARGE", size: error.maxBytes }, + { status: 413, headers: { "Connection": "close" } } + ); + } + return json({ message: "ERROR" }, 500); + } + }; + + export default function MemoryUpload() { + let { message, size } = useActionData() || {}; + return ( +
    +
    + + +
    + +
    + + {message &&

    {message}

    } + {size &&

    {size}

    } +
    +
    + ); + } + `, + + "app/routes/passthrough-upload-handler.tsx": js` + import { + json, + unstable_parseMultipartFormData as parseMultipartFormData, + } from "@remix-run/node"; + import { Form, useActionData } from "@remix-run/react"; + + export let action = async ({ request }) => { + try { + let formData = await parseMultipartFormData(request, () => undefined); + + return json( + { message: "SUCCESS", size: 0 }, + ); + } catch (error) { + return json( + { message: "ERROR" }, + { status: 500, headers: { "Connection": "close" } } + ); + } + }; + + export default function PassthroughUpload() { + let { message, size } = useActionData() || {}; + return ( +
    +
    + + +
    + +
    + + {message &&

    {message}

    } + {size &&

    {size}

    } +
    +
    + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => { + appFixture.close(); +}); + +test("can upload a file with createFileUploadHandler", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/file-upload-handler"); + await app.uploadFile("#file", path.resolve(__dirname, "assets/toupload.txt")); + await app.clickSubmitButton("/file-upload-handler"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">SUCCESS<"); + expect(await app.getHtml("#size")).toMatch(">13<"); +}); + +test("can catch MaxPartSizeExceededError when file is too big with createFileUploadHandler", async ({ + page, +}) => { + test.slow(); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/file-upload-handler"); + await app.uploadFile( + "#file", + path.resolve(__dirname, "assets/touploadtoobig.txt") + ); + await app.clickSubmitButton("/file-upload-handler"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">FILE_TOO_LARGE<"); + expect(await app.getHtml("#size")).toMatch(">13<"); +}); + +test("can upload a file with createMemoryUploadHandler", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/memory-upload-handler"); + await app.uploadFile("#file", path.resolve(__dirname, "assets/toupload.txt")); + await app.clickSubmitButton("/memory-upload-handler"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">SUCCESS<"); + expect(await app.getHtml("#size")).toMatch(">13<"); +}); + +test("can upload a file with a passthrough handler", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/passthrough-upload-handler"); + await app.uploadFile("#file", path.resolve(__dirname, "assets/toupload.txt")); + await app.clickSubmitButton("/passthrough-upload-handler"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">SUCCESS<"); +}); + +test("can catch MaxPartSizeExceededError when file is too big with createMemoryUploadHandler", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/memory-upload-handler"); + await app.uploadFile( + "#file", + path.resolve(__dirname, "assets/touploadtoobig.txt") + ); + await app.clickSubmitButton("/memory-upload-handler"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">FILE_TOO_LARGE<"); + expect(await app.getHtml("#size")).toMatch(">13<"); +}); + +test.describe("without javascript", () => { + test.use({ javaScriptEnabled: false }); + + test("can upload a file with createFileUploadHandler", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/file-upload-handler"); + await app.uploadFile( + "#file", + path.resolve(__dirname, "assets/toupload.txt") + ); + await page.click("#submit"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">SUCCESS<"); + expect(await app.getHtml("#size")).toMatch(">13<"); + }); + + test("can catch MaxPartSizeExceededError when file is too big with createFileUploadHandler", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/file-upload-handler"); + await app.uploadFile( + "#file", + path.resolve(__dirname, "assets/touploadtoobig.txt") + ); + await page.click("#submit"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">FILE_TOO_LARGE<"); + expect(await app.getHtml("#size")).toMatch(">13<"); + }); + + test("can upload a file with createMemoryUploadHandler", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/memory-upload-handler"); + await app.uploadFile( + "#file", + path.resolve(__dirname, "assets/toupload.txt") + ); + await page.click("#submit"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">SUCCESS<"); + expect(await app.getHtml("#size")).toMatch(">13<"); + }); + + test("can upload a file with passthrough handler", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/passthrough-upload-handler"); + await app.uploadFile( + "#file", + path.resolve(__dirname, "assets/toupload.txt") + ); + await page.click("#submit"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">SUCCESS<"); + }); + + test("can catch MaxPartSizeExceededError when file is too big with createMemoryUploadHandler", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/memory-upload-handler"); + await app.uploadFile( + "#file", + path.resolve(__dirname, "assets/touploadtoobig.txt") + ); + await page.click("#submit"); + await page.waitForSelector("#message"); + + expect(await app.getHtml("#message")).toMatch(">FILE_TOO_LARGE<"); + expect(await app.getHtml("#size")).toMatch(">13<"); + }); +}); diff --git a/integration/vanilla-extract-test.ts b/integration/vanilla-extract-test.ts new file mode 100644 index 0000000000..a1823c6f17 --- /dev/null +++ b/integration/vanilla-extract-test.ts @@ -0,0 +1,675 @@ +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, + json, +} from "./helpers/create-fixture.js"; + +const TEST_PADDING_VALUE = "20px"; + +test.describe("Vanilla Extract", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "package.json": json({ + name: "remix-template-remix", + private: true, + sideEffects: false, + type: "module", + dependencies: { + "@remix-run/css-bundle": "0.0.0-local-version", + "@remix-run/node": "0.0.0-local-version", + "@remix-run/react": "0.0.0-local-version", + "@remix-run/serve": "0.0.0-local-version", + isbot: "0.0.0-local-version", + react: "0.0.0-local-version", + "react-dom": "0.0.0-local-version", + }, + devDependencies: { + "@remix-run/dev": "0.0.0-local-version", + "@types/react": "0.0.0-local-version", + "@types/react-dom": "0.0.0-local-version", + typescript: "0.0.0-local-version", + + "@vanilla-extract/css": "0.0.0-local-version", + }, + engines: { + node: ">=18.0.0", + }, + }), + + "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 ( + + + + + + + + + ) + } + `, + ...typeScriptFixture(), + ...javaScriptFixture(), + ...classCompositionFixture(), + ...rootRelativeClassCompositionFixture(), + ...sideEffectImportsFixture(), + ...sideEffectImportsWithinChildCompilationFixture(), + ...stableIdentifiersFixture(), + ...imageUrlsViaCssUrlFixture(), + ...imageUrlsViaRootRelativeCssUrlFixture(), + ...imageUrlsViaAbsoluteCssUrlFixture(), + ...imageUrlsViaJsImportFixture(), + ...imageUrlsViaRootRelativeJsImportFixture(), + ...imageUrlsViaClassCompositionFixture(), + ...imageUrlsViaJsImportClassCompositionFixture(), + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => appFixture.close()); + + let typeScriptFixture = () => ({ + "app/fixtures/typescript/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const root = style({ + background: 'peachpuff', + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/routes/typescript-test.tsx": js` + import * as styles from "../fixtures/typescript/styles.css"; + + export default function() { + return ( +
    + TypeScript test +
    + ) + } + `, + }); + test("TypeScript", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/typescript-test"); + let locator = await page.locator("[data-testid='typescript']"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let javaScriptFixture = () => ({ + "app/fixtures/javascript/styles.css.js": js` + import { style } from "@vanilla-extract/css"; + + export const root = style({ + background: 'peachpuff', + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/routes/javascript-test.tsx": js` + import * as styles from "../fixtures/javascript/styles.css"; + + export default function() { + return ( +
    + javaScript test +
    + ) + } + `, + }); + test("JavaScript", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/javascript-test"); + let locator = await page.locator("[data-testid='javascript']"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let classCompositionFixture = () => ({ + "app/fixtures/class-composition/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + import { padding } from "./padding.css"; + + export const root = style([ + padding, + { background: 'peachpuff' }, + ]); + `, + "app/fixtures/class-composition/padding.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const padding = style({ + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/routes/class-composition-test.tsx": js` + import * as styles from "../fixtures/class-composition/styles.css"; + + export default function() { + return ( +
    + Class composition test +
    + ) + } + `, + }); + test("class composition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/class-composition-test"); + let locator = await page.locator("[data-testid='class-composition']"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let rootRelativeClassCompositionFixture = () => ({ + "app/fixtures/root-relative-class-composition/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + import { padding } from "~/fixtures/root-relative-class-composition/padding.css"; + + export const root = style([ + padding, + { background: 'peachpuff' }, + ]); + `, + "app/fixtures/root-relative-class-composition/padding.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const padding = style({ + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/routes/root-relative-class-composition-test.tsx": js` + import * as styles from "../fixtures/root-relative-class-composition/styles.css"; + + export default function() { + return ( +
    + Root-relative class composition test +
    + ) + } + `, + }); + test("root-relative class composition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/root-relative-class-composition-test"); + let locator = await page.locator( + "[data-testid='root-relative-class-composition']" + ); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let sideEffectImportsFixture = () => ({ + "app/fixtures/side-effect-imports/styles.css.ts": js` + import { globalStyle } from "@vanilla-extract/css"; + + globalStyle(".side-effect-imports", { + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/routes/side-effect-imports-test.tsx": js` + import "../fixtures/side-effect-imports/styles.css"; + + export default function() { + return ( +
    + Side-effect imports test +
    + ) + } + `, + }); + test("side-effect imports", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/side-effect-imports-test"); + let locator = await page.locator("[data-testid='side-effect-imports']"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let sideEffectImportsWithinChildCompilationFixture = () => ({ + "app/fixtures/side-effect-imports-within-child-compilation/styles.css.ts": js` + import "./nested-side-effect.css"; + `, + "app/fixtures/side-effect-imports-within-child-compilation/nested-side-effect.css.ts": js` + import { globalStyle } from "@vanilla-extract/css"; + + globalStyle(".side-effect-imports-within-child-compilation", { + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/routes/side-effect-imports-within-child-compilation-test.tsx": js` + import "../fixtures/side-effect-imports-within-child-compilation/styles.css"; + + export default function() { + return ( +
    + Side-effect imports within child compilation test +
    + ) + } + `, + }); + test("side-effect imports within child compilation", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/side-effect-imports-within-child-compilation-test"); + let locator = await page.locator( + "[data-testid='side-effect-imports-within-child-compilation']" + ); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let stableIdentifiersFixture = () => ({ + "app/fixtures/stable-identifiers/styles_a.css.ts": js` + import { style } from "@vanilla-extract/css"; + import { shared } from "./shared.css"; + + export const root = shared; + `, + "app/fixtures/stable-identifiers/styles_b.css.ts": js` + import { style } from "@vanilla-extract/css"; + import { shared } from "./shared.css"; + + export const root = shared; + `, + "app/fixtures/stable-identifiers/shared.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const shared = style({ + padding: ${JSON.stringify(TEST_PADDING_VALUE)}, + background: 'peachpuff', + }); + `, + "app/routes/stable-identifiers-test.tsx": js` + import * as styles_a from "../fixtures/stable-identifiers/styles_a.css"; + import * as styles_b from "../fixtures/stable-identifiers/styles_b.css"; + + const styles = new Set([styles_a.root, styles_b.root]); + + export default function() { + return ( +
    + Stable identifiers test +
    + ) + } + `, + }); + test("stable identifiers", async ({ page }) => { + // This test ensures that file scoping is working as expected and + // identifiers are stable across different .css.ts contexts. We test this by + // using the same shared style in two different .css.ts files and then + // asserting that it's the same class name. + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/stable-identifiers-test"); + let locator = await page.locator("[data-testid='stable-identifiers']"); + let { padding, classList } = await locator.evaluate((element) => ({ + padding: window.getComputedStyle(element).padding, + classList: Array.from(element.classList), + })); + expect(padding).toBe(TEST_PADDING_VALUE); + expect(classList.length).toBe(1); + }); + + let imageUrlsViaCssUrlFixture = () => ({ + "app/fixtures/imageUrlsViaCssUrl/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const root = style({ + backgroundColor: 'peachpuff', + backgroundImage: 'url("./image.svg")', + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/fixtures/imageUrlsViaCssUrl/image.svg": ` + + + + `, + "app/routes/image-urls-via-css-url-test.tsx": js` + import * as styles from "../fixtures/imageUrlsViaCssUrl/styles.css"; + + export default function() { + return ( +
    + Image URLs via CSS URL test +
    + ) + } + `, + }); + test("image URLs via CSS URL", 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-via-css-url-test"); + let locator = await page.locator("[data-testid='image-urls-via-css-url']"); + let backgroundImage = await locator.evaluate( + (element) => window.getComputedStyle(element).backgroundImage + ); + expect(backgroundImage).toContain(".svg"); + expect(imgStatus).toBe(200); + }); + + let imageUrlsViaRootRelativeCssUrlFixture = () => ({ + "app/fixtures/imageUrlsViaRootRelativeCssUrl/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const root = style({ + backgroundColor: 'peachpuff', + backgroundImage: 'url("~/fixtures/imageUrlsViaRootRelativeCssUrl/image.svg")', + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/fixtures/imageUrlsViaRootRelativeCssUrl/image.svg": ` + + + + `, + "app/routes/image-urls-via-root-relative-css-url-test.tsx": js` + import * as styles from "../fixtures/imageUrlsViaRootRelativeCssUrl/styles.css"; + + export default function() { + return ( +
    + Image URLs via CSS URL test +
    + ) + } + `, + }); + test("image URLs via root-relative CSS URL", 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-via-root-relative-css-url-test"); + let locator = await page.locator( + "[data-testid='image-urls-via-root-relative-css-url']" + ); + let backgroundImage = await locator.evaluate( + (element) => window.getComputedStyle(element).backgroundImage + ); + expect(backgroundImage).toContain(".svg"); + expect(imgStatus).toBe(200); + }); + + let imageUrlsViaAbsoluteCssUrlFixture = () => ({ + "app/fixtures/imageUrlsViaAbsoluteCssUrl/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const root = style({ + backgroundColor: 'peachpuff', + backgroundImage: 'url("/imageUrlsViaAbsoluteCssUrl/image.svg")', + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "public/imageUrlsViaAbsoluteCssUrl/image.svg": ` + + + + `, + "app/routes/image-urls-via-absolute-css-url-test.tsx": js` + import * as styles from "../fixtures/imageUrlsViaAbsoluteCssUrl/styles.css"; + + export default function() { + return ( +
    + Image URLs via absolute CSS URL test +
    + ) + } + `, + }); + test("image URLs via absolute CSS URL", 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-via-absolute-css-url-test"); + let locator = await page.locator( + "[data-testid='image-urls-via-absolute-css-url']" + ); + let backgroundImage = await locator.evaluate( + (element) => window.getComputedStyle(element).backgroundImage + ); + expect(backgroundImage).toContain(".svg"); + expect(imgStatus).toBe(200); + }); + + let imageUrlsViaJsImportFixture = () => ({ + "app/fixtures/imageUrlsViaJsImport/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + import href from "./image.svg"; + + export const root = style({ + backgroundColor: 'peachpuff', + backgroundImage: 'url(' + href + ')', + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/fixtures/imageUrlsViaJsImport/image.svg": ` + + + + `, + "app/routes/image-urls-via-js-import-test.tsx": js` + import * as styles from "../fixtures/imageUrlsViaJsImport/styles.css"; + + export default function() { + return ( +
    + Image URLs via JS import test +
    + ) + } + `, + }); + test("image URLs via JS import", 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-via-js-import-test"); + let locator = await page.locator( + "[data-testid='image-urls-via-js-import']" + ); + let backgroundImage = await locator.evaluate( + (element) => window.getComputedStyle(element).backgroundImage + ); + expect(backgroundImage).toContain(".svg"); + expect(imgStatus).toBe(200); + }); + + let imageUrlsViaRootRelativeJsImportFixture = () => ({ + "app/fixtures/imageUrlsViaRootRelativeJsImport/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + import href from "~/fixtures/imageUrlsViaRootRelativeJsImport/image.svg"; + + export const root = style({ + backgroundColor: 'peachpuff', + backgroundImage: 'url(' + href + ')', + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + }); + `, + "app/fixtures/imageUrlsViaRootRelativeJsImport/image.svg": ` + + + + `, + "app/routes/image-urls-via-root-relative-js-import-test.tsx": js` + import * as styles from "../fixtures/imageUrlsViaRootRelativeJsImport/styles.css"; + + export default function() { + return ( +
    + Image URLs via root-relative JS import test +
    + ) + } + `, + }); + test("image URLs via root-relative JS import", 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-via-root-relative-js-import-test"); + let locator = await page.locator( + "[data-testid='image-urls-via-root-relative-js-import']" + ); + let backgroundImage = await locator.evaluate( + (element) => window.getComputedStyle(element).backgroundImage + ); + expect(backgroundImage).toContain(".svg"); + expect(imgStatus).toBe(200); + }); + + let imageUrlsViaClassCompositionFixture = () => ({ + "app/fixtures/imageUrlsViaClassComposition/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + import { backgroundImage } from "./nested/backgroundImage.css"; + + export const root = style([ + backgroundImage, + { + backgroundColor: 'peachpuff', + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + } + ]); + `, + "app/fixtures/imageUrlsViaClassComposition/nested/backgroundImage.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const backgroundImage = style({ + backgroundImage: 'url(../image.svg)', + }); + `, + "app/fixtures/imageUrlsViaClassComposition/image.svg": ` + + + + `, + "app/routes/image-urls-via-class-composition-test.tsx": js` + import * as styles from "../fixtures/imageUrlsViaClassComposition/styles.css"; + + export default function() { + return ( +
    + Image URLs via class composition test +
    + ) + } + `, + }); + test("image URLs via class composition", 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-via-class-composition-test"); + let locator = await page.locator( + "[data-testid='image-urls-via-class-composition']" + ); + let backgroundImage = await locator.evaluate( + (element) => window.getComputedStyle(element).backgroundImage + ); + expect(backgroundImage).toContain(".svg"); + expect(imgStatus).toBe(200); + }); + + let imageUrlsViaJsImportClassCompositionFixture = () => ({ + "app/fixtures/imageUrlsViaJsImportClassComposition/styles.css.ts": js` + import { style } from "@vanilla-extract/css"; + import { backgroundImage } from "./nested/backgroundImage.css"; + + export const root = style([ + backgroundImage, + { + backgroundColor: 'peachpuff', + padding: ${JSON.stringify(TEST_PADDING_VALUE)} + } + ]); + `, + "app/fixtures/imageUrlsViaJsImportClassComposition/nested/backgroundImage.css.ts": js` + import { style } from "@vanilla-extract/css"; + import href from "../image.svg"; + + export const backgroundImage = style({ + backgroundImage: 'url(' + href + ')', + }); + `, + "app/fixtures/imageUrlsViaJsImportClassComposition/image.svg": ` + + + + `, + "app/routes/image-urls-via-js-import-class-composition-test.tsx": js` + import * as styles from "../fixtures/imageUrlsViaJsImportClassComposition/styles.css"; + + export default function() { + return ( +
    + Image URLs via class composition test +
    + ) + } + `, + }); + test("image URLs via JS import class composition", 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-via-js-import-class-composition-test"); + let locator = await page.locator( + "[data-testid='image-urls-via-js-import-class-composition']" + ); + let backgroundImage = await locator.evaluate( + (element) => window.getComputedStyle(element).backgroundImage + ); + expect(backgroundImage).toContain(".svg"); + expect(imgStatus).toBe(200); + }); +}); diff --git a/integration/vite-basename-test.ts b/integration/vite-basename-test.ts new file mode 100644 index 0000000000..115a653cc5 --- /dev/null +++ b/integration/vite-basename-test.ts @@ -0,0 +1,519 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { + createEditor, + createProject, + customDev, + viteBuild, + viteConfig, + viteDev, + viteDevCmd, + viteRemixServe, +} from "./helpers/vite.js"; +import { js } from "./helpers/create-fixture.js"; + +const files = { + "app/routes/_index.tsx": String.raw` + import { useState, useEffect } from "react"; + import { Link } from "@remix-run/react" + + export default function IndexRoute() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( +
    +

    Index

    + +

    Mounted: {mounted ? "yes" : "no"}

    +

    HMR updated: 0

    + other +
    + ); + } + `, + "app/routes/other.tsx": String.raw` + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => { + return "other-loader"; + }; + + export default function OtherRoute() { + const loaderData = useLoaderData() + + return ( +
    +

    {loaderData}

    +
    + ); + } + `, +}; + +async function viteConfigFile({ + port, + base, + basename, +}: { + port: number; + base?: string; + basename?: string; +}) { + return js` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + ${base !== "/" ? 'base: "' + base + '",' : ""} + ${await viteConfig.server({ port })} + plugins: [ + ${ + basename !== "/" + ? 'remix({ basename: "' + basename + '" }),' + : "remix()," + } + ] + } + `; +} + +const customServerFile = ({ + port, + base, + basename, +}: { + port: number; + base?: string; + basename?: string; +}) => { + base = base ?? "/mybase/"; + basename = basename ?? base; + + return String.raw` + import { createRequestHandler } from "@remix-run/express"; + import { installGlobals } from "@remix-run/node"; + import express from "express"; + installGlobals(); + + const viteDevServer = + process.env.NODE_ENV === "production" + ? undefined + : await import("vite").then(({ createServer }) => + createServer({ + server: { + middlewareMode: true, + }, + }) + ); + + const app = express(); + app.use("${base}", viteDevServer?.middlewares || express.static("build/client")); + app.all( + "${basename}*", + createRequestHandler({ + build: viteDevServer + ? () => viteDevServer.ssrLoadModule("virtual:remix/server-build") + : await import("./build/server/index.js"), + }) + ); + app.get("*", (_req, res) => { + res.setHeader("content-type", "text/html") + res.end('Remix app is at ${basename}'); + }); + + const port = ${port}; + app.listen(port, () => console.log('http://localhost:' + port)); + `; +}; + +test.describe("Vite base / Remix basename / Vite dev", () => { + let port: number; + let cwd: string; + let stop: () => unknown; + + async function setup({ + base, + basename, + startServer, + }: { + base: string; + basename: string; + startServer?: boolean; + }) { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ port, base, basename }), + ...files, + }); + if (startServer !== false) { + stop = await viteDev({ cwd, port, basename }); + } + } + + test.afterAll(async () => await stop()); + + test("works when the base and basename are the same", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/" }); + await workflowDev({ page, cwd, port }); + }); + + test("works when the base and basename are different", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/app/" }); + await workflowDev({ page, cwd, port, basename: "/mybase/app/" }); + }); + + test("errors if basename does not start with base", async ({ page }) => { + await setup({ + base: "/mybase/", + basename: "/notmybase/", + startServer: false, + }); + let proc = await viteDevCmd({ cwd }); + expect(proc.stderr.toString()).toMatch( + "Error: When using the Remix `basename` and the Vite `base` config, the " + + "`basename` config must begin with `base` for the default Vite dev server." + ); + }); +}); + +test.describe("Vite base / Remix basename / express dev", async () => { + let port: number; + let cwd: string; + let stop: () => void; + + async function setup({ + base, + basename, + startServer, + }: { + base: string; + basename: string; + startServer?: boolean; + }) { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ port, base, basename }), + "server.mjs": customServerFile({ port, basename }), + ...files, + }); + if (startServer !== false) { + stop = await customDev({ cwd, port, basename }); + } + } + + test.afterAll(() => stop()); + + test("works when base and basename are the same", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/" }); + await workflowDev({ page, cwd, port }); + }); + + test("works when base and basename are different", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/app/" }); + await workflowDev({ page, cwd, port, basename: "/mybase/app/" }); + }); + + test("works when basename does not start with base", async ({ page }) => { + await setup({ + base: "/mybase/", + basename: "/notmybase/", + }); + await workflowDev({ page, cwd, port, basename: "/notmybase/" }); + }); +}); + +async function workflowDev({ + page, + cwd, + port, + base, + basename, +}: { + page: Page; + cwd: string; + port: number; + base?: string; + basename?: string; +}) { + base = base ?? "/mybase/"; + basename = basename ?? base; + + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + let edit = createEditor(cwd); + + let requestUrls: string[] = []; + page.on("request", (request) => { + requestUrls.push(request.url()); + }); + + // setup: initial render at basename + await page.goto(`http://localhost:${port}${basename}`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + + // setup: hydration + await expect(page.locator("#index [data-mounted]")).toHaveText( + "Mounted: yes" + ); + + // setup: browser state + let hmrStatus = page.locator("#index [data-hmr]"); + await expect(hmrStatus).toHaveText("HMR updated: 0"); + let input = page.locator("#index input"); + await expect(input).toBeVisible(); + await input.type("stateful"); + expect(pageErrors).toEqual([]); + + // route: HMR + await edit("app/routes/_index.tsx", (contents) => + contents.replace("HMR updated: 0", "HMR updated: 1") + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: 1"); + await expect(input).toHaveValue("stateful"); + expect(pageErrors).toEqual([]); + + // client side navigation + await page.getByRole("link", { name: "other" }).click(); + await page.waitForURL(`http://localhost:${port}${basename}other`); + await page.getByText("other-loader").click(); + expect(pageErrors).toEqual([]); + + let isAssetRequest = (url: string) => + /\.[jt]sx?/.test(url) || + /@id\/__x00__virtual:/.test(url) || + /@vite\/client/.test(url) || + /node_modules\/vite\/dist\/client\/env/.test(url); + + // verify client asset requests are all under base + expect( + requestUrls + .filter((url) => isAssetRequest(url)) + .every((url) => url.startsWith(`http://localhost:${port}${base}`)) + ).toBe(true); + + // verify client route requests are all under basename + expect( + requestUrls + .filter((url) => !isAssetRequest(url)) + .every((url) => url.startsWith(`http://localhost:${port}${basename}`)) + ).toBe(true); +} + +test.describe("Vite base / Remix basename / vite build", () => { + let port: number; + let cwd: string; + let stop: () => unknown; + + async function setup({ + base, + basename, + startServer, + }: { + base: string; + basename: string; + startServer?: boolean; + }) { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ port, base, basename }), + ...files, + }); + viteBuild({ cwd }); + if (startServer !== false) { + stop = await viteRemixServe({ cwd, port, basename }); + } + } + + test.afterAll(() => stop()); + + test("works when base and basename are the same", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/" }); + await workflowBuild({ page, port }); + }); + + test("works when base and basename are different", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/app/" }); + await workflowBuild({ page, port, basename: "/mybase/app/" }); + }); + + test("works when basename does not start with base", async ({ page }) => { + await setup({ + base: "/mybase/", + basename: "/notmybase/", + }); + await workflowBuild({ page, port, basename: "/notmybase/" }); + }); +}); + +test.describe("Vite base / Remix basename / express build", async () => { + let port: number; + let cwd: string; + let stop: () => void; + + async function setup({ + base, + basename, + startServer, + }: { + base: string; + basename: string; + startServer?: boolean; + }) { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ port, base, basename }), + "server.mjs": customServerFile({ port, base, basename }), + ...files, + }); + viteBuild({ cwd }); + if (startServer !== false) { + stop = await customDev({ + cwd, + port, + basename, + env: { NODE_ENV: "production" }, + }); + } + } + + test.afterAll(() => stop()); + + test("works when base and basename are the same", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/" }); + await workflowBuild({ page, port }); + }); + + test("works when base and basename are different", async ({ page }) => { + await setup({ base: "/mybase/", basename: "/mybase/app/" }); + await workflowBuild({ page, port, basename: "/mybase/app/" }); + }); + + test("works when basename does not start with base", async ({ page }) => { + await setup({ + base: "/mybase/", + basename: "/notmybase/", + }); + await workflowBuild({ page, port, basename: "/notmybase/" }); + }); + + test("works when when base is an absolute external URL", async ({ page }) => { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfigFile({ + port, + base: "https://cdn.example.com/assets/", + basename: "/app/", + }), + // Slim server that only serves basename (route) requests from the remix handler + "server.mjs": String.raw` + import { createRequestHandler } from "@remix-run/express"; + import { installGlobals } from "@remix-run/node"; + import express from "express"; + installGlobals(); + + const app = express(); + app.all( + "/app/*", + createRequestHandler({ build: await import("./build/server/index.js") }) + ); + + const port = ${port}; + app.listen(port, () => console.log('http://localhost:' + port)); + `, + ...files, + }); + + viteBuild({ cwd }); + stop = await customDev({ + cwd, + port, + basename: "/app/", + env: { NODE_ENV: "production" }, + }); + + // Intercept and make all CDN requests 404 + let requestUrls: string[] = []; + await page.route("**/*.js", (route) => { + requestUrls.push(route.request().url()); + route.fulfill({ status: 404 }); + }); + + // setup: initial render + await page.goto(`http://localhost:${port}/app/`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + + // Can't validate hydration here due to 404s, but we can ensure assets are + // attempting to load from the CDN + expect( + requestUrls.length > 0 && + requestUrls.every((url) => + url.startsWith("https://cdn.example.com/assets/") + ) + ).toBe(true); + }); +}); + +async function workflowBuild({ + page, + port, + base, + basename, +}: { + page: Page; + port: number; + base?: string; + basename?: string; +}) { + base = base ?? "/mybase/"; + basename = basename ?? base; + + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + let requestUrls: string[] = []; + page.on("request", (request) => { + requestUrls.push(request.url()); + }); + + // setup: initial render + await page.goto(`http://localhost:${port}${basename}`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + + // setup: hydration + await expect(page.locator("#index [data-mounted]")).toHaveText( + "Mounted: yes" + ); + + // client side navigation + await page.getByRole("link", { name: "other" }).click(); + await page.waitForURL(`http://localhost:${port}${basename}other`); + await page.getByText("other-loader").click(); + expect(pageErrors).toEqual([]); + + let isAssetRequest = (url: string) => /\.js/.test(url); + + // verify client asset requests are all under base + expect( + requestUrls + .filter((url) => isAssetRequest(url)) + .every((url) => url.startsWith(`http://localhost:${port}${base}`)) + ).toBe(true); + + // verify client route requests are all under basename + expect( + requestUrls + .filter((url) => !isAssetRequest(url)) + .every((url) => url.startsWith(`http://localhost:${port}${basename}`)) + ).toBe(true); +} diff --git a/integration/vite-build-test.ts b/integration/vite-build-test.ts new file mode 100644 index 0000000000..dd6c7681b6 --- /dev/null +++ b/integration/vite-build-test.ts @@ -0,0 +1,380 @@ +import * as path from "node:path"; +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; +import glob from "glob"; + +import { + createProject, + viteBuild, + viteRemixServe, + viteConfig, + grep, +} from "./helpers/vite.js"; + +let port: number; +let cwd: string; +let stop: () => void; + +const js = String.raw; + +test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "remix.config.js": js` + throw new Error("Remix should not access remix.config.js when using Vite"); + export default {}; + `, + ".env": ` + ENV_VAR_FROM_DOTENV_FILE=true + `, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + import mdx from "@mdx-js/rollup"; + + export default defineConfig({ + ${await viteConfig.server({ port })} + build: { + // force emitting asset files instead of inlined as data-url + assetsInlineLimit: 0, + }, + plugins: [ + mdx(), + remix(), + ], + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
    +

    Root

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

    Index

    + {!mounted ?

    Loading...

    :

    Mounted

    } + + ); + } + `, + "app/utils.server.ts": js` + export const serverOnly1 = "SERVER_ONLY_1" + export const serverOnly2 = "SERVER_ONLY_2" + `, + "app/routes/resource.ts": js` + import { json } from "@remix-run/node"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return json({ serverOnly1 }) + } + + export const action = () => { + console.log(serverOnly2) + return null + } + `, + "app/routes/mdx.mdx": js` + import { useEffect, useState } from "react"; + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + import { serverOnly1, serverOnly2 } from "../utils.server"; + + export const loader = () => { + return json({ + serverOnly1, + content: "MDX route content from loader", + }) + } + + export const action = () => { + console.log(serverOnly2) + return null + } + + export function MdxComponent() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + const { content } = useLoaderData(); + const text = content + (mounted + ? ": mounted" + : ": not mounted"); + return
    {text}
    + } + + ## MDX Route + + + `, + "app/routes/code-split1.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; + + export default function CodeSplit1Route() { + return
    ; + } + `, + "app/routes/code-split2.tsx": js` + import { CodeSplitComponent } from "../code-split-component"; + + export default function CodeSplit2Route() { + return
    ; + } + `, + "app/code-split-component.tsx": js` + import classes from "./code-split.module.css"; + + export function CodeSplitComponent() { + return ok + } + `, + "app/code-split.module.css": js` + .test { + background-color: rgb(255, 170, 0); + } + `, + "app/routes/dotenv.tsx": js` + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => { + return json({ + loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE ?? '.env file was NOT loaded, which is a good thing', + }) + } + + export default function DotenvRoute() { + const { loaderContent } = useLoaderData(); + + return
    {loaderContent}
    ; + } + `, + + "app/assets/test.txt": "test", + "app/routes/ssr-only-assets.tsx": js` + import txtUrl from "../assets/test.txt?url"; + import { useLoaderData } from "@remix-run/react" + + export const loader: LoaderFunction = () => { + return { txtUrl }; + }; + + export default function SsrOnlyAssetsRoute() { + const loaderData = useLoaderData(); + return ( +
    + txtUrl +
    + ); + } + `, + + "app/assets/test.css": ".test{color:red}", + "app/routes/ssr-only-css-url-files.tsx": js` + import cssUrl from "../assets/test.css?url"; + import { useLoaderData } from "@remix-run/react" + + export const loader: LoaderFunction = () => { + return { cssUrl }; + }; + + export default function SsrOnlyCssUrlFilesRoute() { + const loaderData = useLoaderData(); + return ( +
    + cssUrl +
    + ); + } + `, + + "app/routes/ssr-code-split.tsx": js` + import { useLoaderData } from "@remix-run/react" + + export const loader: LoaderFunction = async () => { + const lib = await import("../ssr-code-split-lib"); + return lib.ssrCodeSplitTest(); + }; + + export default function SsrCodeSplitRoute() { + const loaderData = useLoaderData(); + return ( +
    {loaderData}
    + ); + } + `, + + "app/ssr-code-split-lib.ts": js` + export function ssrCodeSplitTest() { + return "ssrCodeSplitTest"; + } + `, + }); + + let { stderr, status } = viteBuild({ cwd }); + expect( + stderr + .toString() + // This can be removed when this issue is fixed: https://github.com/remix-run/remix/issues/9055 + .replace('Generated an empty chunk: "resource".', "") + .trim() + ).toBeFalsy(); + expect(status).toBe(0); + stop = await viteRemixServe({ cwd, port }); +}); +test.afterAll(() => stop()); + +test("Vite / build / server code is removed from client build", async () => { + expect(grep(path.join(cwd, "build/client"), /SERVER_ONLY_1/).length).toBe(0); + expect(grep(path.join(cwd, "build/client"), /SERVER_ONLY_2/).length).toBe(0); +}); + +test("Vite / build / renders matching MDX routes", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/mdx`, { + waitUntil: "networkidle", + }); + await expect(page.locator("[data-mdx-route]")).toHaveText( + "MDX route content from loader: mounted" + ); + expect(pageErrors).toEqual([]); +}); + +test("Vite / build / emits SSR-only assets to the client assets directory", async ({ + page, +}) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/ssr-only-assets`, { + waitUntil: "networkidle", + }); + + await page.getByRole("link", { name: "txtUrl" }).click(); + await page.waitForURL("**/assets/test-*.txt"); + await expect(page.getByText("test")).toBeVisible(); + expect(pageErrors).toEqual([]); +}); + +test("Vite / build /emits SSR-only .css?url files to the client assets directory", async ({ + page, +}) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/ssr-only-css-url-files`, { + waitUntil: "networkidle", + }); + + await page.getByRole("link", { name: "cssUrl" }).click(); + await page.waitForURL("**/assets/test-*.css"); + await expect(page.getByText(".test{")).toBeVisible(); + expect(pageErrors).toEqual([]); +}); + +test("Vite / build / supports code-split JS from SSR build", async ({ + page, +}) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/ssr-code-split`, { + waitUntil: "networkidle", + }); + + await expect(page.locator("[data-ssr-code-split]")).toHaveText( + "ssrCodeSplitTest" + ); + expect(pageErrors).toEqual([]); +}); + +test("Vite / build / removes assets (other than code-split JS) and CSS files from SSR build", async () => { + let assetFiles = glob.sync("build/server/assets/**/*", { cwd }); + let [asset, ...rest] = assetFiles; + expect(rest).toEqual([]); // Provide more useful test output if this fails + expect(asset).toMatch(/ssr-code-split-lib-.*\.js/); +}); + +test("Vite / build / supports code-split CSS", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/code-split1`, { + waitUntil: "networkidle", + }); + expect( + await page + .locator("#code-split1 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); + + await page.goto(`http://localhost:${port}/code-split2`, { + waitUntil: "networkidle", + }); + expect( + await page + .locator("#code-split2 span") + .evaluate((e) => window.getComputedStyle(e).backgroundColor) + ).toBe("rgb(255, 170, 0)"); + + expect(pageErrors).toEqual([]); +}); + +test("Vite / build / doesn't load .env file", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/dotenv`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let loaderContent = page.locator("[data-dotenv-route-loader-content]"); + await expect(loaderContent).toHaveText( + ".env file was NOT loaded, which is a good thing" + ); + + expect(pageErrors).toEqual([]); +}); diff --git a/integration/vite-cloudflare-test.ts b/integration/vite-cloudflare-test.ts new file mode 100644 index 0000000000..bcc29a73d4 --- /dev/null +++ b/integration/vite-cloudflare-test.ts @@ -0,0 +1,156 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import dedent from "dedent"; + +import type { Files } from "./helpers/vite.js"; +import { test, viteConfig } from "./helpers/vite.js"; + +const files: Files = async ({ port }) => ({ + "vite.config.ts": dedent` + import { + vitePlugin as remix, + cloudflareDevProxyVitePlugin as remixCloudflareDevProxy, + } from "@remix-run/dev"; + import { getLoadContext } from "./load-context"; + + export default { + ${await viteConfig.server({ port })} + plugins: [ + remixCloudflareDevProxy({ getLoadContext }), + remix(), + ], + } + `, + "load-context.ts": ` + import { type AppLoadContext } from "@remix-run/cloudflare"; + import { type PlatformProxy } from "wrangler"; + + type Env = { + MY_KV: KVNamespace; + } + type Cloudflare = Omit, 'dispose'>; + + declare module "@remix-run/cloudflare" { + interface AppLoadContext { + cloudflare: Cloudflare; + env2: Cloudflare["env"]; + extra: string; + } + } + + type GetLoadContext = (args: { + request: Request; + context: { cloudflare: Cloudflare }; + }) => AppLoadContext; + + export const getLoadContext: GetLoadContext = ({ context }) => { + return { + ...context, + env2: context.cloudflare.env, + extra: "stuff", + }; + }; + `, + "functions/[[page]].ts": ` + import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages"; + + // @ts-ignore - the server build file is generated by \`remix vite:build\` + import * as build from "../build/server"; + import { getLoadContext } from "../load-context"; + + export const onRequest = createPagesFunctionHandler({ + build, + getLoadContext, + }); + `, + "wrangler.toml": ` + kv_namespaces = [ + { id = "abc123", binding="MY_KV" } + ] + `, + "app/routes/_index.tsx": ` + import { + json, + type LoaderFunctionArgs, + type ActionFunctionArgs, + } from "@remix-run/cloudflare"; + import { Form, useLoaderData } from "@remix-run/react"; + + const key = "__my-key__"; + + export async function loader({ context }: LoaderFunctionArgs) { + const { MY_KV } = context.cloudflare.env; + const value = await MY_KV.get(key); + return json({ value, extra: context.extra }); + } + + export async function action({ request, context }: ActionFunctionArgs) { + const { MY_KV } = context.env2; + + if (request.method === "POST") { + const formData = await request.formData(); + const value = formData.get("value") as string; + await MY_KV.put(key, value); + return null; + } + + if (request.method === "DELETE") { + await MY_KV.delete(key); + return null; + } + + throw new Error(\`Method not supported: "\${request.method}"\`); + } + + export default function Index() { + const { value, extra } = useLoaderData(); + return ( +
    +

    Welcome to Remix

    +

    Extra: {extra}

    + {value ? ( + <> +

    Value: {value}

    +
    + +
    + + ) : ( + <> +

    No value

    +
    + + +
    + +
    + + )} +
    + ); + } + `, +}); + +test("vite dev", async ({ page, viteDev }) => { + let { port } = await viteDev(files, "vite-cloudflare-template"); + await workflow({ page, port }); +}); + +test("wrangler", async ({ page, wranglerPagesDev }) => { + let { port } = await wranglerPagesDev(files); + await workflow({ page, port }); +}); + +async function workflow({ page, port }: { page: Page; port: number }) { + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); + await expect(page.locator("[data-extra]")).toHaveText("Extra: stuff"); + await expect(page.locator("[data-text]")).toHaveText("No value"); + + await page.getByLabel("Set value:").fill("my-value"); + await page.getByRole("button").click(); + await expect(page.locator("[data-text]")).toHaveText("Value: my-value"); + expect(page.errors).toEqual([]); +} diff --git a/integration/vite-css-test.ts b/integration/vite-css-test.ts new file mode 100644 index 0000000000..de37c65ce2 --- /dev/null +++ b/integration/vite-css-test.ts @@ -0,0 +1,354 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; +import dedent from "dedent"; + +import { + createProject, + createEditor, + viteDev, + viteBuild, + viteRemixServe, + customDev, + EXPRESS_SERVER, + viteConfig, +} from "./helpers/vite.js"; + +const js = String.raw; +const css = String.raw; + +const PADDING = "20px"; +const NEW_PADDING = "30px"; + +const files = { + "postcss.config.js": js` + export default ({ + plugins: [ + { + // Minimal PostCSS plugin to test that it's being used + postcssPlugin: 'replace', + Declaration (decl) { + decl.value = decl.value + .replace( + /NEW_PADDING_INJECTED_VIA_POSTCSS/g, + ${JSON.stringify(NEW_PADDING)}, + ) + .replace( + /PADDING_INJECTED_VIA_POSTCSS/g, + ${JSON.stringify(PADDING)}, + ); + }, + }, + ], + }); + `, + "app/entry.client.tsx": js` + import "./entry.client.css"; + + import { RemixBrowser } from "@remix-run/react"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + "app/entry.client.css": css` + .entry-client { + background: pink; + padding: ${PADDING}; + } + `, + "app/styles-bundled.css": css` + .index_bundled { + background: papayawhip; + padding: ${PADDING}; + } + `, + "app/styles-postcss-linked.css": css` + .index_postcss_linked { + background: salmon; + padding: PADDING_INJECTED_VIA_POSTCSS; + } + `, + "app/styles.module.css": css` + .index { + background: peachpuff; + padding: ${PADDING}; + } + `, + "app/styles-vanilla-global.css.ts": js` + import { createVar, globalStyle } from "@vanilla-extract/css"; + + globalStyle(".index_vanilla_global", { + background: "lightgreen", + padding: "${PADDING}", + }); + `, + "app/styles-vanilla-local.css.ts": js` + import { style } from "@vanilla-extract/css"; + + export const index = style({ + background: "lightblue", + padding: "${PADDING}", + }); + `, + "app/routes/_index.tsx": js` + import "../styles-bundled.css"; + import postcssLinkedStyles from "../styles-postcss-linked.css?url"; + import cssModulesStyles from "../styles.module.css"; + import "../styles-vanilla-global.css"; + import * as stylesVanillaLocal from "../styles-vanilla-local.css"; + + export function links() { + return [{ rel: "stylesheet", href: postcssLinkedStyles }]; + } + + export default function IndexRoute() { + return ( + <> + +
    +
    +
    +
    +
    +
    +

    CSS test

    +
    +
    +
    +
    +
    +
    + + ); + } + `, +}; + +const VITE_CONFIG = async (port: number) => dedent` + import { vitePlugin as remix } from "@remix-run/dev"; + import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; + + export default { + ${await viteConfig.server({ port })} + plugins: [remix(), vanillaExtractPlugin()], + } +`; + +test.describe(() => { + test.describe(async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.ts": await VITE_CONFIG(port), + ...files, + }); + stop = await viteDev({ cwd, port }); + }); + test.afterAll(() => stop()); + + test.describe(() => { + test.use({ javaScriptEnabled: false }); + test("Vite / CSS / vite dev / without JS", async ({ page }) => { + await pageLoadWorkflow({ page, port }); + }); + }); + + test.describe(() => { + test.use({ javaScriptEnabled: true }); + test("Vite / CSS / vite dev / with JS", async ({ page }) => { + await pageLoadWorkflow({ page, port }); + await hmrWorkflow({ page, port, cwd }); + }); + }); + }); + + test.describe(async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.ts": await VITE_CONFIG(port), + "server.mjs": EXPRESS_SERVER({ port }), + ...files, + }); + stop = await customDev({ cwd, port }); + }); + test.afterAll(() => stop()); + + test.describe(() => { + test.use({ javaScriptEnabled: false }); + test("Vite / CSS / express / without JS", async ({ page }) => { + await pageLoadWorkflow({ page, port }); + }); + }); + + test.describe(() => { + test.use({ javaScriptEnabled: true }); + test("Vite / CSS / express / with JS", async ({ page }) => { + await pageLoadWorkflow({ page, port }); + await hmrWorkflow({ page, port, cwd }); + }); + }); + }); + + test.describe(async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.ts": await VITE_CONFIG(port), + ...files, + }); + + let edit = createEditor(cwd); + await edit("package.json", (contents) => + contents.replace('"sideEffects": false', '"sideEffects": ["*.css.ts"]') + ); + + let { stderr, status } = viteBuild({ + cwd, + env: { + // Vanilla Extract uses Vite's CJS build which emits a warning to stderr + VITE_CJS_IGNORE_WARNING: "true", + }, + }); + expect(stderr.toString()).toBeFalsy(); + expect(status).toBe(0); + stop = await viteRemixServe({ cwd, port }); + }); + test.afterAll(() => stop()); + + test.describe(() => { + test.use({ javaScriptEnabled: false }); + test("Vite / CSS / vite build / without JS", async ({ page }) => { + await pageLoadWorkflow({ page, port }); + }); + }); + + test.describe(() => { + test.use({ javaScriptEnabled: true }); + test("Vite / CSS / vite build / with JS", async ({ page }) => { + await pageLoadWorkflow({ page, port }); + }); + }); + }); +}); + +async function pageLoadWorkflow({ page, port }: { page: Page; port: number }) { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); + + await Promise.all( + [ + "#css-bundled", + "#css-postcss-linked", + "#css-modules", + "#css-vanilla-global", + "#css-vanilla-local", + ].map( + async (selector) => + await expect(page.locator(selector)).toHaveCSS("padding", PADDING) + ) + ); +} + +async function hmrWorkflow({ + page, + cwd, + port, +}: { + page: Page; + cwd: string; + port: number; +}) { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); + + let input = page.locator("input"); + await expect(input).toBeVisible(); + await input.type("stateful"); + await expect(input).toHaveValue("stateful"); + + let edit = createEditor(cwd); + let modifyCss = (contents: string) => + contents + .replace(PADDING, NEW_PADDING) + .replace( + "PADDING_INJECTED_VIA_POSTCSS", + "NEW_PADDING_INJECTED_VIA_POSTCSS" + ); + + await Promise.all([ + edit("app/styles-bundled.css", modifyCss), + edit("app/styles.module.css", modifyCss), + edit("app/styles-vanilla-global.css.ts", modifyCss), + edit("app/styles-vanilla-local.css.ts", modifyCss), + edit("app/styles-postcss-linked.css", modifyCss), + ]); + + await Promise.all( + [ + "#css-bundled", + "#css-postcss-linked", + "#css-modules", + "#css-vanilla-global", + "#css-vanilla-local", + ].map( + async (selector) => + await expect(page.locator(selector)).toHaveCSS("padding", NEW_PADDING) + ) + ); + + // Ensure CSS updates were handled by HMR + await expect(input).toHaveValue("stateful"); + + // The following change triggers a full page reload, so we check it after all the checks for HMR state preservation + await edit("app/entry.client.css", modifyCss); + await expect(page.locator("#entry-client")).toHaveCSS("padding", NEW_PADDING); + + expect(pageErrors).toEqual([]); +} diff --git a/integration/vite-dev-custom-entry-test.ts b/integration/vite-dev-custom-entry-test.ts new file mode 100644 index 0000000000..e7be9e7237 --- /dev/null +++ b/integration/vite-dev-custom-entry-test.ts @@ -0,0 +1,176 @@ +import { test, expect } from "@playwright/test"; +import type { Readable } from "node:stream"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import getPort from "get-port"; +import waitOn from "wait-on"; + +import { createFixtureProject, js } from "./helpers/create-fixture.js"; +import { killtree } from "./helpers/killtree.js"; + +test.describe("Vite custom entry dev", () => { + let projectDir: string; + let devProc: ChildProcessWithoutNullStreams; + let devPort: number; + + test.beforeAll(async () => { + devPort = await getPort(); + projectDir = await createFixtureProject({ + compiler: "vite", + files: { + "remix.config.js": js` + throw new Error("Remix should not access remix.config.js when using Vite"); + export default {}; + `, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + server: { + port: ${devPort}, + strictPort: true, + }, + plugins: [ + remix(), + ], + }); + `, + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + + import type { EntryContext } from "@remix-run/node"; + import { createReadableStreamFromReadable } from "@remix-run/node"; + import { RemixServer } from "@remix-run/react"; + import { renderToPipeableStream } from "react-dom/server"; + + const ABORT_DELAY = 5_000; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + // Used to test that the request object is an instance of the global Request constructor + responseHeaders.set("x-test-request-instanceof-request", String(request instanceof Request)); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); + } + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, LiveReload } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
    +

    Root

    + +
    + + + + + ); + } + `, + "app/routes/_index.tsx": js` + export default function IndexRoute() { + return
    IndexRoute
    + } + `, + }, + }); + + let nodeBin = process.argv[0]; + let remixBin = "node_modules/@remix-run/dev/dist/cli.js"; + devProc = spawn(nodeBin, [remixBin, "vite:dev"], { + cwd: projectDir, + env: process.env, + stdio: "pipe", + }); + let devStdout = bufferize(devProc.stdout); + let devStderr = bufferize(devProc.stderr); + + await waitOn({ + resources: [`http://localhost:${devPort}/`], + timeout: 10000, + }).catch((err) => { + let stdout = devStdout(); + let stderr = devStderr(); + throw new Error( + [ + err.message, + "", + "exit code: " + devProc.exitCode, + "stdout: " + stdout ? `\n${stdout}\n` : "", + "stderr: " + stderr ? `\n${stderr}\n` : "", + ].join("\n") + ); + }); + }); + + test.afterAll(async () => { + devProc.pid && (await killtree(devProc.pid)); + }); + + // Ensure libraries/consumers can perform an instanceof check on the request + test("request instanceof Request", async ({ request }) => { + let res = await request.get(`http://localhost:${devPort}/`); + expect(res.headers()).toMatchObject({ + "x-test-request-instanceof-request": "true", + }); + }); +}); + +let bufferize = (stream: Readable): (() => string) => { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +}; diff --git a/integration/vite-dev-test.ts b/integration/vite-dev-test.ts new file mode 100644 index 0000000000..ec2f5d14fb --- /dev/null +++ b/integration/vite-dev-test.ts @@ -0,0 +1,493 @@ +import { test, expect } from "@playwright/test"; +import type { Readable } from "node:stream"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import getPort from "get-port"; +import waitOn from "wait-on"; + +import { createFixtureProject, js } from "./helpers/create-fixture.js"; +import { killtree } from "./helpers/killtree.js"; + +test.describe("Vite dev", () => { + let projectDir: string; + let devProc: ChildProcessWithoutNullStreams; + let devPort: number; + + test.beforeAll(async () => { + devPort = await getPort(); + projectDir = await createFixtureProject({ + compiler: "vite", + files: { + "remix.config.js": js` + throw new Error("Remix should not access remix.config.js when using Vite"); + export default {}; + `, + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + import mdx from "@mdx-js/rollup"; + + export default defineConfig({ + server: { + port: ${devPort}, + strictPort: true, + }, + plugins: [ + mdx(), + remix(), + ], + }); + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + +
    +

    Root

    + +
    + + + + ); + } + `, + "app/routes/_index.tsx": js` + import { Suspense } from "react"; + import { defer } from "@remix-run/node"; + import { Await, useLoaderData } from "@remix-run/react"; + + export function loader() { + let deferred = new Promise((resolve) => { + setTimeout(() => resolve(true), 1000) + }); + return defer({ deferred }); + } + + export default function IndexRoute() { + const { deferred } = useLoaderData(); + + return ( +
    +

    Index

    + +

    HMR updated: no

    + Defer finished: no

    }> + {() =>

    Defer finished: yes

    }
    +
    +
    + ); + } + `, + "app/routes/set-cookies.tsx": js` + import { LoaderFunction } from "@remix-run/node"; + + export const loader: LoaderFunction = () => { + const headers = new Headers(); + + headers.append( + "Set-Cookie", + "first=one; Domain=localhost; Path=/; SameSite=Lax" + ); + + headers.append( + "Set-Cookie", + "second=two; Domain=localhost; Path=/; SameSite=Lax" + ); + + headers.append( + "Set-Cookie", + "third=three; Domain=localhost; Path=/; SameSite=Lax" + ); + + headers.set("location", "http://localhost:${devPort}/get-cookies"); + + const response = new Response(null, { + headers, + status: 302, + }); + + return response; + }; + `, + "app/routes/get-cookies.tsx": js` + import { json, LoaderFunctionArgs } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react" + + export const loader = ({ request }: LoaderFunctionArgs) => json({cookies: request.headers.get("Cookie")}); + + export default function IndexRoute() { + const { cookies } = useLoaderData(); + + return ( +
    +

    Get Cookies

    +

    {cookies}

    +
    + ); + } + `, + "app/routes/jsx.jsx": js` + export default function JsxRoute() { + return ( +
    +

    HMR updated: no

    +
    + ); + } + `, + "app/routes/mdx.mdx": js` + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => { + return json({ + content: "MDX route content from loader", + }) + } + + export function MdxComponent() { + const { content } = useLoaderData(); + return
    {content}
    + } + + ## MDX Route + + + `, + ".env": ` + ENV_VAR_FROM_DOTENV_FILE=Content from .env file + `, + "app/routes/dotenv.tsx": js` + import { useState, useEffect } from "react"; + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => { + return json({ + loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE, + }) + } + + export default function DotenvRoute() { + const { loaderContent } = useLoaderData(); + + const [clientContent, setClientContent] = useState(''); + useEffect(() => { + try { + setClientContent("process.env.ENV_VAR_FROM_DOTENV_FILE shouldn't be available on the client, found: " + process.env.ENV_VAR_FROM_DOTENV_FILE); + } catch (err) { + setClientContent("process.env.ENV_VAR_FROM_DOTENV_FILE not available on the client, which is a good thing"); + } + }, []); + + return <> +
    {loaderContent}
    +
    {clientContent}
    + + } + `, + "app/routes/error-stacktrace.tsx": js` + import type { LoaderFunction, MetaFunction } from "@remix-run/node"; + import { Link, useLocation } from "@remix-run/react"; + + export const loader: LoaderFunction = ({ request }) => { + if (request.url.includes("crash-loader")) { + throw new Error("crash-loader"); + } + return null; + }; + + export default function TestRoute() { + const location = useLocation(); + + if (import.meta.env.SSR && location.search.includes("crash-server-render")) { + throw new Error("crash-server-render"); + } + + return ( +
    +
      + {["crash-loader", "crash-server-render"].map( + (v) => ( +
    • + {v} +
    • + ) + )} +
    +
    + ); + } + `, + "app/routes/known-route-exports.tsx": js` + import { useMatches } from "@remix-run/react"; + + export const meta = () => [{ + title: "HMR meta: 0" + }] + + export const links = () => [{ + rel: "icon", + href: "/favicon.ico", + type: "image/png", + "data-link": "HMR links: 0", + }] + + export const handle = { + data: "HMR handle: 0" + }; + + export default function TestRoute() { + const matches = useMatches(); + + return ( +
    + +

    HMR component: 0

    +

    {matches[1].handle.data}

    +
    + ); + } + `, + }, + }); + + let nodeBin = process.argv[0]; + let remixBin = "node_modules/@remix-run/dev/dist/cli.js"; + devProc = spawn(nodeBin, [remixBin, "vite:dev"], { + cwd: projectDir, + env: process.env, + stdio: "pipe", + }); + let devStdout = bufferize(devProc.stdout); + let devStderr = bufferize(devProc.stderr); + + await waitOn({ + resources: [`http://localhost:${devPort}/`], + timeout: 10000, + }).catch((err) => { + let stdout = devStdout(); + let stderr = devStderr(); + throw new Error( + [ + err.message, + "", + "exit code: " + devProc.exitCode, + "stdout: " + stdout ? `\n${stdout}\n` : "", + "stderr: " + stderr ? `\n${stderr}\n` : "", + ].join("\n") + ); + }); + }); + + test.afterAll(async () => { + devProc.pid && (await killtree(devProc.pid)); + }); + + test("renders matching routes", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${devPort}/`, { + waitUntil: "networkidle", + }); + + // Ensure no errors on page load + expect(pageErrors).toEqual([]); + + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + await expect(page.locator("#index [data-defer]")).toHaveText( + "Defer finished: yes" + ); + + let hmrStatus = page.locator("#index [data-hmr]"); + await expect(hmrStatus).toHaveText("HMR updated: no"); + + let input = page.locator("#index input"); + await expect(input).toBeVisible(); + await input.type("stateful"); + + let indexRouteContents = await fs.readFile( + path.join(projectDir, "app/routes/_index.tsx"), + "utf8" + ); + await fs.writeFile( + path.join(projectDir, "app/routes/_index.tsx"), + indexRouteContents.replace("HMR updated: no", "HMR updated: yes"), + "utf8" + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: yes"); + await expect(input).toHaveValue("stateful"); + + // Ensure no errors after HMR + expect(pageErrors).toEqual([]); + }); + + test("handles multiple set-cookie headers", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${devPort}/set-cookies`, { + waitUntil: "networkidle", + }); + + expect(pageErrors).toEqual([]); + + // Ensure we redirected + expect(new URL(page.url()).pathname).toBe("/get-cookies"); + + await expect(page.locator("#get-cookies [data-cookies]")).toHaveText( + "first=one; second=two; third=three" + ); + }); + + test("handles JSX in .jsx file without React import", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${devPort}/jsx`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let hmrStatus = page.locator("#jsx [data-hmr]"); + await expect(hmrStatus).toHaveText("HMR updated: no"); + + let indexRouteContents = await fs.readFile( + path.join(projectDir, "app/routes/jsx.jsx"), + "utf8" + ); + await fs.writeFile( + path.join(projectDir, "app/routes/jsx.jsx"), + indexRouteContents.replace("HMR updated: no", "HMR updated: yes"), + "utf8" + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: yes"); + + expect(pageErrors).toEqual([]); + }); + + test("handles MDX routes", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${devPort}/mdx`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let mdxContent = page.locator("[data-mdx-route]"); + await expect(mdxContent).toHaveText("MDX route content from loader"); + + expect(pageErrors).toEqual([]); + }); + + test("loads .env file", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${devPort}/dotenv`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let loaderContent = page.locator("[data-dotenv-route-loader-content]"); + await expect(loaderContent).toHaveText("Content from .env file"); + + let clientContent = page.locator("[data-dotenv-route-client-content]"); + await expect(clientContent).toHaveText( + "process.env.ENV_VAR_FROM_DOTENV_FILE not available on the client, which is a good thing" + ); + + expect(pageErrors).toEqual([]); + }); + + test("request errors map to original source code", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto( + `http://localhost:${devPort}/error-stacktrace?crash-server-render` + ); + await expect(page.locator("main")).toContainText( + "Error: crash-server-render" + ); + await expect(page.locator("main")).toContainText( + "error-stacktrace.tsx:16:11" + ); + + await page.goto( + `http://localhost:${devPort}/error-stacktrace?crash-loader` + ); + await expect(page.locator("main")).toContainText("Error: crash-loader"); + await expect(page.locator("main")).toContainText( + "error-stacktrace.tsx:7:11" + ); + }); + + test("handle known route exports with HMR", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${devPort}/known-route-exports`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + // file editing utils + let filepath = path.join(projectDir, "app/routes/known-route-exports.tsx"); + let filedata = await fs.readFile(filepath, "utf8"); + async function editFile(edit: (data: string) => string) { + filedata = edit(filedata); + await fs.writeFile(filepath, filedata, "utf8"); + } + + // verify input state is preserved after each update + let input = page.locator("input"); + await input.type("stateful"); + await expect(input).toHaveValue("stateful"); + + // component + await editFile((data) => + data.replace("HMR component: 0", "HMR component: 1") + ); + await expect(page.locator("[data-hmr]")).toHaveText("HMR component: 1"); + await expect(input).toHaveValue("stateful"); + + // handle + await editFile((data) => data.replace("HMR handle: 0", "HMR handle: 1")); + await expect(page.locator("[data-handle]")).toHaveText("HMR handle: 1"); + await expect(input).toHaveValue("stateful"); + + // meta + await editFile((data) => data.replace("HMR meta: 0", "HMR meta: 1")); + await expect(page).toHaveTitle("HMR meta: 1"); + await expect(input).toHaveValue("stateful"); + + // links + await editFile((data) => data.replace("HMR links: 0", "HMR links: 1")); + await expect(page.locator("[data-link]")).toHaveAttribute( + "data-link", + "HMR links: 1" + ); + + expect(pageErrors).toEqual([]); + }); +}); + +let bufferize = (stream: Readable): (() => string) => { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +}; diff --git a/integration/vite-dot-client-test.ts b/integration/vite-dot-client-test.ts new file mode 100644 index 0000000000..85ebc464b9 --- /dev/null +++ b/integration/vite-dot-client-test.ts @@ -0,0 +1,47 @@ +import * as path from "node:path"; +import { test, expect } from "@playwright/test"; + +import { createProject, grep, viteBuild } from "./helpers/vite.js"; + +let files = { + "app/utils.client.ts": String.raw` + export const dotClientFile = "CLIENT_ONLY_FILE"; + export default dotClientFile; + `, + "app/.client/utils.ts": String.raw` + export const dotClientDir = "CLIENT_ONLY_DIR"; + export default dotClientDir; + `, +}; + +test("Vite / client code excluded from server bundle", async () => { + let cwd = await createProject({ + ...files, + "app/routes/dot-client-imports.tsx": String.raw` + import { dotClientFile } from "../utils.client"; + import { dotClientDir } from "../.client/utils"; + + export default function() { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return ( + <> +

    Index

    +

    {mounted ? dotClientFile + dotClientDir : ""}

    + + ); + } + `, + }); + let { status } = viteBuild({ cwd }); + expect(status).toBe(0); + let lines = grep( + path.join(cwd, "build/server"), + /CLIENT_ONLY_FILE|CLIENT_ONLY_DIR/ + ); + expect(lines).toHaveLength(0); +}); diff --git a/integration/vite-dot-server-test.ts b/integration/vite-dot-server-test.ts new file mode 100644 index 0000000000..67ca42fc89 --- /dev/null +++ b/integration/vite-dot-server-test.ts @@ -0,0 +1,269 @@ +import * as path from "node:path"; +import { expect } from "@playwright/test"; +import stripAnsi from "strip-ansi"; +import dedent from "dedent"; + +import type { Files } from "./helpers/vite.js"; +import { + test, + createProject, + grep, + viteBuild, + viteConfig, +} from "./helpers/vite.js"; + +let serverOnlyModule = ` + export const serverOnly = "SERVER_ONLY"; + export default serverOnly; +`; + +let tsconfig = (aliases: Record) => ` + { + "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": ${JSON.stringify(aliases)}, + "noEmit": true + } + } +`; + +test("Vite / dead-code elimination for server exports", async () => { + let cwd = await createProject({ + "app/utils.server.ts": serverOnlyModule, + "app/.server/utils.ts": serverOnlyModule, + "app/routes/remove-server-exports-and-dce.tsx": ` + import fs from "node:fs"; + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + import { serverOnly as serverOnlyFile } from "../utils.server"; + import { serverOnly as serverOnlyDir } from "../.server/utils"; + + export const loader = () => { + let contents = fs.readFileSync("server_only.txt"); + return json({ serverOnlyFile, serverOnlyDir, contents }) + } + + export const action = () => { + let contents = fs.readFileSync("server_only.txt"); + console.log({ serverOnlyFile, serverOnlyDir, contents }); + return null; + } + + export default function() { + let { data } = useLoaderData(); + return
    {JSON.stringify(data)}
    ; + } + `, + }); + let { status } = viteBuild({ cwd }); + expect(status).toBe(0); + + let lines = grep( + path.join(cwd, "build/client"), + /SERVER_ONLY|SERVER_ONLY|node:fs/ + ); + expect(lines).toHaveLength(0); +}); + +test.describe("Vite / route / server-only module referenced by client", () => { + let matrix = [ + { type: "file", path: "app/utils.server.ts", specifier: `~/utils.server` }, + { type: "dir", path: "app/.server/utils.ts", specifier: `~/.server/utils` }, + + { + type: "file alias", + path: "app/utils.server.ts", + specifier: `#dot-server-file`, + }, + { + type: "dir alias", + path: "app/.server/utils.ts", + specifier: `#dot-server-dir/utils`, + }, + ]; + + let cases = matrix.flatMap(({ type, path, specifier }) => [ + { + name: `default import / .server ${type}`, + path, + specifier, + route: ` + import serverOnly from "${specifier}"; + export default () =>

    {serverOnly}

    ; + `, + }, + { + name: `named import / .server ${type}`, + path, + specifier, + route: ` + import { serverOnly } from "${specifier}" + export default () =>

    {serverOnly}

    ; + `, + }, + { + name: `namespace import / .server ${type}`, + path, + specifier, + route: ` + import * as utils from "${specifier}" + export default () =>

    {utils.serverOnly}

    ; + `, + }, + ]); + + for (let { name, path, specifier, route } of cases) { + test(name, async () => { + let cwd = await createProject({ + "tsconfig.json": tsconfig({ + "~/*": ["app/*"], + "#dot-server-file": ["app/utils.server.ts"], + "#dot-server-dir/*": ["app/.server/*"], + }), + [path]: serverOnlyModule, + "app/routes/_index.tsx": route, + }); + let result = viteBuild({ cwd }); + let stderr = result.stderr.toString("utf8"); + [ + "Server-only module referenced by client", + + ` '${specifier}' imported by route 'app/routes/_index.tsx'`, + + " Remix automatically removes server-code from these exports:", + " `loader`, `action`, `headers`", + + ` But other route exports in 'app/routes/_index.tsx' depend on '${specifier}'.`, + + " See https://remix.run/docs/en/main/future/vite#splitting-up-client-and-server-code", + ].forEach(expect(stderr).toMatch); + }); + } +}); + +test.describe("Vite / non-route / server-only module referenced by client", () => { + let matrix = [ + { type: "file", path: "app/utils.server.ts", specifier: `~/utils.server` }, + { type: "dir", path: "app/.server/utils.ts", specifier: `~/.server/utils` }, + ]; + + let cases = matrix.flatMap(({ type, path, specifier }) => [ + { + name: `default import / .server ${type}`, + path, + specifier, + nonroute: ` + import serverOnly from "${specifier}"; + export const getServerOnly = () => serverOnly; + `, + }, + { + name: `named import / .server ${type}`, + path, + specifier, + nonroute: ` + import { serverOnly } from "${specifier}"; + export const getServerOnly = () => serverOnly; + `, + }, + { + name: `namespace import / .server ${type}`, + path, + specifier, + nonroute: ` + import * as utils from "${specifier}"; + export const getServerOnly = () => utils.serverOnly; + `, + }, + ]); + + for (let { name, path, specifier, nonroute } of cases) { + test(name, async () => { + let cwd = await createProject({ + [path]: serverOnlyModule, + "app/reexport-server-only.ts": nonroute, + "app/routes/_index.tsx": ` + import { serverOnly } from "~/reexport-server-only" + export default () =>

    {serverOnly}

    ; + `, + }); + let result = viteBuild({ cwd }); + let stderr = stripAnsi(result.stderr.toString("utf8")); + + [ + `Server-only module referenced by client`, + + ` '${specifier}' imported by 'app/reexport-server-only.ts'`, + + " See https://remix.run/docs/en/main/future/vite#splitting-up-client-and-server-code", + ].forEach(expect(stderr).toMatch); + }); + } +}); + +test.describe("Vite / server-only escape hatch", async () => { + let files: Files = async ({ port }) => ({ + "vite.config.ts": dedent` + import { vitePlugin as remix } from "@remix-run/dev"; + import envOnly from "vite-env-only"; + import tsconfigPaths from "vite-tsconfig-paths"; + + export default { + ${await viteConfig.server({ port })} + plugins: [remix(), envOnly(), tsconfigPaths()], + } + `, + "app/utils.server.ts": serverOnlyModule, + "app/.server/utils.ts": serverOnlyModule, + "app/routes/_index.tsx": ` + import { serverOnly$ } from "vite-env-only"; + + import { serverOnly as serverOnlyFile } from "~/utils.server"; + import serverOnlyDir from "~/.server/utils"; + + export const handle = { + escapeHatch: serverOnly$(async () => { + return { serverOnlyFile, serverOnlyDir }; + }) + } + + export default () =>

    This should work

    ; + `, + }); + + test("vite dev", async ({ page, viteDev }) => { + let { port } = await viteDev(files); + + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); + await expect(page.locator("[data-title]")).toHaveText("This should work"); + expect(page.errors).toEqual([]); + }); + + test("vite build + remix-serve", async ({ page, viteRemixServe }) => { + let { port, cwd } = await viteRemixServe(files); + + let lines = grep(path.join(cwd, "build/client"), /SERVER_ONLY/); + expect(lines).toHaveLength(0); + + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); + await expect(page.locator("[data-title]")).toHaveText("This should work"); + expect(page.errors).toEqual([]); + }); +}); diff --git a/integration/vite-dotenv-test.ts b/integration/vite-dotenv-test.ts new file mode 100644 index 0000000000..34997f2625 --- /dev/null +++ b/integration/vite-dotenv-test.ts @@ -0,0 +1,81 @@ +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { + createProject, + customDev, + EXPRESS_SERVER, + viteConfig, +} from "./helpers/vite.js"; + +let files = { + ".env": ` + ENV_VAR_FROM_DOTENV_FILE=Content from .env file + `, + "app/routes/dotenv.tsx": String.raw` + import { useState, useEffect } from "react"; + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => { + return json({ + loaderContent: process.env.ENV_VAR_FROM_DOTENV_FILE, + }) + } + + export default function DotenvRoute() { + const { loaderContent } = useLoaderData(); + + const [clientContent, setClientContent] = useState(''); + useEffect(() => { + try { + setClientContent("process.env.ENV_VAR_FROM_DOTENV_FILE shouldn't be available on the client, found: " + process.env.ENV_VAR_FROM_DOTENV_FILE); + } catch (err) { + setClientContent("process.env.ENV_VAR_FROM_DOTENV_FILE not available on the client, which is a good thing"); + } + }, []); + + return <> +
    {loaderContent}
    +
    {clientContent}
    + + } + `, +}; + +test.describe(async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfig.basic({ port }), + "server.mjs": EXPRESS_SERVER({ port }), + ...files, + }); + stop = await customDev({ cwd, port }); + }); + test.afterAll(() => stop()); + + test("Vite / Load context / express", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/dotenv`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let loaderContent = page.locator("[data-dotenv-route-loader-content]"); + await expect(loaderContent).toHaveText("Content from .env file"); + + let clientContent = page.locator("[data-dotenv-route-client-content]"); + await expect(clientContent).toHaveText( + "process.env.ENV_VAR_FROM_DOTENV_FILE not available on the client, which is a good thing" + ); + + expect(pageErrors).toEqual([]); + }); +}); diff --git a/integration/vite-hmr-hdr-test.ts b/integration/vite-hmr-hdr-test.ts new file mode 100644 index 0000000000..68003e673a --- /dev/null +++ b/integration/vite-hmr-hdr-test.ts @@ -0,0 +1,334 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { Page, PlaywrightWorkerOptions } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import type { Files } from "./helpers/vite.js"; +import { + test, + createEditor, + EXPRESS_SERVER, + viteConfig, +} from "./helpers/vite.js"; + +const indexRoute = ` + // imports + import { useState, useEffect } from "react"; + + export const meta = () => [{ title: "HMR updated title: 0" }] + + // loader + + export default function IndexRoute() { + // hooks + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( +
    +

    Index

    + +

    Mounted: {mounted ? "yes" : "no"}

    +

    HMR updated: 0

    + {/* elements */} +
    + ); + } +`; + +test("Vite / HMR & HDR / vite dev", async ({ page, browserName, viteDev }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ port }), + "app/routes/_index.tsx": indexRoute, + }); + let { cwd, port } = await viteDev(files); + await workflow({ page, browserName, cwd, port }); +}); + +test("Vite / HMR & HDR / express", async ({ page, browserName, customDev }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ port }), + "server.mjs": EXPRESS_SERVER({ port }), + "app/routes/_index.tsx": indexRoute, + }); + let { cwd, port } = await customDev(files); + await workflow({ page, browserName, cwd, port }); +}); + +test("Vite / HMR & HDR / mdx", async ({ page, viteDev }) => { + let files: Files = async ({ port }) => ({ + "vite.config.ts": ` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + import mdx from "@mdx-js/rollup"; + + export default defineConfig({ + ${await viteConfig.server({ port })} + plugins: [ + mdx(), + remix(), + ], + }); + `, + "app/component.tsx": ` + import {useState} from "react"; + + export const Counter = () => { + const [count, setCount] = useState(0); + return + } + `, + "app/routes/mdx.mdx": ` + import { Counter } from "../component"; + + # MDX Title (HMR: 0) + + + `, + }); + + let { port, cwd } = await viteDev(files); + let edit = createEditor(cwd); + await page.goto(`http://localhost:${port}/mdx`, { + waitUntil: "networkidle", + }); + + await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 0)"); + let button = page.locator("button"); + await expect(button).toHaveText("Count: 0"); + await button.click(); + await expect(button).toHaveText("Count: 1"); + + await edit("app/routes/mdx.mdx", (contents) => + contents.replace("(HMR: 0)", "(HMR: 1)") + ); + await page.waitForLoadState("networkidle"); + + await expect(page.locator("h1")).toHaveText("MDX Title (HMR: 1)"); + await expect(page.locator("button")).toHaveText("Count: 1"); + + expect(page.errors).toEqual([]); +}); + +async function workflow({ + page, + browserName, + cwd, + port, +}: { + page: Page; + browserName: PlaywrightWorkerOptions["browserName"]; + cwd: string; + port: number; +}) { + let edit = createEditor(cwd); + + // setup: initial render + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + + // setup: hydration + await expect(page.locator("#index [data-mounted]")).toHaveText( + "Mounted: yes" + ); + + // setup: browser state + let hmrStatus = page.locator("#index [data-hmr]"); + await expect(page).toHaveTitle("HMR updated title: 0"); + await expect(hmrStatus).toHaveText("HMR updated: 0"); + let input = page.locator("#index input"); + await expect(input).toBeVisible(); + await input.type("stateful"); + expect(page.errors).toEqual([]); + + // route: HMR + await edit("app/routes/_index.tsx", (contents) => + contents + .replace("HMR updated title: 0", "HMR updated title: 1") + .replace("HMR updated: 0", "HMR updated: 1") + ); + await page.waitForLoadState("networkidle"); + await expect(page).toHaveTitle("HMR updated title: 1"); + await expect(hmrStatus).toHaveText("HMR updated: 1"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // route: add loader + await edit("app/routes/_index.tsx", (contents) => + contents + .replace( + "// imports", + `// imports\nimport { json } from "@remix-run/node";\nimport { useLoaderData } from "@remix-run/react"` + ) + .replace( + "// loader", + `// loader\nexport const loader = () => json({ message: "HDR updated: 0" });` + ) + .replace( + "// hooks", + "// hooks\nconst { message } = useLoaderData();" + ) + .replace( + "{/* elements */}", + `{/* elements */}\n

    {message}

    ` + ) + ); + await page.waitForLoadState("networkidle"); + let hdrStatus = page.locator("#index [data-hdr]"); + await expect(hdrStatus).toHaveText("HDR updated: 0"); + // React Fast Refresh cannot preserve state for a component when hooks are added or removed + await expect(input).toHaveValue(""); + await input.type("stateful"); + expect(page.errors.length).toBeGreaterThan(0); + expect( + // When adding a loader, a harmless error is logged to the browser console. + // HMR works as intended, so this seems like a React Fast Refresh bug caused by off-screen rendering with old server data or something like that 🤷 + page.errors.filter((error) => { + let chromium = + browserName === "chromium" && + error.message === + "Cannot destructure property 'message' of 'useLoaderData(...)' as it is null."; + let firefox = + browserName === "firefox" && + error.message === "(intermediate value)() is null"; + let webkit = + browserName === "webkit" && + error.message === "Right side of assignment cannot be destructured"; + let expected = chromium || firefox || webkit; + return !expected; + }) + ).toEqual([]); + page.errors = []; + + // route: HDR + await edit("app/routes/_index.tsx", (contents) => + contents.replace("HDR updated: 0", "HDR updated: 1") + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: 1"); + await expect(input).toHaveValue("stateful"); + + // route: HMR + HDR + await edit("app/routes/_index.tsx", (contents) => + contents + .replace("HMR updated: 1", "HMR updated: 2") + .replace("HDR updated: 1", "HDR updated: 2") + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: 2"); + await expect(hdrStatus).toHaveText("HDR updated: 2"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // create new non-route component module + await fs.writeFile( + path.join(cwd, "app/component.tsx"), + String.raw` + export function MyComponent() { + return

    Component HMR: 0

    ; + } + `, + "utf8" + ); + await edit("app/routes/_index.tsx", (contents) => + contents + .replace( + "// imports", + `// imports\nimport { MyComponent } from "../component";` + ) + .replace("{/* elements */}", "{/* elements */}\n") + ); + await page.waitForLoadState("networkidle"); + let component = page.locator("#index [data-component]"); + await expect(component).toBeVisible(); + await expect(component).toHaveText("Component HMR: 0"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // non-route: HMR + await edit("app/component.tsx", (contents) => + contents.replace("Component HMR: 0", "Component HMR: 1") + ); + await page.waitForLoadState("networkidle"); + await expect(component).toHaveText("Component HMR: 1"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // create new non-route server module + await fs.writeFile( + path.join(cwd, "app/indirect-hdr-dep.ts"), + String.raw`export const indirect = "indirect 0"`, + "utf8" + ); + await fs.writeFile( + path.join(cwd, "app/direct-hdr-dep.ts"), + String.raw` + import { indirect } from "./indirect-hdr-dep" + export const direct = "direct 0 & " + indirect + `, + "utf8" + ); + await edit("app/routes/_index.tsx", (contents) => + contents + .replace( + "// imports", + `// imports\nimport { direct } from "../direct-hdr-dep"` + ) + .replace( + `json({ message: "HDR updated: 2" })`, + `json({ message: "HDR updated: " + direct })` + ) + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: direct 0 & indirect 0"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // non-route: HDR for direct dependency + await edit("app/direct-hdr-dep.ts", (contents) => + contents.replace("direct 0 &", "direct 1 &") + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 0"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // non-route: HDR for indirect dependency + await edit("app/indirect-hdr-dep.ts", (contents) => + contents.replace("indirect 0", "indirect 1") + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 1"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // everything everywhere all at once + await Promise.all([ + edit("app/routes/_index.tsx", (contents) => + contents + .replace("HMR updated: 2", "HMR updated: 3") + .replace("HDR updated: ", "HDR updated: route & ") + ), + edit("app/component.tsx", (contents) => + contents.replace("Component HMR: 1", "Component HMR: 2") + ), + edit("app/direct-hdr-dep.ts", (contents) => + contents.replace("direct 1 &", "direct 2 &") + ), + edit("app/indirect-hdr-dep.ts", (contents) => + contents.replace("indirect 1", "indirect 2") + ), + ]); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: 3"); + await expect(component).toHaveText("Component HMR: 2"); + await expect(hdrStatus).toHaveText( + "HDR updated: route & direct 2 & indirect 2" + ); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); +} diff --git a/integration/vite-loader-context-test.ts b/integration/vite-loader-context-test.ts new file mode 100644 index 0000000000..1423646752 --- /dev/null +++ b/integration/vite-loader-context-test.ts @@ -0,0 +1,53 @@ +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { + createProject, + customDev, + EXPRESS_SERVER, + viteConfig, +} from "./helpers/vite.js"; + +let port: number; +let cwd: string; +let stop: () => void; + +test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfig.basic({ port }), + "server.mjs": EXPRESS_SERVER({ port, loadContext: { value: "value" } }), + "app/routes/_index.tsx": String.raw` + import { json } from "@remix-run/node"; + import { useLoaderData } from "@remix-run/react"; + + export const loader = ({ context }) => { + return json({ context }) + } + + export default function IndexRoute() { + let { context } = useLoaderData(); + return ( +
    +

    Context: {context.value}

    +
    + ); + } + `, + }); + stop = await customDev({ cwd, port }); +}); +test.afterAll(() => stop()); + +test("Vite / Load context / express", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-context]")).toHaveText( + "Context: value" + ); + expect(pageErrors).toEqual([]); +}); diff --git a/integration/vite-manifests-test.ts b/integration/vite-manifests-test.ts new file mode 100644 index 0000000000..59ac109f2c --- /dev/null +++ b/integration/vite-manifests-test.ts @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import path from "node:path"; +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; +import dedent from "dedent"; + +import { createProject, viteBuild, viteConfig } from "./helpers/vite.js"; + +function createRoute(path: string) { + return { + [`app/routes/${path}`]: ` + export default function Route() { + return

    Path: ${path}

    ; + } + `, + }; +} + +const TEST_ROUTES = [ + "_index.tsx", + "parent-route.tsx", + "parent-route.child-route.tsx", +]; + +const files = { + "app/root.tsx": ` + import { Links, Meta, Outlet, Scripts, LiveReload } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + + ); + } + `, + ...Object.assign({}, ...TEST_ROUTES.map(createRoute)), +}; + +test.describe(() => { + let cwd: string; + + test.beforeAll(async () => { + cwd = await createProject({ + "vite.config.ts": dedent` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + build: { manifest: true }, + plugins: [remix({ manifest: true })], + } + `, + ...files, + }); + + viteBuild({ cwd }); + }); + + test("Vite / manifests enabled / Vite manifests", () => { + let viteManifestFiles = fs.readdirSync(path.join(cwd, "build", ".vite")); + + expect(viteManifestFiles).toEqual([ + "client-manifest.json", + "server-manifest.json", + ]); + }); + + test("Vite / manifests enabled / Remix manifest", async () => { + let manifestPath = path.join(cwd, "build", ".remix", "manifest.json"); + expect(JSON.parse(fs.readFileSync(manifestPath, "utf8"))).toEqual({ + routes: { + root: { + file: "root.tsx", + id: "root", + path: "", + }, + "routes/_index": { + file: "routes/_index.tsx", + id: "routes/_index", + index: true, + parentId: "root", + }, + "routes/parent-route": { + file: "routes/parent-route.tsx", + id: "routes/parent-route", + parentId: "root", + path: "parent-route", + }, + "routes/parent-route.child-route": { + file: "routes/parent-route.child-route.tsx", + id: "routes/parent-route.child-route", + parentId: "routes/parent-route", + path: "child-route", + }, + }, + }); + }); +}); + +test.describe(() => { + let cwd: string; + + test.beforeAll(async () => { + cwd = await createProject({ + "vite.config.ts": await viteConfig.basic({ port: await getPort() }), + ...files, + }); + + viteBuild({ cwd }); + }); + + test("Vite / manifest disabled / Vite manifests", () => { + let manifestDir = path.join(cwd, "build", ".vite"); + expect(fs.existsSync(manifestDir)).toBe(false); + }); + + test("Vite / manifest disabled / Remix manifest doesn't exist", async () => { + let manifestDir = path.join(cwd, "build", ".remix"); + expect(fs.existsSync(manifestDir)).toBe(false); + }); +}); diff --git a/integration/vite-node-env-test.ts b/integration/vite-node-env-test.ts new file mode 100644 index 0000000000..90cc7eb8df --- /dev/null +++ b/integration/vite-node-env-test.ts @@ -0,0 +1,79 @@ +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { + createProject, + viteDev, + viteBuild, + viteRemixServe, + viteConfig, +} from "./helpers/vite.js"; + +let files = { + "app/routes/node_env.tsx": String.raw` + export default function NodeEnvRoute() { + return
    {process.env.NODE_ENV}
    + } + `, +}; + +test.describe(async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfig.basic({ port: port }), + ...files, + }); + }); + + test.describe(() => { + test.beforeAll(async () => { + stop = await viteDev({ cwd, port }); + }); + test.afterAll(() => stop()); + + test("Vite / NODE_ENV / dev", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/node_env`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let nodeEnvContent = page.locator("[data-node-env]"); + await expect(nodeEnvContent).toHaveText("development"); + + expect(pageErrors).toEqual([]); + }); + }); + + test.describe(() => { + let buildPort: number; + test.beforeAll(async () => { + viteBuild({ cwd }); + buildPort = await getPort(); + stop = await viteRemixServe({ cwd, port: buildPort }); + }); + test.afterAll(() => stop()); + + test("Vite / NODE_ENV / build", async ({ page }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${buildPort}/node_env`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let nodeEnvContent = page.locator("[data-node-env]"); + await expect(nodeEnvContent).toHaveText("production"); + + expect(pageErrors).toEqual([]); + }); + }); +}); diff --git a/integration/vite-plugin-order-validation-test.ts b/integration/vite-plugin-order-validation-test.ts new file mode 100644 index 0000000000..1754f5718d --- /dev/null +++ b/integration/vite-plugin-order-validation-test.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; +import dedent from "dedent"; + +import { createProject, viteBuild } from "./helpers/vite.js"; + +test.describe(() => { + let cwd: string; + let buildResult: ReturnType; + + test.beforeAll(async () => { + cwd = await createProject({ + "vite.config.ts": dedent` + import { vitePlugin as remix } from "@remix-run/dev"; + import mdx from "@mdx-js/rollup"; + + export default { + plugins: [ + remix(), + mdx(), + ], + } + `, + }); + + buildResult = viteBuild({ cwd }); + }); + + test("Vite / plugin order validation / MDX", () => { + expect(buildResult.stderr.toString()).toContain( + 'Error: The "@mdx-js/rollup" plugin should be placed before the Remix plugin in your Vite config file' + ); + }); +}); diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts new file mode 100644 index 0000000000..e190f2289d --- /dev/null +++ b/integration/vite-presets-test.ts @@ -0,0 +1,248 @@ +import fs from "node:fs/promises"; +import * as path from "node:path"; +import URL from "node:url"; +import { expect } from "@playwright/test"; +import { normalizePath } from "vite"; +import dedent from "dedent"; + +import { viteBuild, test, createProject } from "./helpers/vite.js"; + +const js = String.raw; + +const files = { + "vite.config.ts": dedent(js` + import { vitePlugin as remix } from "@remix-run/dev"; + import fs from "node:fs/promises"; + import serializeJs from "serialize-javascript"; + + let isDeepFrozen = (obj: any) => + Object.isFrozen(obj) && + Object.keys(obj).every( + prop => typeof obj[prop] !== 'object' || isDeepFrozen(obj[prop]) + ); + + export default { + build: { + assetsDir: "custom-assets-dir", + }, + plugins: [remix({ + presets: [ + // Ensure user config is passed to remixConfig hook + { + name: "test-preset", + remixConfig: async ({ remixUserConfig: { presets, ...restUserConfig } }) => { + if (!Array.isArray(presets)) { + throw new Error("Remix user config doesn't have presets array."); + } + + let expected = JSON.stringify({ appDirectory: "app"}); + let actual = JSON.stringify(restUserConfig); + + if (actual !== expected) { + throw new Error([ + "Remix user config wasn't passed to remixConfig hook.", + "Expected: " + expected, + "Actual: " + actual, + ].join(" ")); + } + + return {}; + }, + }, + + // Ensure preset config takes lower precedence than user config + { + name: "test-preset", + remixConfig: async () => ({ + appDirectory: "INCORRECT_APP_DIR", // This is overridden by the user config further down this file + }), + }, + { + name: "test-preset", + remixConfigResolved: async ({ remixConfig }) => { + if (remixConfig.appDirectory.includes("INCORRECT_APP_DIR")) { + throw new Error("Remix preset config wasn't overridden with user config"); + } + } + }, + + // Ensure config presets are merged in the correct order + { + name: "test-preset", + remixConfig: async () => ({ + buildDirectory: "INCORRECT_BUILD_DIR", + }), + }, + { + name: "test-preset", + remixConfig: async () => ({ + buildDirectory: "build", + }), + }, + + // Ensure remixConfig is called with a frozen Remix user config + { + name: "test-preset", + remixConfig: async ({ remixUserConfig }) => { + await fs.writeFile("PRESET_REMIX_CONFIG_META.json", JSON.stringify({ + remixUserConfigFrozen: isDeepFrozen(remixUserConfig), + }), "utf-8"); + } + }, + + // Ensure remixConfigResolved is called with a frozen Remix config + { + name: "test-preset", + remixConfigResolved: async ({ remixConfig }) => { + await fs.writeFile("PRESET_REMIX_CONFIG_RESOLVED_META.json", JSON.stringify({ + remixConfigFrozen: isDeepFrozen(remixConfig), + }), "utf-8"); + } + }, + + // Ensure presets can set serverBundles option (this is critical for Vercel support) + { + name: "test-preset", + remixConfig: async () => ({ + serverBundles() { + return "preset-server-bundle-id"; + }, + }), + }, + + // Ensure presets can set buildEnd option (this is critical for Vercel support) + { + name: "test-preset", + remixConfig: async () => ({ + async buildEnd(buildEndArgs) { + let { viteConfig, buildManifest, remixConfig } = buildEndArgs; + + await fs.writeFile( + "BUILD_END_META.js", + [ + "export const keys = " + JSON.stringify(Object.keys(buildEndArgs)) + ";", + "export const buildManifest = " + serializeJs(buildManifest, { space: 2, unsafe: true }) + ";", + "export const remixConfig = " + serializeJs(remixConfig, { space: 2, unsafe: true }) + ";", + "export const assetsDir = " + JSON.stringify(viteConfig.build.assetsDir) + ";", + ].join("\\n"), + "utf-8" + ); + }, + }), + }, + ], + // Ensure user config takes precedence over preset config + appDirectory: "app", + })], + } + `), +}; + +test("Vite / presets", async () => { + let cwd = await createProject(files); + let { status, stderr } = viteBuild({ cwd }); + expect(stderr.toString()).toBeFalsy(); + expect(status).toBe(0); + + function pathStartsWithCwd(pathname: string) { + return normalizePath(pathname).startsWith(normalizePath(cwd)); + } + + function relativeToCwd(pathname: string) { + return normalizePath(path.relative(cwd, pathname)); + } + + let buildEndArgsMeta: any = await import( + URL.pathToFileURL(path.join(cwd, "BUILD_END_META.js")).href + ); + + let { remixConfig } = buildEndArgsMeta; + + // Smoke test Vite config + expect(buildEndArgsMeta.assetsDir).toBe("custom-assets-dir"); + + // Before rewriting to relative paths, assert that paths are absolute within cwd + expect(pathStartsWithCwd(remixConfig.buildDirectory)).toBe(true); + + // Rewrite path args to be relative and normalized for snapshot test + remixConfig.buildDirectory = relativeToCwd(remixConfig.buildDirectory); + + // Ensure preset configs are merged in correct order, resulting in the correct build directory + expect(remixConfig.buildDirectory).toBe("build"); + + // Ensure preset config takes lower precedence than user config + expect(remixConfig.serverModuleFormat).toBe("esm"); + + // Ensure `remixConfig` is called with a frozen Remix user config + expect( + JSON.parse( + await fs.readFile( + path.join(cwd, "PRESET_REMIX_CONFIG_META.json"), + "utf-8" + ) + ) + ).toEqual({ + remixUserConfigFrozen: true, + }); + + // Ensure `remixConfigResolved` is called with a frozen Remix config + expect( + JSON.parse( + await fs.readFile( + path.join(cwd, "PRESET_REMIX_CONFIG_RESOLVED_META.json"), + "utf-8" + ) + ) + ).toEqual({ + remixConfigFrozen: true, + }); + + // Snapshot the buildEnd args keys + expect(buildEndArgsMeta.keys).toEqual([ + "buildManifest", + "remixConfig", + "viteConfig", + ]); + + // Smoke test the resolved config + expect(Object.keys(remixConfig)).toEqual([ + "appDirectory", + "basename", + "buildDirectory", + "buildEnd", + "future", + "manifest", + "publicPath", + "routes", + "serverBuildFile", + "serverBundles", + "serverModuleFormat", + "ssr", + ]); + + // Ensure we get a valid build manifest + expect(buildEndArgsMeta.buildManifest).toEqual({ + routeIdToServerBundleId: { + "routes/_index": "preset-server-bundle-id", + }, + routes: { + root: { + file: "app/root.tsx", + id: "root", + path: "", + }, + "routes/_index": { + file: "app/routes/_index.tsx", + id: "routes/_index", + index: true, + parentId: "root", + }, + }, + serverBundles: { + "preset-server-bundle-id": { + file: "build/server/preset-server-bundle-id/index.js", + id: "preset-server-bundle-id", + }, + }, + }); +}); diff --git a/integration/vite-route-added-test.ts b/integration/vite-route-added-test.ts new file mode 100644 index 0000000000..4180e896bc --- /dev/null +++ b/integration/vite-route-added-test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { createProject, viteDev, viteConfig } from "./helpers/vite.js"; + +const files = { + "app/routes/_index.tsx": String.raw` + import { useState, useEffect } from "react"; + import { Link } from "@remix-run/react"; + + export default function IndexRoute() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( +

    Mounted: {mounted ? "yes" : "no"}

    + ); + } + `, +}; + +test.describe(async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfig.basic({ port }), + ...files, + }); + stop = await viteDev({ cwd, port }); + }); + test.afterAll(() => stop()); + + test("Vite / dev / route added", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + // wait for hydration to make sure initial virtual modules are loaded + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); + + // add new route file + await fs.writeFile( + path.join(cwd, "app/routes/new.tsx"), + String.raw` + export default function Route() { + return ( +
    new route
    + ); + } + `, + "utf-8" + ); + + // client is not notified of new route addition (https://github.com/remix-run/remix/issues/7894) + // however server can handle new route + await expect + .poll(async () => { + await page.goto(`http://localhost:${port}/new`); + return page.getByText("new route").isVisible(); + }) + .toBe(true); + }); +}); diff --git a/integration/vite-route-exports-modified-offscreen-test.ts b/integration/vite-route-exports-modified-offscreen-test.ts new file mode 100644 index 0000000000..66e0737b1e --- /dev/null +++ b/integration/vite-route-exports-modified-offscreen-test.ts @@ -0,0 +1,102 @@ +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { + createProject, + createEditor, + viteDev, + viteConfig, +} from "./helpers/vite.js"; + +const files = { + "app/routes/_index.tsx": String.raw` + import { useState, useEffect } from "react"; + import { Link } from "@remix-run/react"; + + export default function IndexRoute() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( +
    +

    Mounted: {mounted ? "yes" : "no"}

    + /other +
    + ); + } + `, + "app/routes/other.tsx": String.raw` + import { useLoaderData } from "@remix-run/react"; + + export const loader = () => "hello"; + + export default function Route() { + const loaderData = useLoaderData(); + return ( +
    loaderData = {JSON.stringify(loaderData)}
    + ); + } + `, +}; + +test.describe(async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await viteConfig.basic({ port }), + ...files, + }); + stop = await viteDev({ cwd, port }); + }); + test.afterAll(() => stop()); + + test("Vite / dev / route exports modified offscreen", async ({ + page, + context, + browserName, + }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + let edit = createEditor(cwd); + + await page.goto(`http://localhost:${port}`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); + expect(pageErrors).toEqual([]); + + let originalContents: string; + + // Removing loader export in other page should invalidate manifest + await edit("app/routes/other.tsx", (contents) => { + originalContents = contents; + return contents.replace(/export const loader.*/, ""); + }); + + // After browser reload, client should be aware that there's no loader on the other route + if (browserName === "webkit") { + // Force new page instance for webkit. + // Otherwise browser doesn't seem to fetch new manifest probably due to caching. + page = await context.newPage(); + } + await page.goto(`http://localhost:${port}`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); + await page.getByRole("link", { name: "/other" }).click(); + await expect(page.locator("[data-loader-data]")).toHaveText( + "loaderData = null" + ); + expect(pageErrors).toEqual([]); + + // Revert route to original state to check HMR works and to ensure the + // original file contents were valid + await edit("app/routes/other.tsx", () => originalContents); + await expect(page.locator("[data-loader-data]")).toHaveText( + 'loaderData = "hello"' + ); + expect(pageErrors).toEqual([]); + }); +}); diff --git a/integration/vite-server-bundles-test.ts b/integration/vite-server-bundles-test.ts new file mode 100644 index 0000000000..696b6040b7 --- /dev/null +++ b/integration/vite-server-bundles-test.ts @@ -0,0 +1,434 @@ +import fs from "node:fs"; +import path from "node:path"; +import { type Page, test, expect } from "@playwright/test"; +import getPort from "get-port"; +import dedent from "dedent"; + +import { + createProject, + viteDev, + viteBuild, + viteRemixServe, + viteConfig, +} from "./helpers/vite.js"; + +const withBundleServer = async ( + cwd: string, + serverBundle: string, + callback: (port: number) => Promise +): Promise => { + let port = await getPort(); + let stop = await viteRemixServe({ cwd, port, serverBundle }); + await callback(port); + stop(); +}; + +const ROUTE_FILE_COMMENT = "// THIS IS A ROUTE FILE"; + +function createRoute(path: string) { + return { + [`app/routes/${path}`]: ` + ${ROUTE_FILE_COMMENT} + import { Outlet } from "@remix-run/react"; + import { useState, useEffect } from "react"; + + export default function Route() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + return ( + <> +
    + Route: ${path} + {mounted ? (Mounted) : null} +
    + + + ); + } + `, + }; +} + +const TEST_ROUTES = [ + "_index.tsx", + + // Bundle A has an index route + "bundle-a.tsx", + "bundle-a._index.tsx", + "bundle-a.route-a.tsx", + "bundle-a.route-b.tsx", + + // Bundle B doesn't have an index route + "bundle-b.tsx", + "bundle-b.route-a.tsx", + "bundle-b.route-b.tsx", + + // Bundle C is nested in a pathless route + "_pathless.tsx", + "_pathless.bundle-c.tsx", + "_pathless.bundle-c.route-a.tsx", + "_pathless.bundle-c.route-b.tsx", +]; + +const files = { + "app/root.tsx": ` + ${ROUTE_FILE_COMMENT} + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + ...Object.assign({}, ...TEST_ROUTES.map(createRoute)), +}; + +const expectRenderedRoutes = async (page: Page, routeFiles: string[]) => { + await Promise.all( + TEST_ROUTES.map(async (routeFile) => { + let locator = page.locator( + `[data-route-file="${routeFile}"] [data-mounted]` + ); + if (routeFiles.includes(routeFile)) { + await expect(locator).toBeAttached(); + } else { + // Assert no other routes are rendered + await expect(locator).not.toBeAttached(); + } + }) + ); +}; + +test.describe(() => { + let cwd: string; + let port: number; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.ts": dedent` + import { vitePlugin as remix } from "@remix-run/dev"; + + export default { + ${await viteConfig.server({ port })} + build: { manifest: true }, + plugins: [remix({ + manifest: true, + serverBundles: async ({ branch }) => { + // Smoke test to ensure we can read the route files via 'route.file' + await Promise.all(branch.map(async (route) => { + const fs = await import("node:fs/promises"); + const routeFileContents = await fs.readFile(route.file, "utf8"); + if (!routeFileContents.includes(${JSON.stringify( + ROUTE_FILE_COMMENT + )})) { + throw new Error("Couldn't file route file test comment"); + } + })); + + if (branch.some((route) => route.id === "routes/_index")) { + return "root"; + } + + if (branch.some((route) => route.id === "routes/bundle-a")) { + return "bundle-a"; + } + + if (branch.some((route) => route.id === "routes/bundle-b")) { + return "bundle-b"; + } + + if (branch.some((route) => route.id === "routes/_pathless.bundle-c")) { + return "bundle-c"; + } + + throw new Error("No bundle defined for route " + branch[branch.length - 1].id); + } + })] + } + `, + ...files, + }); + }); + + test.describe(() => { + let stop: () => void; + test.beforeAll(async () => { + stop = await viteDev({ cwd, port }); + }); + + test.afterAll(() => stop()); + + test("Vite / server bundles / dev", async ({ page }) => { + // There are no server bundles in dev mode, this is just a smoke test to + // ensure dev mode works and that routes from all bundles are available + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/`); + await expectRenderedRoutes(page, ["_index.tsx"]); + + await page.goto(`http://localhost:${port}/bundle-a`); + await expectRenderedRoutes(page, ["bundle-a.tsx", "bundle-a._index.tsx"]); + + await page.goto(`http://localhost:${port}/bundle-b`); + await expectRenderedRoutes(page, ["bundle-b.tsx"]); + + await page.goto(`http://localhost:${port}/bundle-c`); + await expectRenderedRoutes(page, [ + "_pathless.tsx", + "_pathless.bundle-c.tsx", + ]); + + expect(pageErrors).toEqual([]); + }); + }); + + test.describe(() => { + test.beforeAll(() => viteBuild({ cwd })); + + test("Vite / server bundles / build / server", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await withBundleServer(cwd, "root", async (port) => { + await page.goto(`http://localhost:${port}/`); + await expectRenderedRoutes(page, ["_index.tsx"]); + + let _404s = ["/bundle-a", "/bundle-b", "/bundle-c"]; + for (let path of _404s) { + let response = await page.goto(`http://localhost:${port}${path}`); + expect(response?.status()).toBe(404); + } + }); + + await withBundleServer(cwd, "bundle-a", async (port) => { + await page.goto(`http://localhost:${port}/bundle-a`); + await expectRenderedRoutes(page, [ + "bundle-a.tsx", + "bundle-a._index.tsx", + ]); + + await page.goto(`http://localhost:${port}/bundle-a/route-a`); + await expectRenderedRoutes(page, [ + "bundle-a.tsx", + "bundle-a.route-a.tsx", + ]); + + await page.goto(`http://localhost:${port}/bundle-a/route-b`); + await expectRenderedRoutes(page, [ + "bundle-a.tsx", + "bundle-a.route-b.tsx", + ]); + + let _404s = ["/bundle-b", "/bundle-c"]; + for (let path of _404s) { + let response = await page.goto(`http://localhost:${port}${path}`); + expect(response?.status()).toBe(404); + } + }); + + await withBundleServer(cwd, "bundle-b", async (port) => { + await page.goto(`http://localhost:${port}/bundle-b`); + await expectRenderedRoutes(page, ["bundle-b.tsx"]); + + await page.goto(`http://localhost:${port}/bundle-b/route-a`); + await expectRenderedRoutes(page, [ + "bundle-b.tsx", + "bundle-b.route-a.tsx", + ]); + + await page.goto(`http://localhost:${port}/bundle-b/route-b`); + await expectRenderedRoutes(page, [ + "bundle-b.tsx", + "bundle-b.route-b.tsx", + ]); + + let _404s = ["/bundle-a", "/bundle-c"]; + for (let path of _404s) { + let response = await page.goto(`http://localhost:${port}${path}`); + expect(response?.status()).toBe(404); + } + }); + + await withBundleServer(cwd, "bundle-c", async (port) => { + await page.goto(`http://localhost:${port}/bundle-c`); + await expectRenderedRoutes(page, [ + "_pathless.tsx", + "_pathless.bundle-c.tsx", + ]); + + await page.goto(`http://localhost:${port}/bundle-c/route-a`); + await expectRenderedRoutes(page, [ + "_pathless.tsx", + "_pathless.bundle-c.tsx", + "_pathless.bundle-c.route-a.tsx", + ]); + + await page.goto(`http://localhost:${port}/bundle-c/route-b`); + await expectRenderedRoutes(page, [ + "_pathless.tsx", + "_pathless.bundle-c.tsx", + "_pathless.bundle-c.route-b.tsx", + ]); + + let _404s = ["/bundle-a", "/bundle-b"]; + for (let path of _404s) { + let response = await page.goto(`http://localhost:${port}${path}`); + expect(response?.status()).toBe(404); + } + }); + + expect(pageErrors).toEqual([]); + }); + + test("Vite / server bundles / build / Remix browser manifest", () => { + let clientAssetFiles = fs.readdirSync( + path.join(cwd, "build", "client", "assets") + ); + let manifestFiles = clientAssetFiles.filter((filename) => + filename.startsWith("manifest-") + ); + + expect(manifestFiles.length).toEqual(1); + }); + + test("Vite / server bundles / build / Vite manifests", () => { + let viteManifestFiles = fs.readdirSync(path.join(cwd, "build", ".vite")); + + expect(viteManifestFiles).toEqual([ + "client-manifest.json", + "server-bundle-a-manifest.json", + "server-bundle-b-manifest.json", + "server-bundle-c-manifest.json", + "server-root-manifest.json", + ]); + }); + + test("Vite / server bundles / build / Remix manifest", () => { + let manifestPath = path.join(cwd, "build", ".remix", "manifest.json"); + expect(JSON.parse(fs.readFileSync(manifestPath, "utf8"))).toEqual({ + serverBundles: { + "bundle-c": { + id: "bundle-c", + file: "build/server/bundle-c/index.js", + }, + "bundle-a": { + id: "bundle-a", + file: "build/server/bundle-a/index.js", + }, + "bundle-b": { + id: "bundle-b", + file: "build/server/bundle-b/index.js", + }, + root: { + id: "root", + file: "build/server/root/index.js", + }, + }, + routeIdToServerBundleId: { + "routes/_pathless.bundle-c.route-a": "bundle-c", + "routes/_pathless.bundle-c.route-b": "bundle-c", + "routes/_pathless.bundle-c": "bundle-c", + "routes/bundle-a.route-a": "bundle-a", + "routes/bundle-a.route-b": "bundle-a", + "routes/bundle-b.route-a": "bundle-b", + "routes/bundle-b.route-b": "bundle-b", + "routes/bundle-a._index": "bundle-a", + "routes/bundle-b": "bundle-b", + "routes/_index": "root", + }, + routes: { + root: { + path: "", + id: "root", + file: "app/root.tsx", + }, + "routes/_pathless.bundle-c.route-a": { + file: "app/routes/_pathless.bundle-c.route-a.tsx", + id: "routes/_pathless.bundle-c.route-a", + path: "route-a", + parentId: "routes/_pathless.bundle-c", + }, + "routes/_pathless.bundle-c.route-b": { + file: "app/routes/_pathless.bundle-c.route-b.tsx", + id: "routes/_pathless.bundle-c.route-b", + path: "route-b", + parentId: "routes/_pathless.bundle-c", + }, + "routes/_pathless.bundle-c": { + file: "app/routes/_pathless.bundle-c.tsx", + id: "routes/_pathless.bundle-c", + path: "bundle-c", + parentId: "routes/_pathless", + }, + "routes/bundle-a.route-a": { + file: "app/routes/bundle-a.route-a.tsx", + id: "routes/bundle-a.route-a", + path: "route-a", + parentId: "routes/bundle-a", + }, + "routes/bundle-a.route-b": { + file: "app/routes/bundle-a.route-b.tsx", + id: "routes/bundle-a.route-b", + path: "route-b", + parentId: "routes/bundle-a", + }, + "routes/bundle-b.route-a": { + file: "app/routes/bundle-b.route-a.tsx", + id: "routes/bundle-b.route-a", + path: "route-a", + parentId: "routes/bundle-b", + }, + "routes/bundle-b.route-b": { + file: "app/routes/bundle-b.route-b.tsx", + id: "routes/bundle-b.route-b", + path: "route-b", + parentId: "routes/bundle-b", + }, + "routes/bundle-a._index": { + file: "app/routes/bundle-a._index.tsx", + id: "routes/bundle-a._index", + index: true, + parentId: "routes/bundle-a", + }, + "routes/_pathless": { + file: "app/routes/_pathless.tsx", + id: "routes/_pathless", + parentId: "root", + }, + "routes/bundle-a": { + file: "app/routes/bundle-a.tsx", + id: "routes/bundle-a", + path: "bundle-a", + parentId: "root", + }, + "routes/bundle-b": { + file: "app/routes/bundle-b.tsx", + id: "routes/bundle-b", + path: "bundle-b", + parentId: "root", + }, + "routes/_index": { + file: "app/routes/_index.tsx", + id: "routes/_index", + index: true, + parentId: "root", + }, + }, + }); + }); + }); +}); diff --git a/integration/vite-server-fs-allow-test.ts b/integration/vite-server-fs-allow-test.ts new file mode 100644 index 0000000000..e38bc2cc0d --- /dev/null +++ b/integration/vite-server-fs-allow-test.ts @@ -0,0 +1,51 @@ +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { + createProject, + customDev, + EXPRESS_SERVER, + viteConfig, +} from "./helpers/vite.js"; + +let files = { + "app/routes/test-route.tsx": String.raw` + export default function IndexRoute() { + return
    Hello world
    + } + `, +}; + +test.describe(async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.ts": await viteConfig.basic({ port, fsAllow: ["app"] }), + "server.mjs": EXPRESS_SERVER({ port }), + ...files, + }); + stop = await customDev({ cwd, port }); + }); + test.afterAll(() => stop()); + + test("Vite / server.fs.allow / works with basic allow list", async ({ + page, + }) => { + let pageErrors: unknown[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + await page.goto(`http://localhost:${port}/test-route`, { + waitUntil: "networkidle", + }); + expect(pageErrors).toEqual([]); + + let testContent = page.locator("#test"); + await expect(testContent).toBeAttached(); + + expect(pageErrors).toEqual([]); + }); +}); diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts new file mode 100644 index 0000000000..d7d6adc87a --- /dev/null +++ b/integration/vite-spa-mode-test.ts @@ -0,0 +1,926 @@ +import fs from "node:fs"; +import path from "node:path"; +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + css, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { createProject, viteBuild } from "./helpers/vite.js"; + +test.describe("SPA Mode", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.describe("custom builds", () => { + test.describe("build errors", () => { + test("errors on server-only exports", async () => { + let cwd = await createProject({ + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ ssr: false })], + }); + `, + "app/routes/invalid-exports.tsx": String.raw` + // Invalid exports + export function headers() {} + export function loader() {} + export function action() {} + + // Valid exports + export function clientLoader() {} + export function clientAction() {} + export default function Component() {} + `, + }); + let result = viteBuild({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "SPA Mode: 3 invalid route export(s) in `routes/invalid-exports.tsx`: " + + "`headers`, `loader`, `action`. See https://remix.run/future/spa-mode " + + "for more information." + ); + }); + + test("errors on HydrateFallback export from non-root route", async () => { + let cwd = await createProject({ + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ ssr: false })], + }); + `, + "app/routes/invalid-exports.tsx": String.raw` + // Invalid exports + export function HydrateFallback() {} + + // Valid exports + export function clientLoader() {} + export function clientAction() {} + export default function Component() {} + `, + }); + let result = viteBuild({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "SPA Mode: Invalid `HydrateFallback` export found in `routes/invalid-exports.tsx`. " + + "`HydrateFallback` is only permitted on the root route in SPA Mode. " + + "See https://remix.run/future/spa-mode for more information." + ); + }); + + test("errors on a non-200 status from entry.server.tsx", async () => { + let cwd = await createProject({ + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ ssr: false })], + }); + `, + "app/entry.server.tsx": js` + import { RemixServer } from "@remix-run/react"; + import { renderToString } from "react-dom/server"; + + export default function handleRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) { + const html = renderToString( + + ); + return new Response(html, { + headers: { "Content-Type": "text/html" }, + status: 500, + }); + } + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + + export function HydrateFallback() { + return ( + + + + + + +

    Loading...

    + + + + ); + } + `, + }); + let result = viteBuild({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "SPA Mode: Received a 500 status code from `entry.server.tsx` while " + + "generating the `index.html` file." + ); + expect(stderr).toMatch("

    Loading...

    "); + }); + + test("errors if you do not include in your root ", async () => { + let cwd = await createProject({ + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ ssr: false })], + }); + `, + "app/root.tsx": String.raw` + export function HydrateFallback() { + return

    Loading

    + } + `, + }); + let result = viteBuild({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "SPA Mode: Did you forget to include in your `root.tsx` " + + "`HydrateFallback` component? Your `index.html` file cannot hydrate " + + "into a SPA without ``." + ); + }); + }); + + test("prepends DOCTYPE to HTML in the default entry.server.tsx", async () => { + let fixture = await createFixture({ + compiler: "vite", + spaMode: true, + files: { + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ ssr: false })], + }); + `, + "app/root.tsx": js` + import { Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + +

    Root

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

    Loading SPA...

    + + + + ); + } + `, + }, + }); + let res = await fixture.requestDocument("/"); + expect(await res.text()).toMatch(/^\n/); + }); + + test("works when combined with a basename", async ({ page }) => { + fixture = await createFixture({ + compiler: "vite", + spaMode: true, + files: { + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ + basename: "/base/", + ssr: false + })], + }); + `, + "app/root.tsx": js` + import { Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + + + +

    Root

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

    Loading SPA...

    + + + + ); + } + `, + "app/routes/_index.tsx": js` + import * as React from "react"; + import { useLoaderData } from "@remix-run/react"; + + export async function clientLoader({ request }) { + return "Index Loader Data"; + } + + export default function Component() { + let data = useLoaderData(); + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); + + return ( + <> +

    Index

    +

    {data}

    + {!mounted ?

    Unmounted

    :

    Mounted

    } + + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/base/"); + await page.waitForSelector("[data-mounted]"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + expect(await page.locator("[data-loader-data]").textContent()).toBe( + "Index Loader Data" + ); + }); + + test("can be used to hydrate only a div", async ({ page }) => { + fixture = await createFixture({ + compiler: "vite", + spaMode: true, + files: { + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ ssr: false })], + }); + `, + "app/index.html": String.raw` + + + + Not from Remix! + + +
    + + + `, + "app/entry.client.tsx": js` + import { RemixBrowser } from "@remix-run/react"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + + startTransition(() => { + hydrateRoot( + document.querySelector("#app"), + + + + ); + }); + `, + "app/entry.server.tsx": js` + import fs from "node:fs"; + import path from "node:path"; + + import type { EntryContext } from "@remix-run/node"; + import { RemixServer } from "@remix-run/react"; + import { renderToString } from "react-dom/server"; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + const shellHtml = fs + .readFileSync( + path.join(process.cwd(), "app/index.html") + ) + .toString(); + + const appHtml = renderToString( + + ); + + const html = shellHtml.replace( + "", + appHtml + ); + + return new Response(html, { + headers: { "Content-Type": "text/html" }, + status: responseStatusCode, + }); + } + `, + "app/root.tsx": js` + import { Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + return ( + <> +

    Root

    + + + + ); + } + + export function HydrateFallback() { + return ( + <> +

    Loading SPA...

    + + + ); + } + `, + "app/routes/_index.tsx": js` + import * as React from "react"; + import { useLoaderData } from "@remix-run/react"; + + export async function clientLoader({ request }) { + return "Index Loader Data"; + } + + export default function Component() { + let data = useLoaderData(); + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); + + return ( + <> +

    Index

    +

    {data}

    + {!mounted ?

    Unmounted

    :

    Mounted

    } + + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("title").textContent()).toBe("Not from Remix!"); + await page.waitForSelector("[data-mounted]"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + expect(await page.locator("[data-loader-data]").textContent()).toBe( + "Index Loader Data" + ); + }); + + test("works for migration apps with only a root route and no loader", async ({ + page, + }) => { + fixture = await createFixture({ + compiler: "vite", + spaMode: true, + files: { + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ + // We don't want to pick up the app/routes/_index.tsx file from + // the template and instead want to use only the src/root.tsx + // file below + appDirectory: "src", + ssr: false, + })], + }); + `, + "src/root.tsx": js` + import { + Meta, + Links, + Outlet, + Routes, + Route, + Scripts, + ScrollRestoration, + } from "@remix-run/react"; + + export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + + ); + } + + export default function Root() { + return ( + <> +

    Root

    + + Index} /> + + + ); + } + + export function HydrateFallback() { + return

    Loading SPA...

    ; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch('

    Loading SPA...

    '); + + let logs: string[] = []; + page.on("console", (msg) => logs.push(msg.text())); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("[data-root]"); + expect(await page.locator("[data-root]").textContent()).toBe("Root"); + expect(await page.locator("[data-index]").textContent()).toBe("Index"); + + // Hydrates without issues + expect(logs).toEqual([]); + }); + + test("wraps default root HydrateFallback in user-provided Layout", async ({ + page, + }) => { + fixture = await createFixture({ + compiler: "vite", + spaMode: true, + files: { + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ + // We don't want to pick up the app/routes/_index.tsx file from + // the template and instead want to use only the src/root.tsx + // file below + appDirectory: "src", + ssr: false, + })], + }); + `, + "src/root.tsx": js` + import { + Meta, + Links, + Outlet, + Routes, + Route, + Scripts, + ScrollRestoration, + } from "@remix-run/react"; + + export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + + ); + } + + export default function Root() { + return ( + <> +

    Root

    + + Index} /> + + + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html.match(/ { + test.beforeAll(async () => { + fixture = await createFixture({ + compiler: "vite", + spaMode: true, + files: { + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + build: { manifest: true }, + plugins: [remix({ ssr: false })], + }); + `, + "public/styles-root.css": css` + body { + background-color: rgba(255, 0, 0, 0.25); + } + `, + "public/styles-index.css": css` + body { + background-color: rgba(0, 255, 0, 0.25); + } + `, + "app/root.tsx": js` + import * as React from "react"; + import { Form, Link, Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export function meta({ data }) { + return [{ + title: "Root Title" + }]; + } + + export function links() { + return [{ + rel: "stylesheet", + href: "styles-root.css" + }]; + } + + export default function Root() { + let id = React.useId(); + return ( + + + + + + +

    Root

    +
    {id}
    + + + + + + ); + } + + export function HydrateFallback() { + const id = React.useId(); + const [hydrated, setHydrated] = React.useState(false); + React.useEffect(() => setHydrated(true), []); + + return ( + + + + + + +

    Loading SPA...

    +
    {id}
    + {hydrated ?

    Hydrated

    : null} + + + + ); + } + `, + "app/routes/_index.tsx": js` + import * as React from "react"; + import { useLoaderData } from "@remix-run/react"; + + export function meta({ data }) { + return [{ + title: "Index Title: " + data + }]; + } + + export function links() { + return [{ + rel: "stylesheet", + href: "styles-index.css" + }]; + } + + export async function clientLoader({ request }) { + if (new URL(request.url).searchParams.has('slow')) { + await new Promise(r => setTimeout(r, 500)); + } + return "Index Loader Data"; + } + + export default function Component() { + let data = useLoaderData(); + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); + + return ( + <> +

    Index

    +

    {data}

    + {!mounted ?

    Unmounted

    :

    Mounted

    } + + ); + } + `, + "app/routes/about.tsx": js` + import { useActionData, useLoaderData } from "@remix-run/react"; + + export function meta({ data }) { + return [{ + title: "About Title: " + data + }]; + } + + export function clientLoader() { + return "About Loader Data"; + } + + export function clientAction() { + return "About Action Data"; + } + + export default function Component() { + let data = useLoaderData(); + let actionData = useActionData(); + + return ( + <> +

    About

    +

    {data}

    +

    {actionData}

    + + ); + } + `, + "app/routes/error.tsx": js` + import { useRouteError } from "@remix-run/react"; + + export async function clientLoader({ serverLoader }) { + await serverLoader(); + return null; + } + + export async function clientAction({ serverAction }) { + await serverAction(); + return null; + } + + export default function Component() { + return

    Error

    ; + } + + export function ErrorBoundary() { + let error = useRouteError(); + return
    {error.data}
    + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("renders the root HydrateFallback initially", async ({ page }) => { + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch('

    Loading SPA...

    '); + }); + + test("does not include Meta/Links from routes below the root", async ({ + page, + }) => { + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch("Root Title"); + expect(html).toMatch(''); + expect(html).not.toMatch("Index Title"); + expect(html).not.toMatch("styles-index.css"); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("[data-mounted]"); + expect(await page.locator('link[href="styles-index.css"]')).toBeDefined(); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + expect(await page.locator("title").textContent()).toBe( + "Index Title: Index Loader Data" + ); + }); + + test("hydrates", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + expect(await page.locator("[data-loader-data]").textContent()).toBe( + "Index Loader Data" + ); + expect(await page.locator("[data-mounted]").textContent()).toBe( + "Mounted" + ); + expect(await page.locator("title").textContent()).toBe( + "Index Title: Index Loader Data" + ); + }); + + test("hydrates a proper useId value", async ({ page }) => { + // SSR'd useId value we can assert against pre- and post-hydration + let USE_ID_VALUE = ":R5:"; + + // Ensure we SSR a proper useId value + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch(`
    ${USE_ID_VALUE}
    `); + + // We should hydrate the same useId value in HydrateFallback + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?slow"); + await page.waitForSelector("[data-hydrated]"); + expect(await page.locator("[data-use-id]").textContent()).toBe( + USE_ID_VALUE + ); + + // Once hydrated, we should get a different useId value from the root Component + await page.waitForSelector("[data-route]"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + expect(await page.locator("[data-use-id]").textContent()).not.toBe( + USE_ID_VALUE + ); + }); + + test("navigates and calls loaders", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + + await app.clickLink("/about"); + await page.waitForSelector('[data-route]:has-text("About")'); + expect(await page.locator("[data-route]").textContent()).toBe("About"); + expect(await page.locator("[data-loader-data]").textContent()).toBe( + "About Loader Data" + ); + expect(await page.locator("title").textContent()).toBe( + "About Title: About Loader Data" + ); + }); + + test("navigates and calls actions/loaders", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + + await app.clickSubmitButton("/about"); + await page.waitForSelector('[data-route]:has-text("About")'); + expect(await page.locator("[data-route]").textContent()).toBe("About"); + expect(await page.locator("[data-action-data]").textContent()).toBe( + "About Action Data" + ); + expect(await page.locator("[data-loader-data]").textContent()).toBe( + "About Loader Data" + ); + expect(await page.locator("title").textContent()).toBe( + "About Title: About Loader Data" + ); + }); + + test("errors if you call serverLoader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + + await app.clickLink("/error"); + await page.waitForSelector("[data-error]"); + expect(await page.locator("[data-error]").textContent()).toBe( + 'Error: You cannot call serverLoader() in SPA Mode (routeId: "routes/error")' + ); + }); + + test("errors if you call serverAction", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + + await app.clickSubmitButton("/error"); + await page.waitForSelector("[data-error]"); + expect(await page.locator("[data-error]").textContent()).toBe( + 'Error: You cannot call serverAction() in SPA Mode (routeId: "routes/error")' + ); + }); + + test("only generates client Vite manifest", () => { + let viteManifestFiles = fs.readdirSync( + path.join(fixture.projectDir, "build", ".vite") + ); + + expect(viteManifestFiles).toEqual(["client-manifest.json"]); + }); + }); +}); diff --git a/integration/vite-unused-route-exports-test.ts b/integration/vite-unused-route-exports-test.ts new file mode 100644 index 0000000000..1dd7d2afd5 --- /dev/null +++ b/integration/vite-unused-route-exports-test.ts @@ -0,0 +1,37 @@ +import * as path from "node:path"; +import { test, expect } from "@playwright/test"; + +import { createProject, grep, viteBuild } from "./helpers/vite.js"; + +test("Vite / dead-code elimination for unused route exports", async () => { + let cwd = await createProject({ + "app/routes/custom-route-exports.tsx": String.raw` + const unusedMessage = "ROUTE_EXPORT_THAT_ISNT_USED"; + const usedMessage = "ROUTE_EXPORT_THAT_IS_USED"; + + export const unusedRouteExport = unusedMessage; + export const usedRouteExport = usedMessage; + + export default function CustomExportsRoute() { + return

    Custom route exports

    + } + `, + "app/routes/use-route-export.tsx": String.raw` + import { usedRouteExport } from "./custom-route-exports"; + + export default function CustomExportsRoute() { + return

    {usedRouteExport}

    + } + `, + }); + let { status } = viteBuild({ cwd }); + expect(status).toBe(0); + + expect( + grep(path.join(cwd, "build/client"), /ROUTE_EXPORT_THAT_ISNT_USED/).length + ).toBe(0); + + expect( + grep(path.join(cwd, "build/client"), /ROUTE_EXPORT_THAT_IS_USED/).length + ).toBeGreaterThanOrEqual(1); +}); diff --git a/packages/remix-dev/.gitignore b/packages/remix-dev/.gitignore new file mode 100644 index 0000000000..a94178405e --- /dev/null +++ b/packages/remix-dev/.gitignore @@ -0,0 +1,2 @@ +server-build.js +server-build.d.ts diff --git a/packages/remix-dev/CHANGELOG.md b/packages/remix-dev/CHANGELOG.md new file mode 100644 index 0000000000..6d323831eb --- /dev/null +++ b/packages/remix-dev/CHANGELOG.md @@ -0,0 +1,1774 @@ +# `@remix-run/dev` + +## 2.9.0-pre.0 + +### Minor Changes + +- New `future.unstable_singleFetch` flag ([#8773](https://github.com/remix-run/remix/pull/8773)) + + - Naked objects returned from loaders/actions are no longer automatically converted to JSON responses. They'll be streamed as-is via `turbo-stream` so `Date`'s will become `Date` through `useLoaderData()` + - You can return naked objects with `Promise`'s without needing to use `defer()` - including nested `Promise`'s + - If you need to return a custom status code or custom response headers, you can still use the `defer` utility + - `` is no longer used. Instead, you should `export const streamTimeout` from `entry.server.tsx` and the remix server runtime will use that as the delay to abort the streamed response + - If you export your own streamTimeout, you should decouple that from aborting the react `renderToPipeableStream`. You should always ensure that react is aborted _afer_ the stream is aborted so that abort rejections can be flushed down + - Actions no longer automatically revalidate on 4xx/5xx responses (via RR `future.unstable_skipActionErrorRevalidation` flag) - you can return a 2xx to opt-into revalidation or use `shouldRevalidate` + +### Patch Changes + +- Improve `getDependenciesToBundle` resolution in monorepos ([#8848](https://github.com/remix-run/remix/pull/8848)) +- Fix SPA mode when single fetch is enabled by using streaming entry.server ([#9063](https://github.com/remix-run/remix/pull/9063)) +- Vite: added sourcemap support for transformed routes ([#8970](https://github.com/remix-run/remix/pull/8970)) +- Updated dependencies: + - `@remix-run/node@2.9.0-pre.0` + - `@remix-run/server-runtime@2.9.0-pre.0` + - `@remix-run/react@2.9.0-pre.0` + - `@remix-run/serve@2.9.0-pre.0` + +## 2.8.1 + +### Patch Changes + +- Support reading from Vite config when running `remix reveal` and `remix routes` CLI commands ([#8916](https://github.com/remix-run/remix/pull/8916)) +- Add Vite commands to Remix CLI `--help` output ([#8939](https://github.com/remix-run/remix/pull/8939)) +- Vite: Fix support for `build.sourcemap` option in Vite config ([#8965](https://github.com/remix-run/remix/pull/8965)) +- Clean up redundant client route query strings on route JavaScript files in production builds ([#8969](https://github.com/remix-run/remix/pull/8969)) +- Vite: Fix error when using Vite's `server.fs.allow` option without a client entry file ([#8966](https://github.com/remix-run/remix/pull/8966)) +- Updated dependencies: + - `@remix-run/node@2.8.1` + - `@remix-run/server-runtime@2.8.1` + +## 2.8.0 + +### Minor Changes + +- Pass resolved `viteConfig` to Remix Vite plugin's `buildEnd` hook ([#8885](https://github.com/remix-run/remix/pull/8885)) + +### Patch Changes + +- Mark `Layout` as browser safe route export in `esbuild` compiler ([#8842](https://github.com/remix-run/remix/pull/8842)) +- Vite: Silence build warnings when dependencies include "use client" directives ([#8897](https://github.com/remix-run/remix/pull/8897)) +- Vite: Fix `serverBundles` issue where multiple browser manifests are generated ([#8864](https://github.com/remix-run/remix/pull/8864)) +- Support custom Vite `build.assetsDir` option ([#8843](https://github.com/remix-run/remix/pull/8843)) +- Updated dependencies: + - `@remix-run/node@2.8.0` + - `@remix-run/server-runtime@2.8.0` + +## 2.7.2 + +### Patch Changes + +- Vite: Fix error when building projects with `.css?url` imports ([#8829](https://github.com/remix-run/remix/pull/8829)) +- Updated dependencies: + - `@remix-run/node@2.7.2` + - `@remix-run/server-runtime@2.7.2` + +## 2.7.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.7.1` + - `@remix-run/server-runtime@2.7.1` + +## 2.7.0 + +### Minor Changes + +- Allow an optional `Layout` export from the root route ([#8709](https://github.com/remix-run/remix/pull/8709)) + +- Vite: Cloudflare Proxy as a Vite plugin ([#8749](https://github.com/remix-run/remix/pull/8749)) + + **This is a breaking change for projects relying on Cloudflare support from the unstable Vite plugin** + + The Cloudflare preset (`unstable_cloudflarePreset`) as been removed and replaced with a new Vite plugin: + + ```diff + import { + unstable_vitePlugin as remix, + - unstable_cloudflarePreset as cloudflare, + + cloudflareDevProxyVitePlugin as remixCloudflareDevProxy, + } from "@remix-run/dev"; + import { defineConfig } from "vite"; + + export default defineConfig({ + plugins: [ + + remixCloudflareDevProxy(), + + remix(), + - remix({ + - presets: [cloudflare()], + - }), + ], + - ssr: { + - resolve: { + - externalConditions: ["workerd", "worker"], + - }, + - }, + }); + ``` + + `remixCloudflareDevProxy` must come _before_ the `remix` plugin so that it can override Vite's dev server middleware to be compatible with Cloudflare's proxied environment. + + Because it is a Vite plugin, `remixCloudflareDevProxy` can set `ssr.resolve.externalConditions` to be `workerd`-compatible for you. + + `remixCloudflareDevProxy` accepts a `getLoadContext` function that replaces the old `getRemixDevLoadContext`. + If you were using a `nightly` version that required `getBindingsProxy` or `getPlatformProxy`, that is no longer required. + Any options you were passing to `getBindingsProxy` or `getPlatformProxy` should now be passed to `remixCloudflareDevProxy` instead. + + This API also better aligns with future plans to support Cloudflare with a framework-agnostic Vite plugin that makes use of Vite's (experimental) Runtime API. + +- Vite: Stabilize the Remix Vite plugin, Cloudflare preset, and all related types by removing all `unstable_` / `Unstable_` prefixes. ([#8713](https://github.com/remix-run/remix/pull/8713)) + + While this is a breaking change for existing Remix Vite plugin consumers, now that the plugin has stabilized, there will no longer be any breaking changes outside of a major release. Thank you to all of our early adopters and community contributors for helping us get here! 🙏 + +- Vite: Stabilize "SPA Mode" by renaming the Remix vite plugin config from `unstable_ssr -> ssr` ([#8692](https://github.com/remix-run/remix/pull/8692)) + +- Vite: Add a new `basename` option to the Vite plugin, allowing users to set the internal React Router [`basename`](https://reactrouter.com/en/main/routers/create-browser-router#basename) in order to to serve their applications underneath a subpath ([#8145](https://github.com/remix-run/remix/pull/8145)) + +### Patch Changes + +- Vite: fix server exports dead-code elimination for routes outside of app directory ([#8795](https://github.com/remix-run/remix/pull/8795)) + +- Always prepend DOCTYPE in SPA mode entry.server.tsx, can opt out via remix reveal ([#8725](https://github.com/remix-run/remix/pull/8725)) + +- Fix build issue in SPA mode when using a `basename` ([#8720](https://github.com/remix-run/remix/pull/8720)) + +- Vite: Validate that the MDX Rollup plugin, if present, is placed before Remix in Vite config ([#8690](https://github.com/remix-run/remix/pull/8690)) + +- Vite: reliably detect non-root routes in Windows ([#8806](https://github.com/remix-run/remix/pull/8806)) + + Sometimes route `file` will be unnormalized Windows path with `\` instead of `/`. + +- Vite: Pass `remixUserConfig` to preset `remixConfig` hook ([#8797](https://github.com/remix-run/remix/pull/8797)) + +- Vite: Fix issue resolving critical CSS during development when the current working directory differs from the project root ([#8752](https://github.com/remix-run/remix/pull/8752)) + +- Vite: Ensure CSS file URLs that are only referenced in the server build are available on the client ([#8796](https://github.com/remix-run/remix/pull/8796)) + +- Vite: Require version 5.1.0 to support `.css?url` imports ([#8723](https://github.com/remix-run/remix/pull/8723)) + +- Fix type error in Remix config for synchronous `routes` function ([#8745](https://github.com/remix-run/remix/pull/8745)) + +- Vite: Support Vite v5.1.0's `.css?url` imports ([#8684](https://github.com/remix-run/remix/pull/8684)) + +- Always ignore route files starting with `.` ([#8801](https://github.com/remix-run/remix/pull/8801)) + +- Vite: Enable use of [`vite preview`](https://main.vitejs.dev/guide/static-deploy.html#deploying-a-static-site) to preview Remix SPA applications ([#8624](https://github.com/remix-run/remix/pull/8624)) + + - In the SPA template, `npm run start` has been renamed to `npm run preview` which uses `vite preview` instead of a standalone HTTP server such as `http-server` or `serv-cli` + +- Vite: Remove the ability to pass `publicPath` as an option to the Remix vite plugin ([#8145](https://github.com/remix-run/remix/pull/8145)) + + - ⚠️ **This is a breaking change for projects using the unstable Vite plugin with a `publicPath`** + - This is already handled in Vite via the [`base`](https://vitejs.dev/guide/build.html#public-base-path) config so we now set the Remix `publicPath` from the Vite `base` config + +- Vite: Fix issue where client route file requests fail if search params have been parsed and serialized before reaching the Remix Vite plugin ([#8740](https://github.com/remix-run/remix/pull/8740)) + +- Vite: Enable HMR for .md and .mdx files ([#8711](https://github.com/remix-run/remix/pull/8711)) + +- Updated dependencies: + - `@remix-run/server-runtime@2.7.0` + - `@remix-run/node@2.7.0` + +## 2.6.0 + +### Minor Changes + +- Add `future.v3_throwAbortReason` flag to throw `request.signal.reason` when a request is aborted instead of an `Error` such as `new Error("query() call aborted: GET /path")` ([#8251](https://github.com/remix-run/remix/pull/8251)) + +### Patch Changes + +- Vite: Add `manifest` option to Vite plugin to enable writing a `.remix/manifest.json` file to the build directory ([#8575](https://github.com/remix-run/remix/pull/8575)) + + **This is a breaking change for consumers of the Vite plugin's "server bundles" feature.** + + The `build/server/bundles.json` file has been superseded by the more general `build/.remix/manifest.json`. While the old server bundles manifest was always written to disk when generating server bundles, the build manifest file must be explicitly enabled via the `manifest` option. + +- Vite: Provide `Unstable_ServerBundlesFunction` and `Unstable_VitePluginConfig` types ([#8654](https://github.com/remix-run/remix/pull/8654)) + +- Vite: add `--sourcemapClient` and `--sourcemapServer` flags to `remix vite:build` ([#8613](https://github.com/remix-run/remix/pull/8613)) + + - `--sourcemapClient` + + - `--sourcemapClient=inline` + + - `--sourcemapClient=hidden` + + - `--sourcemapServer` + + - `--sourcemapServer=inline` + + - `--sourcemapServer=hidden` + + See + +- Vite: Validate IDs returned from the `serverBundles` function to ensure they only contain alphanumeric characters, hyphens and underscores ([#8598](https://github.com/remix-run/remix/pull/8598)) + +- Vite: fix "could not fast refresh" false alarm ([#8580](https://github.com/remix-run/remix/pull/8580)) + + HMR is already functioning correctly but was incorrectly logging that it "could not fast refresh" on internal client routes. + Now internal client routes correctly register Remix exports like `meta` for fast refresh, + which removes the false alarm. + +- Vite: Cloudflare Pages support ([#8531](https://github.com/remix-run/remix/pull/8531)) + + To get started with Cloudflare, you can use the \[`unstable-vite-cloudflare`]\[template-vite-cloudflare] template: + + ```shellscript nonumber + npx create-remix@latest --template remix-run/remix/templates/unstable-vite-cloudflare + ``` + + Or read the new docs at [Future > Vite > Cloudflare](https://remix.run/docs/en/main/future/vite#cloudflare) and + [Future > Vite > Migrating > Migrating Cloudflare Functions](https://remix.run/docs/en/main/future/vite#migrating-cloudflare-functions). + +- Vite: Remove undocumented backwards compatibility layer for Vite v4 ([#8581](https://github.com/remix-run/remix/pull/8581)) + +- Vite: rely on Vite plugin ordering ([#8627](https://github.com/remix-run/remix/pull/8627)) + + **This is a breaking change for projects using the unstable Vite plugin.** + + The Remix plugin expects to process JavaScript or TypeScript files, so any transpilation from other languages must be done first. + For example, that means putting the MDX plugin _before_ the Remix plugin: + + ```diff + import mdx from "@mdx-js/rollup"; + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + import { defineConfig } from "vite"; + + export default defineConfig({ + plugins: [ + + mdx(), + remix() + - mdx(), + ], + }); + ``` + + Previously, the Remix plugin misused `enforce: "post"` from Vite's plugin API to ensure that it ran last. + However, this caused other unforeseen issues. + Instead, we now rely on standard Vite semantics for plugin ordering. + + The official [Vite React SWC plugin](https://github.com/vitejs/vite-plugin-react-swc/blob/main/src/index.ts#L97-L116) also relies on plugin ordering for MDX. + +- Vite: Add `presets` option to ease integration with different platforms and tools. ([#8514](https://github.com/remix-run/remix/pull/8514)) + +- Vite: Remove interop with ``, rely on `` instead ([#8636](https://github.com/remix-run/remix/pull/8636)) + + **This is a breaking change for projects using the unstable Vite plugin.** + + Vite provides a robust client-side runtime for development features like HMR, + making the `` component obsolete. + + In fact, having a separate dev scripts component was causing issues with script execution order. + To work around this, the Remix Vite plugin used to override `` into a bespoke + implementation that was compatible with Vite. + + Instead of all this indirection, now the Remix Vite plugin instructs the `` component + to automatically include Vite's client-side runtime and other dev-only scripts. + + ```diff + import { + - LiveReload, + Outlet, + Scripts, + } + + export default function App() { + return ( + + + + + + + - + + + ) + } + ``` + +- Vite: Add `buildEnd` hook ([#8620](https://github.com/remix-run/remix/pull/8620)) + +- Vite: add dev load context option to Cloudflare preset ([#8649](https://github.com/remix-run/remix/pull/8649)) + +- Vite: Add `mode` field into generated server build ([#8539](https://github.com/remix-run/remix/pull/8539)) + +- Vite: Only write Vite manifest files if `build.manifest` is enabled within the Vite config ([#8599](https://github.com/remix-run/remix/pull/8599)) + + **This is a breaking change for consumers of Vite's `manifest.json` files.** + + To explicitly enable generation of Vite manifest files, you must set `build.manifest` to `true` in your Vite config. + + ```ts + export default defineConfig({ + build: { manifest: true }, + // ... + }); + ``` + +- Vite: reduce network calls for route modules during HMR ([#8591](https://github.com/remix-run/remix/pull/8591)) + +- Vite: Add new `buildDirectory` option with a default value of `"build"`. This replaces the old `assetsBuildDirectory` and `serverBuildDirectory` options which defaulted to `"build/client"` and `"build/server"` respectively. ([#8575](https://github.com/remix-run/remix/pull/8575)) + + **This is a breaking change for consumers of the Vite plugin that were using the `assetsBuildDirectory` and `serverBuildDirectory` options.** + + The Remix Vite plugin now builds into a single directory containing `client` and `server` directories. If you've customized your build output directories, you'll need to migrate to the new `buildDirectory` option, e.g. + + ```diff + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + import { defineConfig } from "vite"; + + export default defineConfig({ + plugins: [ + remix({ + - serverBuildDirectory: "dist/server", + - assetsBuildDirectory: "dist/client", + + buildDirectory: "dist", + }) + ], + }); + ``` + +- Vite: Remove `unstable` prefix from `serverBundles` option. ([#8596](https://github.com/remix-run/remix/pull/8596)) + +- Vite: Write Vite manifest files to `build/.vite` directory rather than being nested within `build/client` and `build/server` directories. ([#8599](https://github.com/remix-run/remix/pull/8599)) + + **This is a breaking change for consumers of Vite's `manifest.json` files.** + + Vite manifest files are now written to the Remix build directory. Since all Vite manifests are now in the same directory, they're no longer named `manifest.json`. Instead, they're named `build/.vite/client-manifest.json` and `build/.vite/server-manifest.json`, or `build/.vite/server-{BUNDLE_ID}-manifest.json` when using server bundles. + +- Updated dependencies: + - `@remix-run/server-runtime@2.6.0` + - `@remix-run/node@2.6.0` + +## 2.5.1 + +### Patch Changes + +- Add `isSpaMode` to `@remix-run/dev/server-build` virtual module ([#8492](https://github.com/remix-run/remix/pull/8492)) +- Automatically prepend `` if not present to fix quirks mode warnings for SPA template ([#8495](https://github.com/remix-run/remix/pull/8495)) +- Vite: Errors for server-only code point to new docs ([#8488](https://github.com/remix-run/remix/pull/8488)) +- Vite: Fix HMR race condition when reading changed file contents ([#8479](https://github.com/remix-run/remix/pull/8479)) +- Vite: Tree-shake unused route exports in the client build ([#8468](https://github.com/remix-run/remix/pull/8468)) +- Vite: Performance profiling ([#8493](https://github.com/remix-run/remix/pull/8493)) + - Run `remix vite:build --profile` to generate a `.cpuprofile` that can be shared or uploaded to speedscope.app + - In dev, press `p + enter` to start a new profiling session or stop the current session + - If you need to profile dev server startup, run `remix vite:dev --profile` to initialize the dev server with a running profiling session + - For more, see the new docs: Vite > Performance +- Vite: Improve performance of dev server requests by invalidating Remix's virtual modules on relevant file changes rather than on every request ([#8164](https://github.com/remix-run/remix/pull/8164)) +- Updated dependencies: + - `@remix-run/node@2.5.1` + - `@remix-run/server-runtime@2.5.1` + +## 2.5.0 + +### Minor Changes + +- Add unstable support for "SPA Mode" ([#8457](https://github.com/remix-run/remix/pull/8457)) + + You can opt into SPA Mode by setting `unstable_ssr: false` in your Remix Vite plugin config: + + ```js + // vite.config.ts + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + import { defineConfig } from "vite"; + + export default defineConfig({ + plugins: [remix({ unstable_ssr: false })], + }); + ``` + + Development in SPA Mode is just like a normal Remix app, and still uses the Remix dev server for HMR/HDR: + + ```sh + remix vite:dev + ``` + + Building in SPA Mode will generate an `index.html` file in your client assets directory: + + ```sh + remix vite:build + ``` + + To run your SPA, you serve your client assets directory via an HTTP server: + + ```sh + npx http-server build/client + ``` + + For more information, please refer to the [SPA Mode docs](https://remix.run/future/spa-mode). + +- Add `unstable_serverBundles` option to Vite plugin to support splitting server code into multiple request handlers. ([#8332](https://github.com/remix-run/remix/pull/8332)) + + This is an advanced feature designed for hosting provider integrations. When compiling your app into multiple server bundles, there will need to be a custom routing layer in front of your app directing requests to the correct bundle. This feature is currently unstable and only designed to gather early feedback. + + **Example usage:** + + ```ts + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + import { defineConfig } from "vite"; + + export default defineConfig({ + plugins: [ + remix({ + unstable_serverBundles: ({ branch }) => { + const isAuthenticatedRoute = branch.some( + (route) => route.id === "routes/_authenticated" + ); + + return isAuthenticatedRoute ? "authenticated" : "unauthenticated"; + }, + }), + ], + }); + ``` + +### Patch Changes + +- Fix issue with `isbot` v4 released on 1/1/2024 ([#8415](https://github.com/remix-run/remix/pull/8415)) + + - `remix dev` will now add `"isbot": "^4"` to `package.json` instead of using `latest` + - Update built-in `entry.server` files to work with both `isbot@3` and `isbot@4` for backwards-compatibility with Remix apps that have pinned `isbot` to v3 + - Templates are updated to use `isbot@4` moving forward via `create-remix` + +- Vite: Fix HMR issues when altering exports for non-rendered routes ([#8157](https://github.com/remix-run/remix/pull/8157)) + +- Vite: Default `NODE_ENV` to `"production"` when running `remix vite:build` command ([#8405](https://github.com/remix-run/remix/pull/8405)) + +- Vite: Remove Vite plugin config option `serverBuildPath` in favor of separate `serverBuildDirectory` and `serverBuildFile` options ([#8332](https://github.com/remix-run/remix/pull/8332)) + +- Vite: Loosen strict route exports restriction, reinstating support for non-Remix route exports ([#8420](https://github.com/remix-run/remix/pull/8420)) + +- Updated dependencies: + - `@remix-run/server-runtime@2.5.0` + - `@remix-run/node@2.5.0` + +## 2.4.1 + +### Patch Changes + +- Vite: Error messages when `.server` files are referenced by client ([#8267](https://github.com/remix-run/remix/pull/8267)) + + - Previously, referencing a `.server` module from client code resulted in an error message like: + - `The requested module '/app/models/answer.server.ts' does not provide an export named 'isDateType'` + - This was confusing because `answer.server.ts` _does_ provide the `isDateType` export, but Remix was replacing `.server` modules with empty modules (`export {}`) for the client build + - Now, Remix explicitly fails at compile time when a `.server` module is referenced from client code and includes dedicated error messages depending on whether the import occurs in a route or a non-route module + - The error messages also include links to relevant documentation + +- Remove `unstable_viteServerBuildModuleId` in favor of manually referencing virtual module name `"virtual:remix/server-build"`. ([#8264](https://github.com/remix-run/remix/pull/8264)) + + **This is a breaking change for projects using the unstable Vite plugin with a custom server.** + + This change was made to avoid issues where `@remix-run/dev` could be inadvertently required in your server's production dependencies. + + Instead, you should manually write the virtual module name `"virtual:remix/server-build"` when calling `ssrLoadModule` in development. + + ```diff + -import { unstable_viteServerBuildModuleId } from "@remix-run/dev"; + + // ... + + app.all( + "*", + createRequestHandler({ + build: vite + - ? () => vite.ssrLoadModule(unstable_viteServerBuildModuleId) + + ? () => vite.ssrLoadModule("virtual:remix/server-build") + : await import("./build/server/index.js"), + }) + ); + ``` + +- Vite: Fix errors for non-existent `index.html` importer ([#8353](https://github.com/remix-run/remix/pull/8353)) + +- Add `vite:dev` and `vite:build` commands to the Remix CLI. ([#8211](https://github.com/remix-run/remix/pull/8211)) + + In order to handle upcoming Remix features where your plugin options can impact the number of Vite builds required, you should now run your Vite `dev` and `build` processes via the Remix CLI. + + ```diff + { + "scripts": { + - "dev": "vite dev", + - "build": "vite build && vite build --ssr" + + "dev": "remix vite:dev", + + "build": "remix vite:build" + } + } + ``` + +- Vite: Preserve names for exports from `.client` modules ([#8200](https://github.com/remix-run/remix/pull/8200)) + + Unlike `.server` modules, the main idea is not to prevent code from leaking into the server build + since the client build is already public. Rather, the goal is to isolate the SSR render from client-only code. + Routes need to import code from `.client` modules without compilation failing and then rely on runtime checks + or otherwise ensure that execution only happens within a client-only context (e.g. event handlers, `useEffect`). + + Replacing `.client` modules with empty modules would cause the build to fail as ESM named imports are statically analyzed. + So instead, we preserve the named export but replace each exported value with `undefined`. + That way, the import is valid at build time and standard runtime checks can be used to determine if the + code is running on the server or client. + +- Disable watch mode in Vite child compiler during build ([#8342](https://github.com/remix-run/remix/pull/8342)) + +- Vite: Show warning when source maps are enabled in production build ([#8222](https://github.com/remix-run/remix/pull/8222)) + +- Updated dependencies: + - `@remix-run/server-runtime@2.4.1` + - `@remix-run/node@2.4.1` + +## 2.4.0 + +### Minor Changes + +- Vite: exclude modules within `.server` directories from client build ([#8154](https://github.com/remix-run/remix/pull/8154)) + +- Add support for `clientLoader`/`clientAction`/`HydrateFallback` route exports ([RFC](https://github.com/remix-run/remix/discussions/7634)) ([#8173](https://github.com/remix-run/remix/pull/8173)) + + Remix now supports loaders/actions that run on the client (in addition to, or instead of the loader/action that runs on the server). While we still recommend server loaders/actions for the majority of your data needs in a Remix app - these provide some levers you can pull for more advanced use-cases such as: + + - Leveraging a data source local to the browser (i.e., `localStorage`) + - Managing a client-side cache of server data (like `IndexedDB`) + - Bypassing the Remix server in a BFF setup and hitting your API directly from the browser + - Migrating a React Router SPA to a Remix application + + By default, `clientLoader` will not run on hydration, and will only run on subsequent client side navigations. + + If you wish to run your client loader on hydration, you can set `clientLoader.hydrate=true` to force Remix to execute it on initial page load. Keep in mind that Remix will still SSR your route component so you should ensure that there is no new _required_ data being added by your `clientLoader`. + + If your `clientLoader` needs to run on hydration and adds data you require to render the route component, you can export a `HydrateFallback` component that will render during SSR, and then your route component will not render until the `clientLoader` has executed on hydration. + + `clientAction` is simpler than `clientLoader` because it has no hydration use-cases. `clientAction` will only run on client-side navigations. + + For more information, please refer to the [`clientLoader`](https://remix.run/route/client-loader) and [`clientAction`](https://remix.run/route/client-action) documentation. + +- Vite: Strict route exports ([#8171](https://github.com/remix-run/remix/pull/8171)) + + With Vite, Remix gets stricter about which exports are allowed from your route modules. + Previously, the Remix compiler would allow any export from routes. + While this was convenient, it was also a common source of bugs that were hard to track down because they only surfaced at runtime. + + For more, see + +- Add a new `future.v3_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. For more information, please see the React Router [`6.21.0` Release Notes](https://github.com/remix-run/react-router/blob/release-next/CHANGELOG.md#futurev7_relativesplatpath) and the [`useResolvedPath` docs](https://remix.run/hooks/use-resolved-path#splat-paths). ([#8216](https://github.com/remix-run/remix/pull/8216)) + +### Patch Changes + +- Upgrade Vite peer dependency range to v5 ([#8172](https://github.com/remix-run/remix/pull/8172)) + +- Support HMR for routes with `handle` export in Vite dev ([#8022](https://github.com/remix-run/remix/pull/8022)) + +- Fix flash of unstyled content for non-Express custom servers in Vite dev ([#8076](https://github.com/remix-run/remix/pull/8076)) + +- Bundle CSS imported in client entry file in Vite plugin ([#8143](https://github.com/remix-run/remix/pull/8143)) + +- Change Vite build output paths to fix a conflict between how Vite and the Remix compiler each manage the `public` directory. ([#8077](https://github.com/remix-run/remix/pull/8077)) + + **This is a breaking change for projects using the unstable Vite plugin.** + + The server is now compiled into `build/server` rather than `build`, and the client is now compiled into `build/client` rather than `public`. + + For more information on the changes and guidance on how to migrate your project, refer to the updated [Remix Vite documentation](https://remix.run/docs/en/main/future/vite). + +- Remove undocumented `legacyCssImports` option from Vite plugin due to issues with `?url` imports of CSS files not being processed correctly in Vite ([#8096](https://github.com/remix-run/remix/pull/8096)) + +- Vite: fix access to default `entry.{client,server}.tsx` within pnpm workspace on Windows ([#8057](https://github.com/remix-run/remix/pull/8057)) + +- Remove `unstable_createViteServer` and `unstable_loadViteServerBuild` which were only minimal wrappers around Vite's `createServer` and `ssrLoadModule` functions when using a custom server. ([#8120](https://github.com/remix-run/remix/pull/8120)) + + **This is a breaking change for projects using the unstable Vite plugin with a custom server.** + + Instead, we now provide `unstable_viteServerBuildModuleId` so that custom servers interact with Vite directly rather than via Remix APIs, for example: + + ```diff + -import { + - unstable_createViteServer, + - unstable_loadViteServerBuild, + -} from "@remix-run/dev"; + +import { unstable_viteServerBuildModuleId } from "@remix-run/dev"; + ``` + + Creating the Vite server in middleware mode: + + ```diff + const vite = + process.env.NODE_ENV === "production" + ? undefined + - : await unstable_createViteServer(); + + : await import("vite").then(({ createServer }) => + + createServer({ + + server: { + + middlewareMode: true, + + }, + + }) + + ); + ``` + + Loading the Vite server build in the request handler: + + ```diff + app.all( + "*", + createRequestHandler({ + build: vite + - ? () => unstable_loadViteServerBuild(vite) + + ? () => vite.ssrLoadModule(unstable_viteServerBuildModuleId) + : await import("./build/server/index.js"), + }) + ); + ``` + +- Pass request handler errors to `vite.ssrFixStacktrace` in Vite dev to ensure stack traces correctly map to the original source code ([#8066](https://github.com/remix-run/remix/pull/8066)) + +- Vite: Preserve names for exports from .client imports ([#8200](https://github.com/remix-run/remix/pull/8200)) + + Unlike `.server` modules, the main idea is not to prevent code from leaking into the server build + since the client build is already public. Rather, the goal is to isolate the SSR render from client-only code. + Routes need to import code from `.client` modules without compilation failing and then rely on runtime checks + to determine if the code is running on the server or client. + + Replacing `.client` modules with empty modules would cause the build to fail as ESM named imports are statically analyzed. + So instead, we preserve the named export but replace each exported value with an empty object. + That way, the import is valid at build time and the standard runtime checks can be used to determine if then + code is running on the server or client. + +- Add `@remix-run/node` to Vite's `optimizeDeps.include` array ([#8177](https://github.com/remix-run/remix/pull/8177)) + +- Improve Vite plugin performance ([#8121](https://github.com/remix-run/remix/pull/8121)) + + - Parallelize detection of route module exports + - Disable `server.preTransformRequests` in Vite child compiler since it's only used to process route modules + +- Remove automatic global Node polyfill installation from the built-in Vite dev server and instead allow explicit opt-in. ([#8119](https://github.com/remix-run/remix/pull/8119)) + + **This is a breaking change for projects using the unstable Vite plugin without a custom server.** + + If you're not using a custom server, you should call `installGlobals` in your Vite config instead. + + ```diff + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + +import { installGlobals } from "@remix-run/node"; + import { defineConfig } from "vite"; + + +installGlobals(); + + export default defineConfig({ + plugins: [remix()], + }); + ``` + +- Vite: Errors at build-time when client imports .server default export ([#8184](https://github.com/remix-run/remix/pull/8184)) + + Remix already stripped .server file code before ensuring that server code never makes it into the client. + That results in errors when client code tries to import server code, which is exactly what we want! + But those errors were happening at runtime for default imports. + A better experience is to have those errors happen at build-time so that you guarantee that your users won't hit them. + +- Fix `request instanceof Request` checks when using Vite dev server ([#8062](https://github.com/remix-run/remix/pull/8062)) + +- Updated dependencies: + - `@remix-run/server-runtime@2.4.0` + - `@remix-run/node@2.4.0` + +## 2.3.1 + +### Patch Changes + +- Support `nonce` prop on `LiveReload` component in Vite dev ([#8014](https://github.com/remix-run/remix/pull/8014)) +- Ensure code-split JS files in the server build's assets directory aren't cleaned up after Vite build ([#8042](https://github.com/remix-run/remix/pull/8042)) +- Fix redundant copying of assets from `public` directory in Vite build ([#8039](https://github.com/remix-run/remix/pull/8039)) + - This ensures that static assets aren't duplicated in the server build directory + - This also fixes an issue where the build would break if `assetsBuildDirectory` was deeply nested within the `public` directory +- Updated dependencies: + - `@remix-run/node@2.3.1` + - `@remix-run/server-runtime@2.3.1` + +## 2.3.0 + +### Patch Changes + +- Support rendering of `LiveReload` component after `Scripts` in Vite dev ([#7919](https://github.com/remix-run/remix/pull/7919)) +- fix(vite): fix "react-refresh/babel" resolution for custom server with pnpm ([#7904](https://github.com/remix-run/remix/pull/7904)) +- Support JSX usage in `.jsx` files without manual `React` import in Vite ([#7888](https://github.com/remix-run/remix/pull/7888)) +- Support optional rendering of `LiveReload` component in Vite dev ([#7919](https://github.com/remix-run/remix/pull/7919)) +- Fix Vite production builds when plugins that have different local state between `development` and `production` modes are present, e.g. `@mdx-js/rollup`. ([#7911](https://github.com/remix-run/remix/pull/7911)) +- Cache resolution of Remix Vite plugin options ([#7908](https://github.com/remix-run/remix/pull/7908)) +- Support Vite 5 ([#7846](https://github.com/remix-run/remix/pull/7846)) +- Allow `process.env.NODE_ENV` values other than `"development"` in Vite dev ([#7980](https://github.com/remix-run/remix/pull/7980)) +- Attach CSS from shared chunks to routes in Vite build ([#7952](https://github.com/remix-run/remix/pull/7952)) +- fix(vite): Let Vite handle serving files outside of project root via `/@fs` ([#7913](https://github.com/remix-run/remix/pull/7913)) + - This fixes errors when using default client entry or server entry in a pnpm project where those files may be outside of the project root, but within the workspace root. + - By default, Vite prevents access to files outside the workspace root (when using workspaces) or outside of the project root (when not using workspaces) unless user explicitly opts into it via Vite's `server.fs.allow`. +- Improve performance of LiveReload proxy in Vite dev ([#7883](https://github.com/remix-run/remix/pull/7883)) +- fix(vite): deduplicate `@remix-run/react` ([#7926](https://github.com/remix-run/remix/pull/7926)) + - Pre-bundle Remix dependencies to avoid Remix router duplicates. + - Our remix-react-proxy plugin does not process default client and + - server entry files since those come from within `node_modules`. + - That means that before Vite pre-bundles dependencies (e.g. first time dev server is run) mismatching Remix routers cause `Error: You must render this element inside a element`. +- Fix React Fast Refresh error on load when using `defer` in Vite dev server ([#7842](https://github.com/remix-run/remix/pull/7842)) +- Handle multiple "Set-Cookie" headers in Vite dev server ([#7843](https://github.com/remix-run/remix/pull/7843)) +- Fix flash of unstyled content on initial page load in Vite dev when using a custom Express server ([#7937](https://github.com/remix-run/remix/pull/7937)) +- Emit assets that were only referenced in the server build into the client assets directory in Vite build ([#7892](https://github.com/remix-run/remix/pull/7892), cherry-picked in [`8cd31d65`](https://github.com/remix-run/remix/commit/8cd31d6543ef4c765220fc64dca9bcc9c61ee9eb)) +- Populate `process.env` from `.env` files on the server in Vite dev ([#7958](https://github.com/remix-run/remix/pull/7958)) +- Fix `FutureConfig` type ([#7895](https://github.com/remix-run/remix/pull/7895)) +- Updated dependencies: + - `@remix-run/server-runtime@2.3.0` + - `@remix-run/node@2.3.0` + +## 2.2.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. + - See "Future > Vite" in the Remix Docs for details +- Add a new `future.v3_fetcherPersist` flag to change the persistence behavior of fetchers. Instead of being immediately cleaned up when unmounted in the UI, fetchers will persist until they return to an `idle` state ([RFC](https://github.com/remix-run/remix/discussions/7698)) ([#7704](https://github.com/remix-run/remix/pull/7704)) + - For more details, please refer to the [React Router 6.18.0](https://github.com/remix-run/react-router/releases/tag/react-router%406.18.0) release notes + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.2.0` + - `@remix-run/node@2.2.0` + +## 2.1.0 + +### Patch Changes + +- Sourcemap takes into account special chars in output file ([#7574](https://github.com/remix-run/remix/pull/7574)) +- Updated dependencies: + - `@remix-run/server-runtime@2.1.0` + +## 2.0.1 + +### Patch Changes + +- Fix types for MDX files when using pnpm ([#7491](https://github.com/remix-run/remix/pull/7491)) +- Update `getDependenciesToBundle` to handle ESM packages without main exports ([#7272](https://github.com/remix-run/remix/pull/7272)) + - Note that these packages must expose `package.json` in their `exports` field so that their path can be resolved +- Fix server builds where `serverBuildPath` extension is `.cjs` ([#7180](https://github.com/remix-run/remix/pull/7180)) +- Updated dependencies: + - `@remix-run/server-runtime@2.0.1` + +## 2.0.0 + +### Major Changes + +- The `create-remix` CLI has been rewritten to feature a cleaner interface, Git repo initialization and optional `remix.init` script execution. The interactive template prompt and official Remix stack/template shorthands have also been removed so that community/third-party templates are now on a more equal footing. ([#6887](https://github.com/remix-run/remix/pull/6887)) + - The code for `create-remix` has been moved out of the Remix CLI since it's not intended for use within an existing Remix application + - This means that the `remix create` command is no longer available. +- Enable built-in PostCSS and Tailwind support by default. ([#6909](https://github.com/remix-run/remix/pull/6909)) + - These tools are now automatically used within the Remix compiler if PostCSS and/or Tailwind configuration files are present in your project. + - If you have a custom PostCSS and/or Tailwind setup outside of Remix, you can disable these features in your `remix.config.js` via the `postcss:false` and/or `tailwind:false` flags +- Drop React 17 support ([#7121](https://github.com/remix-run/remix/pull/7121)) +- Require Node >=18.0.0 ([#6939](https://github.com/remix-run/remix/pull/6939)) +- Compile server build to Node 18 ([#7292](https://github.com/remix-run/remix/pull/7292)) + - This allows features like top-level `await` to be used within a Remix app +- Remove default Node.js polyfills - you must now opt-into polyfills via the [`serverNodeBuiltinsPolyfill`](https://remix.run/docs/en/2.0.0/start/v2#servernodebuiltinspolyfill) and [`browserNodeBuiltinsPolyfill`](https://remix.run/docs/en/2.0.0/start/v2#browsernodebuiltinspolyfill) configs ([#7269](https://github.com/remix-run/remix/pull/7269)) +- Remove `v2_errorBoundary` flag and `CatchBoundary` implementation ([#6906](https://github.com/remix-run/remix/pull/6906)) +- Remove `v2_normalizeFormMethod` future flag - all `formMethod` values will be normalized in v2 ([#6875](https://github.com/remix-run/remix/pull/6875)) +- Remove `v2_routeConvention` flag - the flat route file convention is now standard ([#6969](https://github.com/remix-run/remix/pull/6969)) +- Remove `v2_headers` flag - it is now the default behavior to use the deepest `headers` function in the route tree ([#6979](https://github.com/remix-run/remix/pull/6979)) +- The route `meta` API now defaults to the new "V2 Meta" API ([#6958](https://github.com/remix-run/remix/pull/6958)) + - Please refer to the ([docs](https://remix.run/docs/en/2.0.0/route/meta) and [Preparing for V2](https://remix.run/docs/en/2.0.0/start/v2#route-meta) guide for more information. +- Default to `serverModuleFormat: "esm"` and update `remix-serve` to use dynamic import to support ESM and CJS build outputs ([#6949](https://github.com/remix-run/remix/pull/6949)) +- Remove `serverBuildTarget` config option ([#6896](https://github.com/remix-run/remix/pull/6896)) +- Remove deprecated `REMIX_DEV_HTTP_ORIGIN` env var - use `REMIX_DEV_ORIGIN` instead ([#6963](https://github.com/remix-run/remix/pull/6963)) +- Remove `devServerBroadcastDelay` config option ([#7063](https://github.com/remix-run/remix/pull/7063)) +- Remove deprecated `devServerPort` option - use `--port` / `dev.port` instead ([#7078](https://github.com/remix-run/remix/pull/7078)) +- Remove deprecated `REMIX_DEV_SERVER_WS_PORT` env var - use `remix dev`'s '`--port` / `port` option instead ([#6965](https://github.com/remix-run/remix/pull/6965)) +- Stop passing `isTypeScript` to `remix.init` script ([#7099](https://github.com/remix-run/remix/pull/7099)) +- Remove `replace-remix-magic-imports` codemod ([#6899](https://github.com/remix-run/remix/pull/6899)) +- Remove deprecated `--no-restart`/`restart` cli args/flags - use `--manual`/`manual` instead ([#6962](https://github.com/remix-run/remix/pull/6962)) +- Remove deprecated `--scheme`/`scheme` and `--host`/`host` cli args/flags - use `REMIX_DEV_ORIGIN` instead ([#6962](https://github.com/remix-run/remix/pull/6962)) +- Promote the `future.v2_dev` flag in `remix.config.js` to a root level `dev` config ([#7002](https://github.com/remix-run/remix/pull/7002)) +- Remove `browserBuildDirectory` config option ([#6900](https://github.com/remix-run/remix/pull/6900)) +- Remove `serverBuildDirectory` config option (\[#6897]\( Remove `codemod` command ([#6918](https://github.com/remix-run/remix/pull/6918)) + 6897\)) +- Removed support for "magic exports" from the `remix` package. This package can be removed from your `package.json` and you should update all imports to use the source `@remix-run/*` packages: ([#6895](https://github.com/remix-run/remix/pull/6895)) + + ```diff + - import type { ActionArgs } from "remix"; + - import { json, useLoaderData } from "remix"; + + import type { ActionArgs } from "@remix-run/node"; + + import { json } from "@remix-run/node"; + + import { useLoaderData } from "@remix-run/react"; + ``` + +### Minor Changes + +- Warn users about obsolete future flags in `remix.config.js` ([#7048](https://github.com/remix-run/remix/pull/7048)) +- Detect built mode via `build.mode` ([#6964](https://github.com/remix-run/remix/pull/6964)) + - Prevents mode mismatch between built Remix server entry and user-land server + - Additionally, all runtimes (including non-Node runtimes) can use `build.mode` to determine if HMR should be performed +- Support `bun` package manager ([#7074](https://github.com/remix-run/remix/pull/7074)) +- The `serverNodeBuiltinsPolyfill` option (along with the newly added `browserNodeBuiltinsPolyfill`) now supports defining global polyfills in addition to module polyfills ([#7269](https://github.com/remix-run/remix/pull/7269)) + + - For example, to polyfill Node's `Buffer` global: + + ```js + module.exports = { + serverNodeBuiltinsPolyfill: { + globals: { + Buffer: true, + }, + // You'll probably need to polyfill the "buffer" module + // too since the global polyfill imports this: + modules: { + buffer: true, + }, + }, + }; + ``` + +### Patch Changes + +- Fix importing of PNGs, SVGs, and other assets from packages in `node_modules` ([#6813](https://github.com/remix-run/remix/pull/6813), [#7182](https://github.com/remix-run/remix/pull/7182)) + +- Decouple the `@remix-run/dev` package from the contents of the `@remix-run/css-bundle` package. ([#6982](https://github.com/remix-run/remix/pull/6982)) + + - The contents of the `@remix-run/css-bundle` package are now entirely managed by the Remix compiler + - Even though it's still recommended that your Remix dependencies all share the same version, this change ensures that there are no runtime errors when upgrading `@remix-run/dev` without upgrading `@remix-run/css-bundle` + +- Allow non-development modes for `remix watch` ([#7117](https://github.com/remix-run/remix/pull/7117)) + +- Stop `remix dev` when `esbuild` is not running ([#7158](https://github.com/remix-run/remix/pull/7158)) + +- Do not interpret JSX in `.ts` files ([#7306](https://github.com/remix-run/remix/pull/7306)) + + - While JSX is supported in `.js` files for compatibility with existing apps and libraries, + `.ts` files should not contain JSX. By not interpreting `.ts` files as JSX, `.ts` files + can contain single-argument type generics without needing a comma to disambiguate from JSX: + + ```ts + // this works in .ts files + const id = (x: T) => x; + // ^ single-argument type generic + ``` + + ```tsx + // this doesn't work in .tsx files + const id = (x: T) => x; + // ^ is this a JSX element? or a single-argument type generic? + ``` + + ```tsx + // this works in .tsx files + const id = (x: T) => x; + // ^ comma: this is a generic, not a JSX element + const component =

    hello

    ; + // ^ no comma: this is a JSX element + ``` + +- Enhance obsolete flag warning for `future.v2_dev` if it was an object, and prompt users to lift it to the root `dev` config ([#7427](https://github.com/remix-run/remix/pull/7427)) + +- Allow decorators in app code ([#7176](https://github.com/remix-run/remix/pull/7176)) + +- Allow JSX in `.js` files during HMR ([#7112](https://github.com/remix-run/remix/pull/7112)) + +- Kill app server when remix dev terminates ([#7280](https://github.com/remix-run/remix/pull/7280)) + +- Support dependencies that import polyfill packages for Node built-ins via a trailing slash (e.g. importing the `buffer` package with `var Buffer = require('buffer/').Buffer` as recommended in their README) ([#7198](https://github.com/remix-run/remix/pull/7198)) + + - These imports were previously marked as external + - This meant that they were left as dynamic imports in the client bundle and would throw a runtime error in the browser (e.g. `Dynamic require of "buffer/" is not supported`) + +- Surface errors when PostCSS config is invalid ([#7391](https://github.com/remix-run/remix/pull/7391)) + +- Restart dev server when Remix config changes ([#7269](https://github.com/remix-run/remix/pull/7269)) + +- Remove outdated ESM import warnings ([#6916](https://github.com/remix-run/remix/pull/6916)) + + - Most of the time these warnings were false positives. + - Instead, we now rely on built-in Node warnings for ESM imports. + +- Do not trigger rebuilds when `.DS_Store` changes ([#7172](https://github.com/remix-run/remix/pull/7172)) + +- Remove warnings for stabilized flags: ([#6905](https://github.com/remix-run/remix/pull/6905)) + + - `unstable_cssSideEffectImports` + - `unstable_cssModules` + - `unstable_vanillaExtract` + +- Allow any mode (`NODE_ENV`) ([#7113](https://github.com/remix-run/remix/pull/7113)) + +- Replace the deprecated [`xdm`](https://github.com/wooorm/xdm) package with [`@mdx-js/mdx`](https://github.com/mdx-js/mdx) ([#4054](https://github.com/remix-run/remix/pull/4054)) + +- Write a `version.txt` sentinel file _after_ server build is completely written ([#7299](https://github.com/remix-run/remix/pull/7299)) + +- Updated dependencies: + - `@remix-run/server-runtime@2.0.0` + +## 1.19.3 + +### Patch Changes + +- Show deprecation warning when using `devServerBroadcastDelay` and `devServerPort` config options ([#7064](https://github.com/remix-run/remix/pull/7064)) +- Updated dependencies: + - `@remix-run/server-runtime@1.19.3` + +## 1.19.2 + +### Patch Changes + +- Update `proxy-agent` to resolve npm audit security vulnerability ([#7027](https://github.com/remix-run/remix/pull/7027)) +- Updated dependencies: + - `@remix-run/server-runtime@1.19.2` + +## 1.19.1 + +### Patch Changes + +- Add a heartbeat ping to prevent the WebSocket connection from being closed due to inactivity when using a proxy like Cloudflare ([#6904](https://github.com/remix-run/remix/pull/6904), [#6927](https://github.com/remix-run/remix/pull/6927)) +- Treeshake out HMR code from production builds ([#6894](https://github.com/remix-run/remix/pull/6894)) +- Updated dependencies: + - `@remix-run/server-runtime@1.19.1` + +## 1.19.0 + +### Minor Changes + +- improved networking options for `v2_dev` ([#6724](https://github.com/remix-run/remix/pull/6724)) + + deprecate the `--scheme` and `--host` options and replace them with the `REMIX_DEV_ORIGIN` environment variable + +- Output esbuild metafiles for bundle analysis ([#6772](https://github.com/remix-run/remix/pull/6772)) + + Written to server build directory (`build/` by default): + + - `metafile.css.json` + - `metafile.js.json` (browser JS) + - `metafile.server.json` (server JS) + + Metafiles can be uploaded to for analysis. + +- Add `serverNodeBuiltinsPolyfill` config option. In `remix.config.js` you can now disable polyfills of Node.js built-in modules for non-Node.js server platforms, or opt into a subset of polyfills. ([#6814](https://github.com/remix-run/remix/pull/6814), [#6859](https://github.com/remix-run/remix/pull/6859), [#6877](https://github.com/remix-run/remix/pull/6877)) + + ```js + // Disable all polyfills + exports.serverNodeBuiltinsPolyfill = { modules: {} }; + + // Enable specific polyfills + exports.serverNodeBuiltinsPolyfill = { + modules: { + crypto: true, // Provide a JSPM polyfill + fs: "empty", // Provide an empty polyfill + }, + }; + ``` + +### Patch Changes + +- ignore missing react-dom/client for react 17 ([#6725](https://github.com/remix-run/remix/pull/6725)) + +- Warn if not using `v2_dev` ([#6818](https://github.com/remix-run/remix/pull/6818)) + + Also, rename `--no-restart` to `--manual` to match intention and documentation. + `--no-restart` remains an alias for `--manual` in v1 for backwards compatibility. + +- ignore errors when killing already dead processes ([#6773](https://github.com/remix-run/remix/pull/6773)) + +- Always rewrite css-derived assets during builds ([#6837](https://github.com/remix-run/remix/pull/6837)) + +- fix sourcemaps for `v2_dev` ([#6762](https://github.com/remix-run/remix/pull/6762)) + +- Do not clear screen when dev server starts ([#6719](https://github.com/remix-run/remix/pull/6719)) + + On some terminal emulators, "clearing" only scrolls the next line to the + top. on others, it erases the scrollback. + + Instead, let users call `clear` themselves (`clear && remix dev`) if + they want to clear. + +- Updated dependencies: + - `@remix-run/server-runtime@1.19.0` + +## 1.18.1 + +### Patch Changes + +- Ignore missing `react-dom/client` for React 17 ([#6725](https://github.com/remix-run/remix/pull/6725)) +- Updated dependencies: + - `@remix-run/server-runtime@1.18.1` + +## 1.18.0 + +### Minor Changes + +- stabilize v2 dev server ([#6615](https://github.com/remix-run/remix/pull/6615)) +- improved logging for `remix build` and `remix dev` ([#6596](https://github.com/remix-run/remix/pull/6596)) + +### Patch Changes + +- fix docs links for msw and mkcert ([#6672](https://github.com/remix-run/remix/pull/6672)) +- fix `remix dev -c`: kill all descendant processes of specified command when restarting ([#6663](https://github.com/remix-run/remix/pull/6663)) +- Add caching to regular stylesheet compilation ([#6638](https://github.com/remix-run/remix/pull/6638)) +- Rename `Architect (AWS Lambda)` -> `Architect` in the `create-remix` CLI to avoid confusion for other methods of deploying to AWS (i.e., SST) ([#6484](https://github.com/remix-run/remix/pull/6484)) +- Improve CSS bundle build performance by skipping unused Node polyfills ([#6639](https://github.com/remix-run/remix/pull/6639)) +- Improve performance of CSS bundle build by skipping compilation of Remix/React packages that are known not to contain CSS imports ([#6654](https://github.com/remix-run/remix/pull/6654)) +- Cache CSS side-effect imports transform when using HMR ([#6622](https://github.com/remix-run/remix/pull/6622)) +- Fix bug with pathless layout routes beneath nested path segments ([#6649](https://github.com/remix-run/remix/pull/6649)) +- Add caching to PostCSS for CSS Modules ([#6604](https://github.com/remix-run/remix/pull/6604)) +- Add caching to PostCSS for side-effect imports ([#6554](https://github.com/remix-run/remix/pull/6554)) +- cache getRouteModuleExports calls to significantly speed up build and HMR rebuild times ([#6629](https://github.com/remix-run/remix/pull/6629)) +- group rebuild logs with surrounding whitespace ([#6607](https://github.com/remix-run/remix/pull/6607)) +- instructions for integrating with msw ([#6669](https://github.com/remix-run/remix/pull/6669)) +- Update minimum version of `esbuild-plugins-node-modules-polyfill` to 1.0.16 to ensure that the plugin is cached ([#6652](https://github.com/remix-run/remix/pull/6652)) +- Updated dependencies: + - `@remix-run/server-runtime@1.18.0` + +## 1.17.1 + +### Patch Changes + +- Replace `esbuild-plugin-polyfill-node` with `esbuild-plugins-node-modules-polyfill` ([#6562](https://github.com/remix-run/remix/pull/6562)) +- Lazily generate CSS bundle when import of `@remix-run/css-bundle` is detected ([#6535](https://github.com/remix-run/remix/pull/6535)) +- Updated dependencies: + - `@remix-run/server-runtime@1.17.1` + +## 1.17.0 + +### Minor Changes + +- built-in tls support ([#6483](https://github.com/remix-run/remix/pull/6483)) + + New options: + + - `--tls-key` / `tlsKey`: TLS key + - `--tls-cert` / `tlsCert`: TLS Certificate + + If both TLS options are set, `scheme` defaults to `https` + + ## Example + + Install [mkcert](https://github.com/FiloSottile/mkcert) and create a local CA: + + ```sh + brew install mkcert + mkcert -install + ``` + + Then make sure you inform `node` about your CA certs: + + ```sh + export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" + ``` + + 👆 You'll probably want to put that env var in your scripts or `.bashrc`/`.zshrc` + + Now create `key.pem` and `cert.pem`: + + ```sh + mkcert -key-file key.pem -cert-file cert.pem localhost + ``` + + See `mkcert` docs for more details. + + Finally, pass in the paths to the key and cert via flags: + + ```sh + remix dev --tls-key=key.pem --tls-cert=cert.pem + ``` + + or via config: + + ```js + module.exports = { + future: { + unstable_dev: { + tlsKey: "key.pem", + tlsCert: "cert.pem", + }, + }, + }; + ``` + + That's all that's needed to set up the Remix Dev Server with TLS. + + 🚨 Make sure to update your app server for TLS as well. + + For example, with `express`: + + ```ts + import fs from "node:fs"; + import https from "node:https"; + + import express from "express"; + + const app = express(); + + // ...code setting up your express app... + + const appServer = https.createServer( + { + key: fs.readFileSync("key.pem"), + cert: fs.readFileSync("cert.pem"), + }, + app + ); + + appServer.listen(3000, () => { + console.log("Ready on https://localhost:3000"); + }); + ``` + + ## Known limitations + + `remix-serve` does not yet support TLS. + That means this only works for custom app server using the `-c` flag for now. + +- Reuse dev server port for WebSocket (Live Reload,HMR,HDR) ([#6476](https://github.com/remix-run/remix/pull/6476)) + + As a result the `webSocketPort`/`--websocket-port` option has been obsoleted. + Additionally, scheme/host/port options for the dev server have been renamed. + + Available options are: + + | Option | flag | config | default | + | ---------- | ------------------ | ---------------- | --------------------------------- | + | Command | `-c` / `--command` | `command` | `remix-serve ` | + | Scheme | `--scheme` | `scheme` | `http` | + | Host | `--host` | `host` | `localhost` | + | Port | `--port` | `port` | Dynamically chosen open port | + | No restart | `--no-restart` | `restart: false` | `restart: true` | + + Note that scheme/host/port options are for the _dev server_, not your app server. + You probably don't need to use scheme/host/port option if you aren't configuring networking (e.g. for Docker or SSL). + +### Patch Changes + +- Add caching to PostCSS for regular stylesheets ([#6505](https://github.com/remix-run/remix/pull/6505)) + +- Fix warnings when importing CSS files with `future.unstable_dev` enabled ([#6506](https://github.com/remix-run/remix/pull/6506)) + +- Fix Tailwind performance issue when `postcss.config.js` contains `plugins: { tailwindcss: {} }` and `remix.config.js` contains both `tailwind: true` and `postcss: true`. ([#6468](https://github.com/remix-run/remix/pull/6468)) + + Note that this was _not_ an issue when the plugin function had been explicitly called, i.e. `plugins: [tailwindcss()]`. Remix avoids adding the Tailwind plugin to PostCSS if it's already present but we were failing to detect when the plugin function hadn't been called — either because the plugin function itself had been passed, i.e. `plugins: [require('tailwindcss')]`, or the plugin config object syntax had been used, i.e. `plugins: { tailwindcss: {} }`. + +- Faster server export removal for routes when `unstable_dev` is enabled. ([#6455](https://github.com/remix-run/remix/pull/6455)) + + Also, only render modulepreloads on SSR. + Do not render modulepreloads when hydrated. + +- Add `HeadersArgs` type to be consistent with loaders/actions/meta and allows for using a `function` declaration in addition to an arrow function expression ([#6247](https://github.com/remix-run/remix/pull/6247)) + + ```tsx + import type { HeadersArgs } from "@remix-run/node"; // or cloudflare/deno + + export function headers({ loaderHeaders }: HeadersArgs) { + return { + "x-my-custom-thing": loaderHeaders.get("x-my-custom-thing") || "fallback", + }; + } + ``` + +- better error message when `remix-serve` is not found ([#6477](https://github.com/remix-run/remix/pull/6477)) + +- restore color for app server output ([#6485](https://github.com/remix-run/remix/pull/6485)) + +- Fix route ranking bug with pathless layout route next to a sibling index route ([#4421](https://github.com/remix-run/remix/pull/4421)) + + - Under the hood this is done by removing the trailing slash from all generated `path` values since the number of slash-delimited segments counts towards route ranking so the trailing slash incorrectly increases the score for routes + +- Support sibling pathless layout routes by removing pathless layout routes from the unique route path checks in conventional route generation since they inherently trigger duplicate paths ([#4421](https://github.com/remix-run/remix/pull/4421)) + +- fix dev server crashes caused by ungraceful hdr error handling ([#6467](https://github.com/remix-run/remix/pull/6467)) + +- Updated dependencies: + - `@remix-run/server-runtime@1.17.0` + +## 1.16.1 + +### Patch Changes + +- Cross-module `loader` change detection for HDR ([#6299](https://github.com/remix-run/remix/pull/6299)) +- Normalize path for dev server `PATH` envvar so that it works cross-platform (e.g. Windows) ([#6310](https://github.com/remix-run/remix/pull/6310)) +- Fix CSS imports in JS files that use JSX ([#6309](https://github.com/remix-run/remix/pull/6309)) +- Kill app server when dev server exits ([#6395](https://github.com/remix-run/remix/pull/6395)) +- Wait until app server is killed before starting a new app server ([#6289](https://github.com/remix-run/remix/pull/6289)) +- Ensure CSS bundle changes result in a new manifest hash ([#6374](https://github.com/remix-run/remix/pull/6374)) +- Normalize file paths before testing if a changed file is a route entry ([#6293](https://github.com/remix-run/remix/pull/6293)) +- Fix race where app server responds with updated manifest version _before_ dev server is listening for it ([#6294](https://github.com/remix-run/remix/pull/6294)) + - dev server now listens for updated versions _before_ writing the server changes, guaranteeing that it is listening before the app server gets a chance to send its 'ready' message +- Only process `.css.ts`/`.css.js` files with Vanilla Extract if `@vanilla-extract/css` is installed ([#6345](https://github.com/remix-run/remix/pull/6345)) +- Stop modifying a user's `tsconfig.json` when running using `getConfig` (`remix dev`, `remix routes`, `remix build`, etc) ([#6156](https://github.com/remix-run/remix/pull/6156)) +- Cancel previous build when rebuild is kicked off to prevent rebuilds from hanging ([#6295](https://github.com/remix-run/remix/pull/6295)) +- Update minimum version of Babel dependencies to avoid errors parsing decorators ([#6390](https://github.com/remix-run/remix/pull/6390)) +- Support asset imports when detecting loader changes for HDR ([#6396](https://github.com/remix-run/remix/pull/6396)) +- Updated dependencies: + - `@remix-run/server-runtime@1.16.1` + +## 1.16.0 + +### Minor Changes + +- Enable support for [CSS Modules](https://github.com/css-modules/css-modules), [Vanilla Extract](http://vanilla-extract.style) and CSS side-effect imports ([#6046](https://github.com/remix-run/remix/pull/6046)) + + These CSS bundling features were previously only available via `future.unstable_cssModules`, `future.unstable_vanillaExtract` and `future.unstable_cssSideEffectImports` options in `remix.config.js`, but they have now been stabilized. + + In order to use these features, check out our guide to [CSS bundling](https://remix.run/docs/en/1.16.0/guides/styling#css-bundling) in your project. + +- Stabilize built-in PostCSS support via the new `postcss` option in `remix.config.js`. As a result, the `future.unstable_postcss` option has also been deprecated. ([#5960](https://github.com/remix-run/remix/pull/5960)) + + The `postcss` option is `false` by default, but when set to `true` will enable processing of all CSS files using PostCSS if `postcss.config.js` is present. + + If you followed the original PostCSS setup guide for Remix, you may have a folder structure that looks like this, separating your source files from its processed output: + + . + ├── app + │ └── styles (processed files) + │ ├── app.css + │ └── routes + │ └── index.css + └── styles (source files) + ├── app.css + └── routes + └── index.css + + After you've enabled the new `postcss` option, you can delete the processed files from `app/styles` folder and move your source files from `styles` to `app/styles`: + + . + ├── app + │ └── styles (source files) + │ ├── app.css + │ └── routes + │ └── index.css + + You should then remove `app/styles` from your `.gitignore` file since it now contains source files rather than processed output. + + You can then update your `package.json` scripts to remove any usage of `postcss` since Remix handles this automatically. For example, if you had followed the original setup guide: + + ```diff + { + "scripts": { + - "dev:css": "postcss styles --base styles --dir app/styles -w", + - "build:css": "postcss styles --base styles --dir app/styles --env production", + - "dev": "concurrently \"npm run dev:css\" \"remix dev\"" + + "dev": "remix dev" + } + } + ``` + +- Stabilize built-in Tailwind support via the new `tailwind` option in `remix.config.js`. As a result, the `future.unstable_tailwind` option has also been deprecated. ([#5960](https://github.com/remix-run/remix/pull/5960)) + + The `tailwind` option is `false` by default, but when set to `true` will enable built-in support for Tailwind functions and directives in your CSS files if `tailwindcss` is installed. + + If you followed the original Tailwind setup guide for Remix and want to make use of this feature, you should first delete the generated `app/tailwind.css`. + + Then, if you have a `styles/tailwind.css` file, you should move it to `app/tailwind.css`. + + ```sh + rm app/tailwind.css + mv styles/tailwind.css app/tailwind.css + ``` + + Otherwise, if you don't already have an `app/tailwind.css` file, you should create one with the following contents: + + ```css + @tailwind base; + @tailwind components; + @tailwind utilities; + ``` + + You should then remove `/app/tailwind.css` from your `.gitignore` file since it now contains source code rather than processed output. + + You can then update your `package.json` scripts to remove any usage of `tailwindcss` since Remix handles this automatically. For example, if you had followed the original setup guide: + + ```diff + { + // ... + "scripts": { + - "build": "run-s \"build:*\"", + + "build": "remix build", + - "build:css": "npm run generate:css -- --minify", + - "build:remix": "remix build", + - "dev": "run-p \"dev:*\"", + + "dev": "remix dev", + - "dev:css": "npm run generate:css -- --watch", + - "dev:remix": "remix dev", + - "generate:css": "npx tailwindcss -o ./app/tailwind.css", + "start": "remix-serve build" + } + // ... + } + ``` + +- The Remix dev server spins up your app server as a managed subprocess. ([#6133](https://github.com/remix-run/remix/pull/6133)) + This keeps your development environment as close to production as possible. + It also means that the Remix dev server is compatible with _any_ app server. + + By default, the dev server will use the Remix App Server, but you opt to use your own app server by specifying the command to run it via the `-c`/`--command` flag: + + ```sh + remix dev # uses `remix-serve ` as the app server + remix dev -c "node ./server.js" # uses your custom app server at `./server.js` + ``` + + The dev server will: + + - force `NODE_ENV=development` and warn you if it was previously set to something else + - rebuild your app whenever your Remix app code changes + - restart your app server whenever rebuilds succeed + - handle live reload and HMR + Hot Data Revalidation + + ### App server coordination + + In order to manage your app server, the dev server needs to be told what server build is currently being used by your app server. + This works by having the app server send a "I'm ready!" message with the Remix server build hash as the payload. + + This is handled automatically in Remix App Server and is set up for you via calls to `broadcastDevReady` or `logDevReady` in the official Remix templates. + + If you are not using Remix App Server and your server doesn't call `broadcastDevReady`, you'll need to call it in your app server _after_ it is up and running. + For example, in an Express server: + + ```js + // server.js + // + import { broadcastDevReady } from "@remix-run/node"; + + // Path to Remix's server build directory ('build/' by default) + const BUILD_DIR = path.join(process.cwd(), "build"); + + // + + app.listen(3000, () => { + const build = require(BUILD_DIR); + console.log("Ready: http://localhost:" + port); + + // in development, call `broadcastDevReady` _after_ your server is up and running + if (process.env.NODE_ENV === "development") { + broadcastDevReady(build); + } + }); + ``` + + ### Options + + Options priority order is: 1. flags, 2. config, 3. defaults. + + | Option | flag | config | default | + | -------------- | ------------------ | ---------------- | --------------------------------- | + | Command | `-c` / `--command` | `command` | `remix-serve ` | + | HTTP(S) scheme | `--http-scheme` | `httpScheme` | `http` | + | HTTP(S) host | `--http-host` | `httpHost` | `localhost` | + | HTTP(S) port | `--http-port` | `httpPort` | Dynamically chosen open port | + | Websocket port | `--websocket-port` | `websocketPort` | Dynamically chosen open port | + | No restart | `--no-restart` | `restart: false` | `restart: true` | + + 🚨 The `--http-*` flags are only used for internal dev server <-> app server communication. + Your app will run on your app server's normal URL. + + To set `unstable_dev` configuration, replace `unstable_dev: true` with `unstable_dev: { }`. + For example, to set the HTTP(S) port statically: + + ```js + // remix.config.js + module.exports = { + future: { + unstable_dev: { + httpPort: 8001, + }, + }, + }; + ``` + + #### SSL and custom hosts + + You should only need to use the `--http-*` flags and `--websocket-port` flag if you need fine-grain control of what scheme/host/port for the dev server. + If you are setting up SSL or Docker networking, these are the flags you'll want to use. + + 🚨 Remix **will not** set up SSL and custom host for you. + The `--http-scheme` and `--http-host` flag are for you to tell Remix how you've set things up. + It is your task to set up SSL certificates and host files if you want those features. + + #### `--no-restart` and `require` cache purging + + If you want to manage server changes yourself, you can use the `--no-restart` flag to tell the dev server to refrain from restarting your app server when builds succeed: + + ```sh + remix dev -c "node ./server.js" --no-restart + ``` + + For example, you could purge the `require` cache of your app server to keep it running while picking up server changes. + If you do so, you should watch the server build path (`build/` by default) for changes and only purge the `require` cache when changes are detected. + + 🚨 If you use `--no-restart`, it is your responsibility to call `broadcastDevReady` when your app server has picked up server changes. + For example, with `chokidar`: + + ```js + // server.dev.js + const BUILD_PATH = path.resolve(__dirname, "build"); + + const watcher = chokidar.watch(BUILD_PATH); + + watcher.on("change", () => { + // 1. purge require cache + purgeRequireCache(); + // 2. load updated server build + const build = require(BUILD_PATH); + // 3. tell dev server that this app server is now ready + broadcastDevReady(build); + }); + ``` + +### Patch Changes + +- Fix absolute paths in CSS `url()` rules when using CSS Modules, Vanilla Extract and CSS side-effect imports ([#5788](https://github.com/remix-run/remix/pull/5788)) +- look for @remix-run/serve in `devDependencies` when running remix dev ([#6228](https://github.com/remix-run/remix/pull/6228)) +- add warning for v2 "cjs"->"esm" `serverModuleFormat` default change ([#6154](https://github.com/remix-run/remix/pull/6154)) +- write mjs server output files ([#6225](https://github.com/remix-run/remix/pull/6225)) +- fix(react,dev): dev chunking and refresh race condition ([#6201](https://github.com/remix-run/remix/pull/6201)) +- Use correct require context in `bareImports` plugin. ([#6181](https://github.com/remix-run/remix/pull/6181)) +- use minimatch for regex instead of glob-to-regexp ([#6017](https://github.com/remix-run/remix/pull/6017)) +- add `logDevReady` as replacement for platforms that can't initialize async I/O outside of the request response lifecycle. ([#6204](https://github.com/remix-run/remix/pull/6204)) +- Use the "automatic" JSX runtime when processing MDX files. ([#6098](https://github.com/remix-run/remix/pull/6098)) +- forcibly kill app server during dev ([#6197](https://github.com/remix-run/remix/pull/6197)) +- show first compilation error instead of cancelation errors ([#6202](https://github.com/remix-run/remix/pull/6202)) +- Resolve imports from route modules across the graph back to the virtual module created by the v2 routes plugin. This fixes issues where we would duplicate portions of route modules that were imported. ([#6098](https://github.com/remix-run/remix/pull/6098)) +- Updated dependencies: + - `@remix-run/server-runtime@1.16.0` + +## 1.15.0 + +### Minor Changes + +- Added deprecation warning for `v2_normalizeFormMethod` ([#5863](https://github.com/remix-run/remix/pull/5863)) + +- Added a new `future.v2_normalizeFormMethod` flag to normalize the exposed `useNavigation().formMethod` as an uppercase HTTP method to align with the previous `useTransition` behavior as well as the `fetch()` behavior of normalizing to uppercase HTTP methods. ([#5815](https://github.com/remix-run/remix/pull/5815)) + + - When `future.v2_normalizeFormMethod === false`, + - `useNavigation().formMethod` is lowercase + - `useFetcher().formMethod` is uppercase + - When `future.v2_normalizeFormMethod === true`: + - `useNavigation().formMethod` is uppercase + - `useFetcher().formMethod` is uppercase + +- Added deprecation warning for `browserBuildDirectory` in `remix.config` ([#5702](https://github.com/remix-run/remix/pull/5702)) + +- Added deprecation warning for `CatchBoundary` in favor of `future.v2_errorBoundary` ([#5718](https://github.com/remix-run/remix/pull/5718)) + +- Added experimental support for Vanilla Extract caching, which can be enabled by setting `future.unstable_vanillaExtract: { cache: true }` in `remix.config`. This is considered experimental due to the use of a brand new Vanilla Extract compiler under the hood. In order to use this feature, you must be using at least `v1.10.0` of `@vanilla-extract/css`. ([#5735](https://github.com/remix-run/remix/pull/5735)) + +- Added deprecation warning for `serverBuildDirectory` in `remix.config` ([#5704](https://github.com/remix-run/remix/pull/5704)) + +### Patch Changes + +- Fixed issue to ensure changes to CSS inserted via `@remix-run/css-bundle` are picked up during HMR ([#5823](https://github.com/remix-run/remix/pull/5823)) +- We now use `path.resolve` when re-exporting `entry.client` ([#5707](https://github.com/remix-run/remix/pull/5707)) +- Added support for `.mjs` and `.cjs` extensions when detecting CSS side-effect imports ([#5564](https://github.com/remix-run/remix/pull/5564)) +- Fixed resolution issues for pnpm users installing `react-refresh` ([#5637](https://github.com/remix-run/remix/pull/5637)) +- Added deprecation warning for `future.v2_meta` ([#5878](https://github.com/remix-run/remix/pull/5878)) +- Added optional entry file support for React 17 ([#5681](https://github.com/remix-run/remix/pull/5681)) +- Updated dependencies: + - `@remix-run/server-runtime@1.15.0` + +## 1.14.3 + +### Patch Changes + +- dev server is resilient to build failures ([#5795](https://github.com/remix-run/remix/pull/5795)) +- Updated dependencies: + - `@remix-run/server-runtime@1.14.3` + +## 1.14.2 + +### Patch Changes + +- remove premature deprecation warnings ([#5790](https://github.com/remix-run/remix/pull/5790)) +- Updated dependencies: + - `@remix-run/server-runtime@1.14.2` + +## 1.14.1 + +### Patch Changes + +- Add types for importing `*.ico` files ([#5430](https://github.com/remix-run/remix/pull/5430)) +- Allow `moduleResolution: "bundler"` in tsconfig.json ([#5576](https://github.com/remix-run/remix/pull/5576)) +- Fix issue with x-route imports creating multiple entries in the module graph ([#5721](https://github.com/remix-run/remix/pull/5721)) +- Add `serverBuildTarget` deprecation warning ([#5624](https://github.com/remix-run/remix/pull/5624)) +- Updated dependencies: + - `@remix-run/server-runtime@1.14.1` + +## 1.14.0 + +### Minor Changes + +- Hot Module Replacement and Hot Data Revalidation ([#5259](https://github.com/remix-run/remix/pull/5259)) + - Requires `unstable_dev` future flag to be enabled + - HMR provided through React Refresh + - Features: + - HMR for component and style changes + - HDR when loaders for current route change + - Known limitations for MVP: + - Only implemented for React via React Refresh + - No `import.meta.hot` API exposed yet + - Revalidates _all_ loaders on route when loader changes are detected + - Loader changes do not account for imported dependencies changing +- Make `entry.client` and `entry.server` files optional ([#4600](https://github.com/remix-run/remix/pull/4600)) + - we'll use a bundled version of each unless you provide your own + +### Patch Changes + +- Fixes flat route inconsistencies where `route.{ext}` wasn't always being treated like `index.{ext}` when used in a folder ([#5459](https://github.com/remix-run/remix/pull/5459)) + + - Route conflict no longer throw errors and instead display a helpful warning that we're using the first one we found. + + ```log + ⚠️ Route Path Collision: "/dashboard" + + The following routes all define the same URL, only the first one will be used + + 🟢️️ routes/dashboard/route.tsx + ⭕️️ routes/dashboard.tsx + ``` + + ```log + ⚠️ Route Path Collision: "/" + + The following routes all define the same URL, only the first one will be used + + 🟢️️ routes/_landing._index.tsx + ⭕️️ routes/_dashboard._index.tsx + ⭕️ routes/_index.tsx + ``` + +- Log errors thrown during initial build in development. ([#5441](https://github.com/remix-run/remix/pull/5441)) + +- Sync `FutureConfig` interface between packages ([#5398](https://github.com/remix-run/remix/pull/5398)) + +- Add file loader for importing `.csv` files ([#3920](https://github.com/remix-run/remix/pull/3920)) + +- Updated dependencies: + - `@remix-run/server-runtime@1.14.0` + +## 1.13.0 + +### Minor Changes + +- We are deprecating `serverBuildTarget` in `remix.config`. See the [release notes for v1.13.0](https://github.com/remix-run/remix/releases/tag/remix%401.13.0) for more information. ([#5354](https://github.com/remix-run/remix/pull/5354)) +- Add built-in support for PostCSS via the `future.unstable_postcss` feature flag ([#5229](https://github.com/remix-run/remix/pull/5229)) +- Add built-in support for Tailwind via the `future.unstable_tailwind` feature flag ([#5229](https://github.com/remix-run/remix/pull/5229)) + +### Patch Changes + +- Mark Vanilla Extract files as side effects to ensure that files only containing global styles aren't tree-shaken ([#5246](https://github.com/remix-run/remix/pull/5246)) +- Support decorators in files using CSS side-effect imports ([#5305](https://github.com/remix-run/remix/pull/5305)) +- We made several Flat route fixes and enhancements. See the [release notes for v1.13.0](https://github.com/remix-run/remix/releases/tag/remix%401.13.0) for more information. ([#5228](https://github.com/remix-run/remix/pull/5228)) +- Updated dependencies: + - `@remix-run/server-runtime@1.13.0` + +## 1.12.0 + +### Minor Changes + +- Added a new development server available in the Remix config under the `unstable_dev` flag. [See the release notes](https://github.com/remix-run/remix/releases/tag/remix%401.12.0) for a full description. ([#5133](https://github.com/remix-run/remix/pull/5133)) + +### Patch Changes + +- Fixed issues with `v2_routeConvention` on Windows so that new and renamed files are properly included ([#5266](https://github.com/remix-run/remix/pull/5266)) +- Server build should not be removed in `remix watch` and `remix dev` ([#5228](https://github.com/remix-run/remix/pull/5228)) +- The dev server will now clean up build directories whenever a rebuild starts ([#5223](https://github.com/remix-run/remix/pull/5223)) +- Updated dependencies: + - `@remix-run/server-runtime@1.12.0` + +## 1.11.1 + +### Patch Changes + +- Fixed a bug with `v2_routeConvention` that prevented `index` modules from being recognized for route paths ([`195291a3d`](https://github.com/remix-run/remix/commit/195291a3d8c0e098931199bcc26277a45cee0eb9)) +- Updated dependencies: + - `@remix-run/server-runtime@1.11.1` + +## 1.11.0 + +### Minor Changes + +- Specify file loader for `.fbx`, `.glb`, `.gltf`, `.hdr`, and `.mov` files ([#5030](https://github.com/remix-run/remix/pull/5030)) +- Added support for [Vanilla Extract](https://vanilla-extract.style) via the `unstable_vanillaExtract` future flag. **IMPORTANT:** Features marked with `unstable` are … unstable. While we're confident in the use cases they solve, the API and implementation may change without a major version bump. ([#5040](https://github.com/remix-run/remix/pull/5040)) +- Add support for CSS side-effect imports via the `unstable_cssSideEffectImports` future flag. **IMPORTANT:** Features marked with `unstable` are … unstable. While we're confident in the use cases they solve, the API and implementation may change without a major version bump. ([#4919](https://github.com/remix-run/remix/pull/4919)) +- Add support for CSS Modules via the `unstable_cssModules` future flag. **IMPORTANT:** Features marked with `unstable` are … unstable. While we're confident in the use cases they solve, the API and implementation may change without a major version bump. ([#4852](https://github.com/remix-run/remix/pull/4852)) + +### Patch Changes + +- Add new "flat" routing conventions. This convention will be the default in v2 but is available now under the `v2_routeConvention` future flag. ([#4880](https://github.com/remix-run/remix/pull/4880)) +- Added support for `handle` in MDX frontmatter ([#4865](https://github.com/remix-run/remix/pull/4865)) +- Updated dependencies: + - `@remix-run/server-runtime@1.11.0` + +## 1.10.1 + +### Patch Changes + +- Update babel config to transpile down to node 14 ([#5047](https://github.com/remix-run/remix/pull/5047)) +- Updated dependencies: + - `@remix-run/server-runtime@1.10.1` + +## 1.10.0 + +### Patch Changes + +- Fixed several issues with TypeScript to JavaScript conversion when running `create-remix` ([#4891](https://github.com/remix-run/remix/pull/4891)) +- Resolve asset entry full path to support monorepo import of styles ([#4855](https://github.com/remix-run/remix/pull/4855)) +- Updated dependencies: + - `@remix-run/server-runtime@1.10.0` + +## 1.9.0 + +### Minor Changes + +- Allow defining multiple routes for the same route module file ([#3970](https://github.com/remix-run/remix/pull/3970)) +- Added support and conventions for optional route segments ([#4706](https://github.com/remix-run/remix/pull/4706)) + +### Patch Changes + +- The Remix compiler now supports new Typescript 4.9 syntax (like the `satisfies` keyword) ([#4754](https://github.com/remix-run/remix/pull/4754)) +- Optimize `parentRouteId` lookup in `defineConventionalRoutes`. ([#4800](https://github.com/remix-run/remix/pull/4800)) +- Fixed a bug in `.ts` -> `.js` conversion on Windows by using a relative unix-style path ([#4718](https://github.com/remix-run/remix/pull/4718)) +- Updated dependencies: + - `@remix-run/server-runtime@1.9.0` + +## 1.8.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.8.2` + - `@remix-run/serve@1.8.2` + +## 1.8.1 + +### Patch Changes + +- Added a missing type definition for the Remix config `future` option to the `@remix-run/dev/server-build` virtual module ([#4771](https://github.com/remix-run/remix/pull/4771)) +- Updated dependencies: + - `@remix-run/serve@1.8.1` + - `@remix-run/server-runtime@1.8.1` + +## 1.8.0 + +### Minor Changes + +- Added support for a new route `meta` API to handle arrays of tags instead of an object. For details, check out the [RFC](https://github.com/remix-run/remix/discussions/4462). ([#4610](https://github.com/remix-run/remix/pull/4610)) + +### Patch Changes + +- Importing functions and types from the `remix` package is deprecated, and all exported modules will be removed in the next major release. For more details,[see the release notes for 1.4.0](https://github.com/remix-run/remix/releases/tag/v1.4.0) where these changes were first announced. ([#4661](https://github.com/remix-run/remix/pull/4661)) +- Updated dependencies: + - `@remix-run/server-runtime@1.8.0` + - `@remix-run/serve@1.8.0` + +## 1.7.6 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/serve@1.7.6` + - `@remix-run/server-runtime@1.7.6` + +### Patch Changes + +- Updated dependencies: + - `@remix-run/serve@1.7.6-pre.0` + - `@remix-run/server-runtime@1.7.6-pre.0` + +## 1.7.5 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/serve@1.7.5` + - `@remix-run/server-runtime@1.7.5` + +## 1.7.4 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.7.4` + - `@remix-run/serve@1.7.4` + +## 1.7.3 + +### Patch Changes + +- Update `create-remix` to use the new examples repository when using `--template example/` ([#4208](https://github.com/remix-run/remix/pull/4208)) +- Add support for setting `moduleResolution` to `node`, `node16` or `nodenext` in `tsconfig.json`. ([#4034](https://github.com/remix-run/remix/pull/4034)) +- Add resources imported only by resource routes to `assetsBuildDirectory` ([#3841](https://github.com/remix-run/remix/pull/3841)) +- Ensure that any assets referenced in CSS files are hashed and copied to the `assetsBuildDirectory`. ([#4130](https://github.com/remix-run/remix/pull/4130)) +- Updated dependencies: + - `@remix-run/serve@1.7.3` + - `@remix-run/server-runtime@1.7.3` + +## 1.7.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.7.2` + - `@remix-run/serve@1.7.2` + +## 1.7.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.7.1` + - `@remix-run/serve@1.7.1` + +## 1.7.0 + +### Minor Changes + +- Added support for importing `.gql` and `.graphql` files as plain text ([#3923](https://github.com/remix-run/remix/pull/3923)) +- Added support for importing `.zip` and `.avif` files as resource URLs ([#3985](https://github.com/remix-run/remix/pull/3985)) + +### Patch Changes + +- Removed our compiler's React shim in favor of esbuild's new automatic JSX transform ([#3860](https://github.com/remix-run/remix/pull/3860)) +- Updated dependencies: + - `@remix-run/server-runtime@1.7.0` + - `@remix-run/serve@1.7.0` + +## 1.6.8 + +### Patch Changes + +- Added support for `.mjs` and `.cjs` file extensions for `remix.config` ([#3675](https://github.com/remix-run/remix/pull/3675)) +- Added support for importing `.sql` files as text content ([#3190](https://github.com/remix-run/remix/pull/3190)) +- Updated the compiler to make MDX builds deterministic (and a little faster!) ([#3966](https://github.com/remix-run/remix/pull/3966)) +- Updated dependencies: + - `@remix-run/server-runtime@1.6.8` + - `@remix-run/serve@1.6.8` + +## 1.6.7 + +### Patch Changes + +- Remove logical nullish assignment, which is incompatible with Node v14. ([#3880](https://github.com/remix-run/remix/pull/3880)) +- Don't show ESM warnings when consumed via dynamic import. ([#3872](https://github.com/remix-run/remix/pull/3872)) +- Updated dependencies: + - `@remix-run/serve@1.6.7` + - `@remix-run/server-runtime@1.6.7` + +## 1.6.6 + +### Patch Changes + +- Write server build output files so that only assets imported from resource routes are written to disk ([#3817](https://github.com/remix-run/remix/pull/3817)) +- Add support for exporting links in `.mdx` files ([#3801](https://github.com/remix-run/remix/pull/3801)) +- Ensure that build hashing is deterministic ([#2027](https://github.com/remix-run/remix/pull/2027)) +- Fix types for `@remix-run/dev/server-build` virtual module ([#3743](https://github.com/remix-run/remix/pull/3743)) +- Updated dependencies: + - `@remix-run/serve@1.6.6` + - `@remix-run/server-runtime@1.6.6` + +## 1.6.5 + +### Patch Changes + +- Update `serverBareModulesPlugin` warning to use full import path ([#3656](https://github.com/remix-run/remix/pull/3656)) +- Fix broken `--port` flag in `create-remix` ([#3694](https://github.com/remix-run/remix/pull/3694)) +- Updated dependencies + - `@remix-run/server-runtime` + - `@remix-run/serve` diff --git a/packages/remix-dev/README.md b/packages/remix-dev/README.md new file mode 100644 index 0000000000..40685a7476 --- /dev/null +++ b/packages/remix-dev/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts new file mode 100644 index 0000000000..02ccfe6624 --- /dev/null +++ b/packages/remix-dev/__tests__/cli-test.ts @@ -0,0 +1,202 @@ +import childProcess from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import util from "node:util"; +import fse from "fs-extra"; +import semver from "semver"; + +let execFile = util.promisify(childProcess.execFile); + +const TEMP_DIR = path.join( + fse.realpathSync(os.tmpdir()), + `remix-tests-${Math.random().toString(32).slice(2)}` +); + +jest.setTimeout(30_000); +beforeAll(async () => { + await fse.remove(TEMP_DIR); + await fse.ensureDir(TEMP_DIR); +}); + +afterAll(async () => { + await fse.remove(TEMP_DIR); +}); + +async function execRemix( + args: Array, + options: Exclude[2], null | undefined> = {} +) { + if (process.platform === "win32") { + let cp = childProcess.spawnSync( + "node", + [ + "--require", + require.resolve("esbuild-register"), + path.resolve(__dirname, "../cli.ts"), + ...args, + ], + { + cwd: TEMP_DIR, + ...options, + env: { + ...process.env, + NO_COLOR: "1", + ...options.env, + }, + } + ); + + return { + stdout: cp.stdout?.toString("utf-8"), + }; + } else { + let result = await execFile( + "node", + [ + "--require", + require.resolve("esbuild-register"), + path.resolve(__dirname, "../cli.ts"), + ...args, + ], + { + cwd: TEMP_DIR, + ...options, + env: { + ...process.env, + NO_COLOR: "1", + ...options.env, + }, + } + ); + return { + ...result, + stdout: result.stdout.replace(TEMP_DIR, "").trim(), + }; + } +} + +describe("remix CLI", () => { + describe("the --help flag", () => { + it("prints help info", async () => { + let { stdout } = await execRemix(["--help"]); + expect(stdout.trim()).toMatchInlineSnapshot(` + "R E M I X + + Usage: + $ remix init [projectDir] + $ remix vite:build [projectDir] + $ remix vite:dev [projectDir] + $ remix build [projectDir] + $ remix dev [projectDir] + $ remix routes [projectDir] + $ remix watch [projectDir] + + Options: + --help, -h Print this help message and exit + --version, -v Print the CLI version and exit + --no-color Disable ANSI colors in console output + \`vite:build\` Options (Passed through to Vite): + --assetsInlineLimit Static asset base64 inline threshold in bytes (default: 4096) (number) + --clearScreen Allow/disable clear screen when logging (boolean) + --config, -c Use specified config file (string) + --emptyOutDir Force empty outDir when it's outside of root (boolean) + --logLevel, -l Info | warn | error | silent (string) + --minify Enable/disable minification, or specify minifier to use (default: "esbuild") (boolean | "terser" | "esbuild") + --mode, -m Set env mode (string) + --profile Start built-in Node.js inspector + --sourcemapClient Output source maps for client build (default: false) (boolean | "inline" | "hidden") + --sourcemapServer Output source maps for server build (default: false) (boolean | "inline" | "hidden") + \`build\` Options: + --sourcemap Generate source maps for production + \`vite:dev\` Options (Passed through to Vite): + --clearScreen Allow/disable clear screen when logging (boolean) + --config, -c Use specified config file (string) + --cors Enable CORS (boolean) + --force Force the optimizer to ignore the cache and re-bundle (boolean) + --host Specify hostname (string) + --logLevel, -l Info | warn | error | silent (string) + --mode, -m Set env mode (string) + --open Open browser on startup (boolean | string) + --port Specify port (number) + --profile Start built-in Node.js inspector + --strictPort Exit if specified port is already in use (boolean) + \`dev\` Options: + --command, -c Command used to run your app server + --manual Enable manual mode + --port Port for the dev server. Default: any open port + --tls-key Path to TLS key (key.pem) + --tls-cert Path to TLS certificate (cert.pem) + \`init\` Options: + --no-delete Skip deleting the \`remix.init\` script + \`routes\` Options: + --config, -c Use specified Vite config file (string) + --json Print the routes as JSON + \`reveal\` Options: + --config, -c Use specified Vite config file (string) + --no-typescript Generate plain JavaScript files + + Values: + - projectDir The Remix project directory + - remixPlatform \`node\` or \`cloudflare\` + + Initialize a project:: + + Remix project templates may contain a \`remix.init\` directory + with a script that initializes the project. This script automatically + runs during \`remix create\`, but if you ever need to run it manually + (e.g. to test it out) you can: + + $ remix init + + Build your project (Vite): + + $ remix vite:build + + Run your project locally in development (Vite): + + $ remix vite:dev + + Build your project (Classic compiler): + + $ remix build + $ remix build --sourcemap + $ remix build my-app + + Run your project locally in development (Classic compiler): + + $ remix dev + $ remix dev -c "node ./server.js" + + Start your server separately and watch for changes (Classic compiler): + + # custom server start command, for example: + $ remix watch + + # in a separate tab: + $ node --inspect --require ./node_modules/dotenv/config --require ./mocks ./build/server.js + + Show all routes in your app: + + $ remix routes + $ remix routes my-app + $ remix routes --json + $ remix routes --config vite.remix.config.ts + + Reveal the used entry point: + + $ remix reveal entry.client + $ remix reveal entry.server + $ remix reveal entry.client --no-typescript + $ remix reveal entry.server --no-typescript + $ remix reveal entry.server --config vite.remix.config.ts" + `); + }); + }); + + describe("the --version flag", () => { + it("prints the current version", async () => { + let { stdout } = await execRemix(["--version"]); + expect(!!semver.valid(stdout.trim())).toBe(true); + }); + }); +}); diff --git a/packages/remix-dev/__tests__/cssSideEffectImports-test.ts b/packages/remix-dev/__tests__/cssSideEffectImports-test.ts new file mode 100644 index 0000000000..b3abf5c02c --- /dev/null +++ b/packages/remix-dev/__tests__/cssSideEffectImports-test.ts @@ -0,0 +1,476 @@ +import dedent from "dedent"; + +import { addSuffixToCssSideEffectImports } from "../compiler/plugins/cssSideEffectImports"; + +describe("addSuffixToCssSideEffectImports", () => { + describe("adds suffix", () => { + test("side-effect require", () => { + let code = dedent` + require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"require("./foo.css?__remix_sideEffect__");"` + ); + }); + + test("side-effect import", () => { + let code = dedent` + import "./foo.css"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import "./foo.css?__remix_sideEffect__";"` + ); + }); + + test("side-effect import with JSX", () => { + let code = dedent` + import "./foo.css"; + + export const Foo = () =>
    ; + `; + + expect(addSuffixToCssSideEffectImports("jsx", code)) + .toMatchInlineSnapshot(` + "import "./foo.css?__remix_sideEffect__"; + + export const Foo = () =>
    ;" + `); + }); + + test("side-effect import in TypeScript", () => { + let code = dedent` + require("./foo.css"); + + export const foo: string = 'foo' satisfies string; + `; + + expect(addSuffixToCssSideEffectImports("ts", code)) + .toMatchInlineSnapshot(` + "require("./foo.css?__remix_sideEffect__"); + + export const foo: string = ('foo' satisfies string);" + `); + }); + + test("side-effect import in TypeScript with JSX", () => { + let code = dedent` + require("./foo.css"); + + export const foo: string = 'foo' satisfies string; + export const Bar = () =>
    {foo}
    ; + `; + + expect(addSuffixToCssSideEffectImports("tsx", code)) + .toMatchInlineSnapshot(` + "require("./foo.css?__remix_sideEffect__"); + + export const foo: string = ('foo' satisfies string); + export const Bar = () =>
    {foo}
    ;" + `); + }); + + test("conditional side-effect require", () => { + let code = dedent` + if (process.env.NODE_ENV === "production") { + require("./foo.min.css"); + } else { + require("./foo.css"); + } + `; + + expect(addSuffixToCssSideEffectImports("js", code)) + .toMatchInlineSnapshot(` + "if (process.env.NODE_ENV === "production") { + require("./foo.min.css?__remix_sideEffect__"); + } else { + require("./foo.css?__remix_sideEffect__"); + }" + `); + }); + + test("conditional side-effect require via ternary", () => { + let code = dedent` + process.env.NODE_ENV === "production" ? require("./foo.min.css") : require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"process.env.NODE_ENV === "production" ? require("./foo.min.css?__remix_sideEffect__") : require("./foo.css?__remix_sideEffect__");"` + ); + }); + + test("conditional side-effect require via && operator", () => { + let code = dedent` + process.env.NODE_ENV === "development" && require("./debug.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"process.env.NODE_ENV === "development" && require("./debug.css?__remix_sideEffect__");"` + ); + }); + + test("conditional side-effect require via || operator", () => { + let code = dedent` + process.env.NODE_ENV === "production" || require("./debug.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"process.env.NODE_ENV === "production" || require("./debug.css?__remix_sideEffect__");"` + ); + }); + }); + + describe("doesn't add suffix", () => { + test("ignores non side-effect require of CSS", () => { + let code = dedent` + const href = require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"const href = require("./foo.css");"` + ); + }); + + test("ignores default import of CSS", () => { + let code = dedent` + import href from "./foo.css"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import href from "./foo.css";"` + ); + }); + + test("ignores named import of CSS", () => { + let code = dedent` + import { foo } from "./foo.css"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import { foo } from "./foo.css";"` + ); + }); + + test("ignores namespace import of CSS", () => { + let code = dedent` + import * as foo from "./foo.css"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import * as foo from "./foo.css";"` + ); + }); + + test("ignores conditional non side-effect require of CSS", () => { + let code = dedent` + const href = process.env.NODE_ENV === "production" ? + require("./foo.min.css") : + require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)) + .toMatchInlineSnapshot(` + "const href = process.env.NODE_ENV === "production" ? + require("./foo.min.css") : + require("./foo.css");" + `); + }); + + test("ignores conditional non side-effect require of CSS via logical operators", () => { + let code = dedent` + const href = (process.env.NODE_ENV === "production" && require("./foo.min.css")) || require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"const href = process.env.NODE_ENV === "production" && require("./foo.min.css") || require("./foo.css");"` + ); + }); + + test("ignores side-effect require of non-CSS", () => { + let code = dedent` + require("./foo"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"require("./foo");"` + ); + }); + + test("ignores side-effect import of non-CSS", () => { + let code = dedent` + import "./foo"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import "./foo";"` + ); + }); + + test("ignores dynamic import", () => { + let code = dedent` + export const foo = async () => { + await import("./foo.css"); + } + `; + + expect(addSuffixToCssSideEffectImports("js", code)) + .toMatchInlineSnapshot(` + "export const foo = async () => { + await import("./foo.css"); + };" + `); + }); + }); + + describe("parser support for language features", () => { + function languageFeaturesFixture(options: { ts: boolean; jsx: boolean }) { + let tsLanguageFeatures = dedent` + // TS + const exampleSatisfies = 'satisfies' satisfies string; + enum ExampleEnum { + Foo, + Bar, + Baz, + } + `; + + let jsxLanguageFeatures = dedent` + // JSX + const ExampleComponent = () =>
    JSX element
    ; + `; + + let jsLanguageFeatures = dedent` + // JS + const topLevelAwait = await Promise.resolve('top level await'); + function classDecorator(target) { + return target; + } + function methodDecorator(target) { + return target; + } + @classDecorator + class ExampleClass { + #privateField; + #privateFieldWithInitializer = 'private field with initializer'; + #privateMethod() { + return 'private method'; + } + @methodDecorator + decoratedMethod() { + return 'decorated method'; + } + } + const numericSeparator = 1_000_000; + const nullishCoalescing = null ?? 'nullish coalescing'; + const optionalChaining = (['optional', 'chaining'])?.join?.(' '); + let optionalCatchBinding; + try { + optionalCatchBinding = error(); + } catch { + optionalCatchBinding = 'optional catch binding'; + } + export async function* asyncGenerator() { + yield await Promise.resolve('async generator'); + } + `; + + return [ + 'require("./foo.css")', + ...(options.ts ? [tsLanguageFeatures] : []), + ...(options.jsx ? [jsxLanguageFeatures] : []), + jsLanguageFeatures, + ].join("\n\n"); + } + + test("JS language features", () => { + let code = languageFeaturesFixture({ ts: false, jsx: false }); + + expect(addSuffixToCssSideEffectImports("js", code)) + .toMatchInlineSnapshot(` + "require("./foo.css?__remix_sideEffect__"); + + // JS + const topLevelAwait = await Promise.resolve('top level await'); + function classDecorator(target) { + return target; + } + function methodDecorator(target) { + return target; + } + @classDecorator class + ExampleClass { + #privateField; + #privateFieldWithInitializer = 'private field with initializer'; + #privateMethod() { + return 'private method'; + } + @methodDecorator + decoratedMethod() { + return 'decorated method'; + } + } + const numericSeparator = 1_000_000; + const nullishCoalescing = null ?? 'nullish coalescing'; + const optionalChaining = ['optional', 'chaining']?.join?.(' '); + let optionalCatchBinding; + try { + optionalCatchBinding = error(); + } catch { + optionalCatchBinding = 'optional catch binding'; + } + export async function* asyncGenerator() { + yield await Promise.resolve('async generator'); + }" + `); + }); + + test("JSX language features", () => { + let code = languageFeaturesFixture({ ts: false, jsx: true }); + + expect(addSuffixToCssSideEffectImports("jsx", code)) + .toMatchInlineSnapshot(` + "require("./foo.css?__remix_sideEffect__"); + + // JSX + const ExampleComponent = () =>
    JSX element
    ; + + // JS + const topLevelAwait = await Promise.resolve('top level await'); + function classDecorator(target) { + return target; + } + function methodDecorator(target) { + return target; + } + @classDecorator class + ExampleClass { + #privateField; + #privateFieldWithInitializer = 'private field with initializer'; + #privateMethod() { + return 'private method'; + } + @methodDecorator + decoratedMethod() { + return 'decorated method'; + } + } + const numericSeparator = 1_000_000; + const nullishCoalescing = null ?? 'nullish coalescing'; + const optionalChaining = ['optional', 'chaining']?.join?.(' '); + let optionalCatchBinding; + try { + optionalCatchBinding = error(); + } catch { + optionalCatchBinding = 'optional catch binding'; + } + export async function* asyncGenerator() { + yield await Promise.resolve('async generator'); + }" + `); + }); + + test("TS language features", () => { + let code = languageFeaturesFixture({ ts: true, jsx: false }); + + expect(addSuffixToCssSideEffectImports("tsx", code)) + .toMatchInlineSnapshot(` + "require("./foo.css?__remix_sideEffect__"); + + // TS + const exampleSatisfies = ('satisfies' satisfies string); + enum ExampleEnum { + Foo, + Bar, + Baz, + } + + // JS + const topLevelAwait = await Promise.resolve('top level await'); + function classDecorator(target) { + return target; + } + function methodDecorator(target) { + return target; + } + @classDecorator class + ExampleClass { + #privateField; + #privateFieldWithInitializer = 'private field with initializer'; + #privateMethod() { + return 'private method'; + } + @methodDecorator + decoratedMethod() { + return 'decorated method'; + } + } + const numericSeparator = 1_000_000; + const nullishCoalescing = null ?? 'nullish coalescing'; + const optionalChaining = ['optional', 'chaining']?.join?.(' '); + let optionalCatchBinding; + try { + optionalCatchBinding = error(); + } catch { + optionalCatchBinding = 'optional catch binding'; + } + export async function* asyncGenerator() { + yield await Promise.resolve('async generator'); + }" + `); + }); + + test("TSX language features", () => { + let code = languageFeaturesFixture({ ts: true, jsx: true }); + + expect(addSuffixToCssSideEffectImports("tsx", code)) + .toMatchInlineSnapshot(` + "require("./foo.css?__remix_sideEffect__"); + + // TS + const exampleSatisfies = ('satisfies' satisfies string); + enum ExampleEnum { + Foo, + Bar, + Baz, + } + + // JSX + const ExampleComponent = () =>
    JSX element
    ; + + // JS + const topLevelAwait = await Promise.resolve('top level await'); + function classDecorator(target) { + return target; + } + function methodDecorator(target) { + return target; + } + @classDecorator class + ExampleClass { + #privateField; + #privateFieldWithInitializer = 'private field with initializer'; + #privateMethod() { + return 'private method'; + } + @methodDecorator + decoratedMethod() { + return 'decorated method'; + } + } + const numericSeparator = 1_000_000; + const nullishCoalescing = null ?? 'nullish coalescing'; + const optionalChaining = ['optional', 'chaining']?.join?.(' '); + let optionalCatchBinding; + try { + optionalCatchBinding = error(); + } catch { + optionalCatchBinding = 'optional catch binding'; + } + export async function* asyncGenerator() { + yield await Promise.resolve('async generator'); + }" + `); + }); + }); +}); diff --git a/packages/remix-dev/__tests__/defineRoutes-test.ts b/packages/remix-dev/__tests__/defineRoutes-test.ts new file mode 100644 index 0000000000..44f73e9951 --- /dev/null +++ b/packages/remix-dev/__tests__/defineRoutes-test.ts @@ -0,0 +1,166 @@ +import { defineRoutes } from "../config/routes"; + +describe("defineRoutes", () => { + it("returns an array of routes", () => { + let routes = defineRoutes((route) => { + route("/", "routes/home.js"); + route("inbox", "routes/inbox.js", () => { + route("/", "routes/inbox/index.js", { index: true }); + route(":messageId", "routes/inbox/$messageId.js"); + route("archive", "routes/inbox/archive.js"); + }); + }); + + expect(routes).toMatchInlineSnapshot(` + { + "routes/home": { + "caseSensitive": undefined, + "file": "routes/home.js", + "id": "routes/home", + "index": undefined, + "parentId": "root", + "path": "/", + }, + "routes/inbox": { + "caseSensitive": undefined, + "file": "routes/inbox.js", + "id": "routes/inbox", + "index": undefined, + "parentId": "root", + "path": "inbox", + }, + "routes/inbox/$messageId": { + "caseSensitive": undefined, + "file": "routes/inbox/$messageId.js", + "id": "routes/inbox/$messageId", + "index": undefined, + "parentId": "routes/inbox", + "path": ":messageId", + }, + "routes/inbox/archive": { + "caseSensitive": undefined, + "file": "routes/inbox/archive.js", + "id": "routes/inbox/archive", + "index": undefined, + "parentId": "routes/inbox", + "path": "archive", + }, + "routes/inbox/index": { + "caseSensitive": undefined, + "file": "routes/inbox/index.js", + "id": "routes/inbox/index", + "index": true, + "parentId": "routes/inbox", + "path": "/", + }, + } + `); + }); + + it("works with async data", async () => { + // Read everything *before* calling defineRoutes. + let fakeDirectory = await Promise.resolve(["one.md", "two.md"]); + let routes = defineRoutes((route) => { + for (let file of fakeDirectory) { + route(file.replace(/\.md$/, ""), file); + } + }); + + expect(routes).toMatchInlineSnapshot(` + { + "one": { + "caseSensitive": undefined, + "file": "one.md", + "id": "one", + "index": undefined, + "parentId": "root", + "path": "one", + }, + "two": { + "caseSensitive": undefined, + "file": "two.md", + "id": "two", + "index": undefined, + "parentId": "root", + "path": "two", + }, + } + `); + }); + + it("allows multiple routes with the same route module", () => { + let routes = defineRoutes((route) => { + route("/user/:id", "routes/_index.tsx", { id: "user-by-id" }); + route("/user", "routes/_index.tsx", { id: "user" }); + route("/other", "routes/other-route.tsx"); + }); + + expect(routes).toMatchInlineSnapshot(` + { + "routes/other-route": { + "caseSensitive": undefined, + "file": "routes/other-route.tsx", + "id": "routes/other-route", + "index": undefined, + "parentId": "root", + "path": "/other", + }, + "user": { + "caseSensitive": undefined, + "file": "routes/_index.tsx", + "id": "user", + "index": undefined, + "parentId": "root", + "path": "/user", + }, + "user-by-id": { + "caseSensitive": undefined, + "file": "routes/_index.tsx", + "id": "user-by-id", + "index": undefined, + "parentId": "root", + "path": "/user/:id", + }, + } + `); + }); + + it("throws an error on route id collisions", () => { + // Two conflicting custom id's + let defineNonUniqueRoutes = () => { + defineRoutes((route) => { + route("/user/:id", "routes/user.tsx", { id: "user" }); + route("/user", "routes/user.tsx", { id: "user" }); + route("/other", "routes/other-route.tsx"); + }); + }; + + expect(defineNonUniqueRoutes).toThrowErrorMatchingInlineSnapshot( + `"Unable to define routes with duplicate route id: "user""` + ); + + // Custom id conflicting with a later-defined auto-generated id + defineNonUniqueRoutes = () => { + defineRoutes((route) => { + route("/user/:id", "routes/user.tsx", { id: "routes/user" }); + route("/user", "routes/user.tsx"); + }); + }; + + expect(defineNonUniqueRoutes).toThrowErrorMatchingInlineSnapshot( + `"Unable to define routes with duplicate route id: "routes/user""` + ); + + // Custom id conflicting with an earlier-defined auto-generated id + defineNonUniqueRoutes = () => { + defineRoutes((route) => { + route("/user", "routes/user.tsx"); + route("/user/:id", "routes/user.tsx", { id: "routes/user" }); + }); + }; + + expect(defineNonUniqueRoutes).toThrowErrorMatchingInlineSnapshot( + `"Unable to define routes with duplicate route id: "routes/user""` + ); + }); +}); diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/.gitignore b/packages/remix-dev/__tests__/fixtures/cloudflare/.gitignore new file mode 100644 index 0000000000..7c0736ebf5 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/.gitignore @@ -0,0 +1,7 @@ +node_modules + +/.cache +/functions/\[\[path\]\].js +/functions/\[\[path\]\].js.map +/public/build +.dev.vars diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/.node-version b/packages/remix-dev/__tests__/fixtures/cloudflare/.node-version new file mode 100644 index 0000000000..5b0ad74a81 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/.node-version @@ -0,0 +1 @@ +16.13.0 \ No newline at end of file diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/README.md b/packages/remix-dev/__tests__/fixtures/cloudflare/README.md new file mode 100644 index 0000000000..130feb1486 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/README.md @@ -0,0 +1,22 @@ +# Welcome to Remix! + +- [Remix Docs](https://remix.run/docs) + +## Development + +You will be utilizing Wrangler for local development to emulate the Cloudflare runtime. This is already wired up in your package.json as the `dev` script: + +```sh +# start the remix dev server and wrangler +npm run dev +``` + +Open up [http://127.0.0.1:8788](http://127.0.0.1:8788) and you should be ready to go! + +## Deployment + +Cloudflare Pages are currently only deployable through their Git provider integrations. + +If you don't already have an account, then [create a Cloudflare account here](https://dash.cloudflare.com/sign-up/pages) and after verifying your email address with Cloudflare, go to your dashboard and follow the [Cloudflare Pages deployment guide](https://developers.cloudflare.com/pages/framework-guides/deploy-anything). + +Configure the "Build command" should be set to `npm run build`, and the "Build output directory" should be set to `public`. diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/app/root.tsx b/packages/remix-dev/__tests__/fixtures/cloudflare/app/root.tsx new file mode 100644 index 0000000000..68397b09d4 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/app/root.tsx @@ -0,0 +1,33 @@ +import type { LinksFunction } from "@remix-run/cloudflare"; +import { cssBundleHref } from "@remix-run/css-bundle"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/app/routes/_index.tsx b/packages/remix-dev/__tests__/fixtures/cloudflare/app/routes/_index.tsx new file mode 100644 index 0000000000..4aa6089f12 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "@remix-run/cloudflare"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/package.json b/packages/remix-dev/__tests__/fixtures/cloudflare/package.json new file mode 100644 index 0000000000..c644afccb8 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/package.json @@ -0,0 +1,32 @@ +{ + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "remix build", + "dev": "remix dev --manual -c \"npm run start\"", + "start": "wrangler pages dev --compatibility-date=2023-06-21 ./public", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/cloudflare": "*", + "@remix-run/cloudflare-pages": "*", + "@remix-run/css-bundle": "*", + "@remix-run/react": "*", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20230518.0", + "@remix-run/dev": "*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "eslint": "^8.38.0", + "typescript": "^5.1.0", + "wrangler": "^3.1.1" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/public/_headers b/packages/remix-dev/__tests__/fixtures/cloudflare/public/_headers new file mode 100644 index 0000000000..c5129f35cd --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/public/_headers @@ -0,0 +1,4 @@ +/favicon.ico + Cache-Control: public, max-age=3600, s-maxage=3600 +/build/* + Cache-Control: public, max-age=31536000, immutable diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/public/_routes.json b/packages/remix-dev/__tests__/fixtures/cloudflare/public/_routes.json new file mode 100644 index 0000000000..4b57270dae --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/*"], + "exclude": ["/favicon.ico", "/build/*"] +} diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/public/favicon.ico b/packages/remix-dev/__tests__/fixtures/cloudflare/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/packages/remix-dev/__tests__/fixtures/cloudflare/public/favicon.ico differ diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/remix.config.js b/packages/remix-dev/__tests__/fixtures/cloudflare/remix.config.js new file mode 100644 index 0000000000..88f8d63bbc --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/remix.config.js @@ -0,0 +1,14 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +export default { + server: "./server.ts", + serverBuildPath: "functions/[[path]].js", + serverConditions: ["workerd", "worker", "browser"], + serverDependenciesToBundle: "all", + serverMainFields: ["browser", "module", "main"], + serverMinify: true, + serverModuleFormat: "esm", + serverPlatform: "neutral", + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", +}; diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/remix.env.d.ts b/packages/remix-dev/__tests__/fixtures/cloudflare/remix.env.d.ts new file mode 100644 index 0000000000..425870ae63 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/remix.env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/server.ts b/packages/remix-dev/__tests__/fixtures/cloudflare/server.ts new file mode 100644 index 0000000000..338a5e5737 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/server.ts @@ -0,0 +1,9 @@ +import { logDevReady } from "@remix-run/cloudflare"; +import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages"; +import * as build from "@remix-run/dev/server-build"; + +if (process.env.NODE_ENV === "development") { + logDevReady(build); +} + +export const onRequest = createPagesFunctionHandler({ build }); diff --git a/packages/remix-dev/__tests__/fixtures/cloudflare/tsconfig.json b/packages/remix-dev/__tests__/fixtures/cloudflare/tsconfig.json new file mode 100644 index 0000000000..28cce918b8 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/cloudflare/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/packages/remix-dev/__tests__/fixtures/deno/.gitignore b/packages/remix-dev/__tests__/fixtures/deno/.gitignore new file mode 100644 index 0000000000..3f7bf98da3 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/packages/remix-dev/__tests__/fixtures/deno/.vscode/extensions.json b/packages/remix-dev/__tests__/fixtures/deno/.vscode/extensions.json new file mode 100644 index 0000000000..74baffcc47 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["denoland.vscode-deno"] +} diff --git a/packages/remix-dev/__tests__/fixtures/deno/.vscode/resolve_npm_imports.json b/packages/remix-dev/__tests__/fixtures/deno/.vscode/resolve_npm_imports.json new file mode 100644 index 0000000000..af7431c1ee --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/.vscode/resolve_npm_imports.json @@ -0,0 +1,22 @@ +{ + "comment": [ + "Resolve NPM imports for `packages/remix-deno`.", + "This import map is used solely for the denoland.vscode-deno extension.", + "Remix does not support import maps.", + "Dependency management is done through `npm` and `node_modules/` instead.", + "Deno-only dependencies may be imported via URL imports (without using import maps)." + ], + "imports": { + "@remix-run/css-bundle": "https://esm.sh/@remix-run/css-bundle@1.16.0", + "// `@remix-run/deno` code is already a Deno module, so just get types for it directly from `node_modules/`": "", + "@remix-run/deno": "../node_modules/@remix-run/deno/index.ts", + "@remix-run/dev/server-build": "https://esm.sh/@remix-run/dev@1.16.0/server-build", + "@remix-run/react": "https://esm.sh/@remix-run/react@1.16.0", + "@remix-run/server-runtime": "https://esm.sh/@remix-run/server-runtime@1.16.0", + "isbot": "https://esm.sh/isbot@^4.1.0", + "react": "https://esm.sh/react@^18.2.0", + "react-dom": "https://esm.sh/react-dom@^18.2.0", + "react-dom/client": "https://esm.sh/react-dom@^18.2.0/client", + "react-dom/server": "https://esm.sh/react-dom@^18.2.0/server" + } +} diff --git a/packages/remix-dev/__tests__/fixtures/deno/.vscode/settings.json b/packages/remix-dev/__tests__/fixtures/deno/.vscode/settings.json new file mode 100644 index 0000000000..e1533c2bb7 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "deno.enable": true, + "deno.lint": true +} diff --git a/packages/remix-dev/__tests__/fixtures/deno/README.md b/packages/remix-dev/__tests__/fixtures/deno/README.md new file mode 100644 index 0000000000..3e7473485e --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/README.md @@ -0,0 +1,128 @@ +# Remix + Deno + +Welcome to the Deno template for Remix! 🦕 + +For more, check out the [Remix docs](https://remix.run/docs). + +## Install + +```sh +npx create-remix@latest --template remix-run/remix/templates/classic-remix-compiler/deno +``` + +## Managing dependencies + +Read about +[how we recommend to manage dependencies for Remix projects using Deno](https://github.com/remix-run/remix/blob/main/decisions/0001-use-npm-to-manage-npm-dependencies-for-deno-projects.md). + +- ✅ You should use `npm` to install NPM packages + ```sh + npm install react + ``` + ```ts + import { useState } from "react"; + ``` +- ✅ You may use inlined URL imports or + [deps.ts](https://deno.land/manual/examples/manage_dependencies#managing-dependencies) + for Deno modules. + ```ts + import { copy } from "https://deno.land/std@0.138.0/streams/conversion.ts"; + ``` +- ❌ Do not use + [import maps](https://deno.land/manual/linking_to_external_code/import_maps). + +## Development + +From your terminal: + +```sh +npm run dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +### Type hints + +This template provides type hinting to VS Code via a +[dedicated import map](./.vscode/resolve_npm_imports.json). + +To get types in another editor, use an extension for Deno that supports import +maps and point your editor to `./.vscode/resolve_npm_imports.json`. + +For more, see +[our decision doc for interop between Deno and NPM](https://github.com/remix-run/remix/blob/main/decisions/0001-use-npm-to-manage-npm-dependencies-for-deno-projects.md#vs-code-type-hints). + +## Production + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +## Deployment + +Building the Deno app (`npm run build`) results in two outputs: + +- `build/` (server bundle) +- `public/build/` (browser bundle) + +You can deploy these bundles to any host that runs Deno, but here we'll focus on +deploying to [Deno Deploy](https://deno.com/deploy). + +### Setting up Deno Deploy + +1. [Sign up](https://dash.deno.com/signin) for Deno Deploy. + +2. [Create a new Deno Deploy project](https://dash.deno.com/new) for this app. + +3. Replace `` in the `deploy` script in `package.json` + with your Deno Deploy project name: + +```json filename=package.json +{ + "scripts": { + "deploy": "deployctl deploy --project= --include=.cache,build,public ./build/index.js" + } +} +``` + +4. [Create a personal access token](https://dash.deno.com/account) for the Deno + Deploy API and export it as `DENO_DEPLOY_TOKEN`: + +```sh +export DENO_DEPLOY_TOKEN= +``` + +You may want to add this to your `rc` file (e.g. `.bashrc` or `.zshrc`) to make +it available for new terminal sessions, but make sure you don't commit this +token into `git`. If you want to use this token in GitHub Actions, set it as a +GitHub secret. + +5. Install the Deno Deploy CLI, + [`deployctl`](https://github.com/denoland/deployctl): + +```sh +deno install --allow-read --allow-write --allow-env --allow-net --allow-run --no-check -r -f https://deno.land/x/deploy/deployctl.ts +``` + +6. If you have previously installed the Deno Deploy CLI, you should update it to + the latest version: + +```sh +deployctl upgrade +``` + +### Deploying to Deno Deploy + +After you've set up Deno Deploy, run: + +```sh +npm run deploy +``` diff --git a/packages/remix-dev/__tests__/fixtures/deno/app/root.tsx b/packages/remix-dev/__tests__/fixtures/deno/app/root.tsx new file mode 100644 index 0000000000..60ddcdf8f8 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/app/root.tsx @@ -0,0 +1,34 @@ +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction } from "@remix-run/deno"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; +import * as React from "react"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/packages/remix-dev/__tests__/fixtures/deno/app/routes/_index.tsx b/packages/remix-dev/__tests__/fixtures/deno/app/routes/_index.tsx new file mode 100644 index 0000000000..2e62fab622 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/app/routes/_index.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import type { MetaFunction } from "@remix-run/deno"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} diff --git a/packages/remix-dev/__tests__/fixtures/deno/deno.json b/packages/remix-dev/__tests__/fixtures/deno/deno.json new file mode 100644 index 0000000000..67973e8d59 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/deno.json @@ -0,0 +1,6 @@ +{ + "importMap": ".vscode/resolve_npm_imports.json", + "compilerOptions": { + "lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"] + } +} diff --git a/packages/remix-dev/__tests__/fixtures/deno/package.json b/packages/remix-dev/__tests__/fixtures/deno/package.json new file mode 100644 index 0000000000..7a4554c403 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/package.json @@ -0,0 +1,30 @@ +{ + "private": true, + "sideEffects": false, + "scripts": { + "build": "remix build", + "deploy": "deployctl deploy --prod --include=build,public --project= ./build/index.js", + "dev": "npm-run-all build --parallel \"dev:*\"", + "dev:deno": "NODE_ENV=development deno run --unstable --watch --allow-net --allow-read --allow-env ./build/index.js", + "dev:remix": "remix watch", + "format": "deno fmt --ignore=node_modules,build,public/build", + "lint": "deno lint --ignore=node_modules,build,public/build", + "start": "NODE_ENV=production deno run --unstable --allow-net --allow-read --allow-env ./build/index.js", + "typecheck": "deno check" + }, + "dependencies": { + "@remix-run/css-bundle": "*", + "@remix-run/deno": "*", + "@remix-run/react": "*", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@remix-run/dev": "*", + "npm-run-all": "^4.1.5" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/remix-dev/__tests__/fixtures/deno/public/favicon.ico b/packages/remix-dev/__tests__/fixtures/deno/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/packages/remix-dev/__tests__/fixtures/deno/public/favicon.ico differ diff --git a/packages/remix-dev/__tests__/fixtures/deno/remix.config.js b/packages/remix-dev/__tests__/fixtures/deno/remix.config.js new file mode 100644 index 0000000000..cd3a46d297 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/remix.config.js @@ -0,0 +1,13 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + server: "./server.ts", + serverConditions: ["deno", "worker"], + serverDependenciesToBundle: "all", + serverMainFields: ["module", "main"], + serverModuleFormat: "esm", + serverPlatform: "neutral", + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", + // serverBuildPath: "build/index.js", +}; diff --git a/packages/remix-dev/__tests__/fixtures/deno/server.ts b/packages/remix-dev/__tests__/fixtures/deno/server.ts new file mode 100644 index 0000000000..a7caa47e20 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/deno/server.ts @@ -0,0 +1,13 @@ +import { serve } from "https://deno.land/std@0.128.0/http/server.ts"; +import { createRequestHandlerWithStaticFiles } from "@remix-run/deno"; +// Import path interpreted by the Remix compiler +import * as build from "@remix-run/dev/server-build"; + +const remixHandler = createRequestHandlerWithStaticFiles({ + build, + getLoadContext: () => ({}), +}); + +const port = Number(Deno.env.get("PORT")) || 8000; +console.log(`Listening on http://localhost:${port}`); +serve(remixHandler, { port }); diff --git a/packages/remix-dev/__tests__/fixtures/examples-main.tar.gz b/packages/remix-dev/__tests__/fixtures/examples-main.tar.gz new file mode 100644 index 0000000000..f2a7d82029 Binary files /dev/null and b/packages/remix-dev/__tests__/fixtures/examples-main.tar.gz differ diff --git a/packages/remix-dev/__tests__/fixtures/failing-remix-init/package.json b/packages/remix-dev/__tests__/fixtures/failing-remix-init/package.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/failing-remix-init/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/remix-dev/__tests__/fixtures/failing-remix-init/remix.init/index.js b/packages/remix-dev/__tests__/fixtures/failing-remix-init/remix.init/index.js new file mode 100644 index 0000000000..2dbd14d432 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/failing-remix-init/remix.init/index.js @@ -0,0 +1,3 @@ +module.exports = () => { + throw new Error("💣"); +}; diff --git a/packages/remix-dev/__tests__/fixtures/nested-dir-repo.tar.gz b/packages/remix-dev/__tests__/fixtures/nested-dir-repo.tar.gz new file mode 100644 index 0000000000..e0db0c2fc5 Binary files /dev/null and b/packages/remix-dev/__tests__/fixtures/nested-dir-repo.tar.gz differ diff --git a/packages/remix-dev/__tests__/fixtures/node/.gitignore b/packages/remix-dev/__tests__/fixtures/node/.gitignore new file mode 100644 index 0000000000..3f7bf98da3 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/node/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/packages/remix-dev/__tests__/fixtures/node/README.md b/packages/remix-dev/__tests__/fixtures/node/README.md new file mode 100644 index 0000000000..da8d02ad77 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/node/README.md @@ -0,0 +1,38 @@ +# Welcome to Remix! + +- [Remix Docs](https://remix.run/docs) + +## Development + +From your terminal: + +```sh +npm run dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `remix build` + +- `build/` +- `public/build/` diff --git a/packages/remix-dev/__tests__/fixtures/node/app/root.tsx b/packages/remix-dev/__tests__/fixtures/node/app/root.tsx new file mode 100644 index 0000000000..b46b8fb15b --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/node/app/root.tsx @@ -0,0 +1,33 @@ +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/packages/remix-dev/__tests__/fixtures/node/app/routes/_index.tsx b/packages/remix-dev/__tests__/fixtures/node/app/routes/_index.tsx new file mode 100644 index 0000000000..5347369230 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/node/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "@remix-run/node"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} diff --git a/packages/remix-dev/__tests__/fixtures/node/package.json b/packages/remix-dev/__tests__/fixtures/node/package.json new file mode 100644 index 0000000000..26280f9db3 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/node/package.json @@ -0,0 +1,30 @@ +{ + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "remix build", + "dev": "remix dev", + "start": "remix-serve build/index.js", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/css-bundle": "*", + "@remix-run/node": "*", + "@remix-run/react": "*", + "@remix-run/serve": "*", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@remix-run/dev": "*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "eslint": "^8.38.0", + "typescript": "^5.1.6" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/remix-dev/__tests__/fixtures/node/public/favicon.ico b/packages/remix-dev/__tests__/fixtures/node/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/packages/remix-dev/__tests__/fixtures/node/public/favicon.ico differ diff --git a/packages/remix-dev/__tests__/fixtures/node/remix.config.js b/packages/remix-dev/__tests__/fixtures/node/remix.config.js new file mode 100644 index 0000000000..015e2b7085 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/node/remix.config.js @@ -0,0 +1,7 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +export default { + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", + // serverBuildPath: "build/index.js", +}; diff --git a/packages/remix-dev/__tests__/fixtures/node/remix.env.d.ts b/packages/remix-dev/__tests__/fixtures/node/remix.env.d.ts new file mode 100644 index 0000000000..dcf8c45e1d --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/node/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/packages/remix-dev/__tests__/fixtures/node/tsconfig.json b/packages/remix-dev/__tests__/fixtures/node/tsconfig.json new file mode 100644 index 0000000000..28cce918b8 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/node/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/packages/remix-dev/__tests__/fixtures/remix-repo.tar.gz b/packages/remix-dev/__tests__/fixtures/remix-repo.tar.gz new file mode 100644 index 0000000000..e804a00ed5 Binary files /dev/null and b/packages/remix-dev/__tests__/fixtures/remix-repo.tar.gz differ diff --git a/packages/remix-dev/__tests__/fixtures/stack.tar.gz b/packages/remix-dev/__tests__/fixtures/stack.tar.gz new file mode 100644 index 0000000000..edde2637f0 Binary files /dev/null and b/packages/remix-dev/__tests__/fixtures/stack.tar.gz differ diff --git a/packages/remix-dev/__tests__/fixtures/stack/.gitignore b/packages/remix-dev/__tests__/fixtures/stack/.gitignore new file mode 100644 index 0000000000..3f7bf98da3 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/packages/remix-dev/__tests__/fixtures/stack/README.md b/packages/remix-dev/__tests__/fixtures/stack/README.md new file mode 100644 index 0000000000..da8d02ad77 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/README.md @@ -0,0 +1,38 @@ +# Welcome to Remix! + +- [Remix Docs](https://remix.run/docs) + +## Development + +From your terminal: + +```sh +npm run dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `remix build` + +- `build/` +- `public/build/` diff --git a/packages/remix-dev/__tests__/fixtures/stack/app/entry.client.tsx b/packages/remix-dev/__tests__/fixtures/stack/app/entry.client.tsx new file mode 100644 index 0000000000..3eec1fd0a0 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/app/entry.client.tsx @@ -0,0 +1,4 @@ +import { RemixBrowser } from "@remix-run/react"; +import { hydrate } from "react-dom"; + +hydrate(, document); diff --git a/packages/remix-dev/__tests__/fixtures/stack/app/entry.server.tsx b/packages/remix-dev/__tests__/fixtures/stack/app/entry.server.tsx new file mode 100644 index 0000000000..a80b466309 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/app/entry.server.tsx @@ -0,0 +1,23 @@ +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext +) { + // eslint-disable-next-line testing-library/render-result-naming-convention + let markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/packages/remix-dev/__tests__/fixtures/stack/app/root.tsx b/packages/remix-dev/__tests__/fixtures/stack/app/root.tsx new file mode 100644 index 0000000000..1fedc5b72b --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/app/root.tsx @@ -0,0 +1,25 @@ +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/packages/remix-dev/__tests__/fixtures/stack/app/utils.ts b/packages/remix-dev/__tests__/fixtures/stack/app/utils.ts new file mode 100644 index 0000000000..304bb4e19c --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/app/utils.ts @@ -0,0 +1 @@ +// this is a utility file diff --git a/packages/remix-dev/__tests__/fixtures/stack/package.json b/packages/remix-dev/__tests__/fixtures/stack/package.json new file mode 100644 index 0000000000..6b54cd33fd --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/package.json @@ -0,0 +1,15 @@ +{ + "name": "remix-template-remix", + "private": true, + "sideEffects": false, + "scripts": { + "build": "remix build", + "dev": "remix dev", + "start": "remix-serve build/index.js" + }, + "dependencies": {}, + "devDependencies": {}, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/remix-dev/__tests__/fixtures/stack/public/favicon.ico b/packages/remix-dev/__tests__/fixtures/stack/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/packages/remix-dev/__tests__/fixtures/stack/public/favicon.ico differ diff --git a/packages/remix-dev/__tests__/fixtures/stack/remix.config.js b/packages/remix-dev/__tests__/fixtures/stack/remix.config.js new file mode 100644 index 0000000000..9d355b29b3 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/remix.config.js @@ -0,0 +1,7 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", +}; diff --git a/packages/remix-dev/__tests__/fixtures/stack/remix.env.d.ts b/packages/remix-dev/__tests__/fixtures/stack/remix.env.d.ts new file mode 100644 index 0000000000..72e2affe31 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/packages/remix-dev/__tests__/fixtures/stack/remix.init/index.js b/packages/remix-dev/__tests__/fixtures/stack/remix.init/index.js new file mode 100644 index 0000000000..d3dd71ce24 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/remix.init/index.js @@ -0,0 +1 @@ +// this is the init file diff --git a/packages/remix-dev/__tests__/fixtures/stack/tsconfig.json b/packages/remix-dev/__tests__/fixtures/stack/tsconfig.json new file mode 100644 index 0000000000..e8cb841e3a --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/stack/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/packages/remix-dev/__tests__/fixtures/successful-remix-init/package.json b/packages/remix-dev/__tests__/fixtures/successful-remix-init/package.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/successful-remix-init/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/remix-dev/__tests__/fixtures/successful-remix-init/remix.init/index.js b/packages/remix-dev/__tests__/fixtures/successful-remix-init/remix.init/index.js new file mode 100644 index 0000000000..2523b045cc --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/successful-remix-init/remix.init/index.js @@ -0,0 +1,9 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +module.exports = ({ rootDirectory }) => { + fs.writeFileSync( + path.join(rootDirectory, "test.txt"), + "added via remix.init" + ); +}; diff --git a/packages/remix-dev/__tests__/fixtures/tar.js b/packages/remix-dev/__tests__/fixtures/tar.js new file mode 100644 index 0000000000..f59cb607f5 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/tar.js @@ -0,0 +1,21 @@ +const tar = require("tar-fs"); +const fs = require("node:fs"); +const path = require("node:path"); + +let files = fs.readdirSync(__dirname); +let dirs = files.filter((file) => + fs.statSync(path.join(__dirname, file)).isDirectory() +); + +for (let dir of dirs) { + let fullPath = path.join(__dirname, dir); + console.log(`Creating archive for ${fullPath}`); + tar + .pack(fullPath, { + map(header) { + header.name = dir + "/" + header.name; + return header; + }, + }) + .pipe(fs.createWriteStream(path.join(__dirname, `${dir}.tar.gz`))); +} diff --git a/packages/remix-dev/__tests__/flat-routes-test.ts b/packages/remix-dev/__tests__/flat-routes-test.ts new file mode 100644 index 0000000000..b1947554ec --- /dev/null +++ b/packages/remix-dev/__tests__/flat-routes-test.ts @@ -0,0 +1,891 @@ +import path from "node:path"; + +import { + flatRoutesUniversal, + getRoutePathConflictErrorMessage, + getRouteIdConflictErrorMessage, + getRouteSegments, +} from "../config/flat-routes"; +import type { ConfigRoute } from "../config/routes"; +import { normalizeSlashes } from "../config/routes"; + +let APP_DIR = path.join("test", "root", "app"); + +describe("flatRoutes", () => { + describe("creates proper route paths", () => { + let tests: [string, string | undefined][] = [ + ["routes.$", "routes/*"], + ["routes.sub.$", "routes/sub/*"], + ["routes.$slug", "routes/:slug"], + ["routes.sub.$slug", "routes/sub/:slug"], + ["$", "*"], + ["flat.$", "flat/*"], + ["$slug", ":slug"], + ["nested/index", "nested"], + ["nested.$", "*"], + ["nested.$slug", ":slug"], + ["nested._layout.$param", ":param"], + + ["flat.$slug", "flat/:slug"], + ["flat.sub", "flat/sub"], + ["flat._index", "flat"], + ["_index", undefined], + ["_layout/index", undefined], + ["_layout.test", "test"], + ["_layout.$param", ":param"], + ["$slug[.]json", ":slug.json"], + ["sub.[sitemap.xml]", "sub/sitemap.xml"], + ["posts.$slug.[image.jpg]", "posts/:slug/image.jpg"], + ["sub.[[]", "sub/["], + ["sub.]", "sub/]"], + ["sub.[[]]", "sub/[]"], + ["beef]", "beef]"], + ["[index]", "index"], + ["test.inde[x]", "test/index"], + ["[i]ndex.[[].[[]]", "index/[/[]"], + + // Optional segment routes + ["(routes).$", "routes?/*"], + ["(routes).(sub).$", "routes?/sub?/*"], + ["(routes).($slug)", "routes?/:slug?"], + ["(routes).sub.($slug)", "routes?/sub/:slug?"], + ["(nested).$", "nested?/*"], + ["(flat).$", "flat?/*"], + ["($slug)", ":slug?"], + ["(nested).($slug)", "nested?/:slug?"], + ["(flat).($slug)", "flat?/:slug?"], + ["flat.(sub)", "flat/sub?"], + ["_layout.(test)", "test?"], + ["_layout.($user)", ":user?"], + ["(nested)._layout.($param)", "nested?/:param?"], + ["($slug[.]json)", ":slug.json?"], + ["(sub).([sitemap.xml])", "sub?/sitemap.xml?"], + ["(sub).[(sitemap.xml)]", "sub?/(sitemap.xml)"], + ["(posts).($slug).([image.jpg])", "posts?/:slug?/image.jpg?"], + [ + "($[$dollabills]).([.]lol).(what).([$]).($up)", + ":$dollabills?/.lol?/what?/$?/:up?", + ], + ["(sub).(])", "sub?/]?"], + ["(sub).([[]])", "sub?/[]?"], + ["(sub).([[])", "sub?/[?"], + ["(beef])", "beef]?"], + ["([index])", "index?"], + ["(test).(inde[x])", "test?/index?"], + ["([i]ndex).([[]).([[]])", "index?/[?/[]?"], + + // Opting out of parent layout + ["user_.projects.$id.roadmap", "user/projects/:id/roadmap"], + ["app.projects_.$id.roadmap", "app/projects/:id/roadmap"], + ["shop_.projects_.$id.roadmap", "shop/projects/:id/roadmap"], + ]; + + let manifest = flatRoutesUniversal( + APP_DIR, + tests.map((t) => path.join(APP_DIR, "routes", t[0] + ".tsx")) + ); + + for (let [input, expected] of tests) { + it(`"${input}" -> "${expected}"`, () => { + if (input.endsWith("/route") || input.endsWith("/index")) { + input = input.replace(/\/(route|index)$/, ""); + } + let routeInfo = manifest[path.posix.join("routes", input)]; + expect(routeInfo.path).toBe(expected); + }); + } + + let invalidSlashFiles = [ + "($[$dollabills]).([.]lol)[/](what)/([$]).$", + "$[$dollabills].[.]lol[/]what/[$].$", + ]; + + for (let invalid of invalidSlashFiles) { + test("should error when using `/` in a route segment", () => { + let regex = new RegExp( + /Route segment (".*?") for (".*?") cannot contain "\/"/ + ); + expect(() => getRouteSegments(invalid)).toThrow(regex); + }); + } + + let invalidSplatFiles: string[] = [ + "routes/about.[*].tsx", + "routes/about.*.tsx", + "routes/about.[.[.*].].tsx", + ]; + + for (let invalid of invalidSplatFiles) { + test("should error when using `*` in a route segment", () => { + let regex = new RegExp( + /Route segment (".*?") for (".*?") cannot contain "\*"/ + ); + expect(() => getRouteSegments(invalid)).toThrow(regex); + }); + } + + let invalidParamFiles: string[] = [ + "routes/about.[:name].tsx", + "routes/about.:name.tsx", + ]; + + for (let invalid of invalidParamFiles) { + test("should error when using `:` in a route segment", () => { + let regex = new RegExp( + /Route segment (".*?") for (".*?") cannot contain ":"/ + ); + expect(() => getRouteSegments(invalid)).toThrow(regex); + }); + } + }); + + describe("should return the correct route hierarchy", () => { + // we'll add file manually before running the tests + let testFiles: [string, Omit][] = [ + [ + "routes/_auth.tsx", + { + id: "routes/_auth", + parentId: "root", + path: undefined, + }, + ], + [ + "routes/_auth.forgot-password.tsx", + { + id: "routes/_auth.forgot-password", + parentId: "routes/_auth", + path: "forgot-password", + }, + ], + [ + "routes/_auth.login.tsx", + { + id: "routes/_auth.login", + parentId: "routes/_auth", + path: "login", + }, + ], + [ + "routes/_auth.reset-password.tsx", + { + id: "routes/_auth.reset-password", + parentId: "routes/_auth", + path: "reset-password", + }, + ], + [ + "routes/_auth.signup.tsx", + { + id: "routes/_auth.signup", + parentId: "routes/_auth", + path: "signup", + }, + ], + [ + "routes/_landing/index.tsx", + { + id: "routes/_landing", + parentId: "root", + path: undefined, + }, + ], + [ + "routes/_landing._index/index.tsx", + { + id: "routes/_landing._index", + parentId: "routes/_landing", + path: undefined, + index: true, + }, + ], + [ + "routes/_landing.index.tsx", + { + id: "routes/_landing.index", + parentId: "routes/_landing", + path: "index", + }, + ], + [ + "routes/_about.tsx", + { + id: "routes/_about", + parentId: "root", + path: undefined, + }, + ], + [ + "routes/_about.faq.tsx", + { + id: "routes/_about.faq", + parentId: "routes/_about", + path: "faq", + }, + ], + [ + "routes/_about.$splat.tsx", + { + id: "routes/_about.$splat", + parentId: "routes/_about", + path: ":splat", + }, + ], + [ + "routes/app.tsx", + { + id: "routes/app", + parentId: "root", + path: "app", + }, + ], + [ + "routes/app.calendar.$day.tsx", + { + id: "routes/app.calendar.$day", + parentId: "routes/app", + path: "calendar/:day", + }, + ], + [ + "routes/app.calendar._index.tsx", + { + id: "routes/app.calendar._index", + index: true, + parentId: "routes/app", + path: "calendar", + }, + ], + [ + "routes/app.projects.tsx", + { + id: "routes/app.projects", + parentId: "routes/app", + path: "projects", + }, + ], + [ + "routes/app.projects.$id.tsx", + { + id: "routes/app.projects.$id", + parentId: "routes/app.projects", + path: ":id", + }, + ], + [ + "routes/app._pathless.tsx", + { + id: "routes/app._pathless", + parentId: "routes/app", + path: undefined, + }, + ], + [ + "routes/app._pathless._index.tsx", + { + id: "routes/app._pathless._index", + parentId: "routes/app._pathless", + index: true, + path: undefined, + }, + ], + [ + "routes/app._pathless.child.tsx", + { + id: "routes/app._pathless.child", + parentId: "routes/app._pathless", + path: "child", + }, + ], + [ + "routes/folder/route.tsx", + { + id: "routes/folder", + parentId: "root", + path: "folder", + }, + ], + [ + "routes/[route].tsx", + { + id: "routes/[route]", + parentId: "root", + path: "route", + }, + ], + + // Opt out of parent layout + [ + "routes/app_.projects.$id.roadmap[.pdf].tsx", + { + id: "routes/app_.projects.$id.roadmap[.pdf]", + parentId: "root", + path: "app/projects/:id/roadmap.pdf", + }, + ], + [ + "routes/app_.projects.$id.roadmap.tsx", + { + id: "routes/app_.projects.$id.roadmap", + parentId: "root", + path: "app/projects/:id/roadmap", + }, + ], + + [ + "routes/app.skip.tsx", + { + id: "routes/app.skip", + parentId: "routes/app", + path: "skip", + }, + ], + [ + "routes/app.skip_.layout.tsx", + { + id: "routes/app.skip_.layout", + index: undefined, + parentId: "routes/app", + path: "skip/layout", + }, + ], + + [ + "routes/app_.skipall_._index.tsx", + { + id: "routes/app_.skipall_._index", + index: true, + parentId: "root", + path: "app/skipall", + }, + ], + + // Escaping route segments + [ + "routes/_about.[$splat].tsx", + { + id: "routes/_about.[$splat]", + parentId: "routes/_about", + path: "$splat", + }, + ], + [ + "routes/_about.[[].tsx", + { + id: "routes/_about.[[]", + parentId: "routes/_about", + path: "[", + }, + ], + [ + "routes/_about.[]].tsx", + { + id: "routes/_about.[]]", + parentId: "routes/_about", + path: "]", + }, + ], + [ + "routes/_about.[.].tsx", + { + id: "routes/_about.[.]", + parentId: "routes/_about", + path: ".", + }, + ], + + // Optional route segments + [ + "routes/(nested)._layout.($slug).tsx", + { + id: "routes/(nested)._layout.($slug)", + parentId: "root", + path: "nested?/:slug?", + }, + ], + [ + "routes/(routes).$.tsx", + { + id: "routes/(routes).$", + parentId: "root", + path: "routes?/*", + }, + ], + [ + "routes/(routes).(sub).$.tsx", + { + id: "routes/(routes).(sub).$", + parentId: "root", + path: "routes?/sub?/*", + }, + ], + [ + "routes/(routes).($slug).tsx", + { + id: "routes/(routes).($slug)", + parentId: "root", + path: "routes?/:slug?", + }, + ], + [ + "routes/(routes).sub.($slug).tsx", + { + id: "routes/(routes).sub.($slug)", + parentId: "root", + path: "routes?/sub/:slug?", + }, + ], + [ + "routes/(nested).$.tsx", + { + id: "routes/(nested).$", + parentId: "root", + path: "nested?/*", + }, + ], + [ + "routes/(flat).$.tsx", + { + id: "routes/(flat).$", + parentId: "root", + path: "flat?/*", + }, + ], + [ + "routes/(flat).($slug).tsx", + { + id: "routes/(flat).($slug)", + parentId: "root", + path: "flat?/:slug?", + }, + ], + [ + "routes/flat.(sub).tsx", + { + id: "routes/flat.(sub)", + parentId: "root", + path: "flat/sub?", + }, + ], + [ + "routes/_layout.tsx", + { + id: "routes/_layout", + parentId: "root", + path: undefined, + }, + ], + [ + "routes/_layout.(test).tsx", + { + id: "routes/_layout.(test)", + parentId: "routes/_layout", + path: "test?", + }, + ], + [ + "routes/_layout.($slug).tsx", + { + id: "routes/_layout.($slug)", + parentId: "routes/_layout", + path: ":slug?", + }, + ], + + // Optional + escaped route segments + [ + "routes/([_index]).tsx", + { + id: "routes/([_index])", + parentId: "root", + path: "_index?", + }, + ], + [ + "routes/(_[i]ndex).([[]).([[]]).tsx", + { + id: "routes/(_[i]ndex).([[]).([[]])", + parentId: "root", + path: "_index?/[?/[]?", + }, + ], + [ + "routes/(sub).([[]).tsx", + { + id: "routes/(sub).([[])", + parentId: "root", + path: "sub?/[?", + }, + ], + [ + "routes/(sub).(]).tsx", + { + id: "routes/(sub).(])", + parentId: "root", + path: "sub?/]?", + }, + ], + [ + "routes/(sub).([[]]).tsx", + { + id: "routes/(sub).([[]])", + parentId: "root", + path: "sub?/[]?", + }, + ], + [ + "routes/(beef]).tsx", + { + id: "routes/(beef])", + parentId: "root", + path: "beef]?", + }, + ], + [ + "routes/(test).(inde[x]).tsx", + { + id: "routes/(test).(inde[x])", + parentId: "root", + path: "test?/index?", + }, + ], + [ + "routes/($[$dollabills]).([.]lol).(what).([$]).($up).tsx", + { + id: "routes/($[$dollabills]).([.]lol).(what).([$]).($up)", + parentId: "root", + path: ":$dollabills?/.lol?/what?/$?/:up?", + }, + ], + [ + "routes/(posts).($slug).([image.jpg]).tsx", + { + id: "routes/(posts).($slug).([image.jpg])", + parentId: "root", + path: "posts?/:slug?/image.jpg?", + }, + ], + [ + "routes/(sub).([sitemap.xml]).tsx", + { + id: "routes/(sub).([sitemap.xml])", + parentId: "root", + path: "sub?/sitemap.xml?", + }, + ], + [ + "routes/(sub).[(sitemap.xml)].tsx", + { + id: "routes/(sub).[(sitemap.xml)]", + parentId: "root", + path: "sub?/(sitemap.xml)", + }, + ], + [ + "routes/($slug[.]json).tsx", + { + id: "routes/($slug[.]json)", + parentId: "root", + path: ":slug.json?", + }, + ], + + [ + "routes/[]otherstuff].tsx", + { + id: "routes/[]otherstuff]", + parentId: "root", + path: "otherstuff]", + }, + ], + [ + "routes/brand.tsx", + { + id: "routes/brand", + parentId: "root", + path: "brand", + }, + ], + [ + "routes/brand._index.tsx", + { + id: "routes/brand._index", + parentId: "routes/brand", + index: true, + }, + ], + [ + "routes/$.tsx", + { + id: "routes/$", + parentId: "root", + path: "*", + }, + ], + ]; + + let files: [string, ConfigRoute][] = testFiles.map(([file, route]) => { + return [file, { ...route, file }]; + }); + + let routeManifest = flatRoutesUniversal( + APP_DIR, + files.map(([file]) => path.join(APP_DIR, file)) + ); + let routes = Object.values(routeManifest); + + expect(routes).toHaveLength(files.length); + + for (let [file, route] of files) { + test(`hierarchy for ${file} - ${route.path}`, () => { + expect(routes).toContainEqual(route); + }); + } + }); + + describe("doesn't warn when there's not a route collision", () => { + let consoleError = jest + .spyOn(global.console, "error") + .mockImplementation(() => {}); + + afterEach(consoleError.mockReset); + + test("same number of segments and the same dynamic segment index", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "_user.$username.tsx"), + path.join(APP_DIR, "routes", "sneakers.$sneakerId.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(routes).toHaveLength(testFiles.length); + expect(consoleError).not.toHaveBeenCalled(); + }); + }); + + describe("warns when there's a route collision", () => { + let consoleError = jest + .spyOn(global.console, "error") + .mockImplementation(() => {}); + + afterEach(consoleError.mockReset); + + test("index files", () => { + let testFiles = [ + path.join("routes", "_dashboard._index.tsx"), + path.join("routes", "_landing._index.tsx"), + path.join("routes", "_index.tsx"), + ]; + + // route manifest uses the full path + let fullPaths = testFiles.map((file) => path.join(APP_DIR, file)); + + // this is for the expected error message, + // which uses the relative path from the app directory internally + let normalizedTestFiles = testFiles.map((file) => normalizeSlashes(file)); + + let routeManifest = flatRoutesUniversal(APP_DIR, fullPaths); + + let routes = Object.values(routeManifest); + + expect(routes).toHaveLength(1); + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/", normalizedTestFiles) + ); + }); + + test("folder/route.tsx matching folder.tsx", () => { + let testFiles = [ + path.join("routes", "dashboard", "route.tsx"), + path.join("routes", "dashboard.tsx"), + ]; + + // route manifest uses the full path + let fullPaths = testFiles.map((file) => path.join(APP_DIR, file)); + + // this is for the expected error message, + // which uses the relative path from the app directory internally + let normalizedTestFiles = testFiles.map((file) => normalizeSlashes(file)); + + let routeManifest = flatRoutesUniversal(APP_DIR, fullPaths); + + let routes = Object.values(routeManifest); + + expect(routes).toHaveLength(1); + expect(consoleError).toHaveBeenCalledWith( + getRouteIdConflictErrorMessage( + path.posix.join("routes", "dashboard"), + normalizedTestFiles + ) + ); + }); + + test.skip("same path, different param name", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "products.$pid.tsx"), + path.join(APP_DIR, "routes", "products.$productId.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(routes).toHaveLength(1); + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/products/:pid", testFiles) + ); + }); + + test("pathless layouts should not collide", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "_a.tsx"), + path.join(APP_DIR, "routes", "_a._index.tsx"), + path.join(APP_DIR, "routes", "_a.a.tsx"), + path.join(APP_DIR, "routes", "_b.tsx"), + path.join(APP_DIR, "routes", "_b.b.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(consoleError).not.toHaveBeenCalled(); + expect(routes).toHaveLength(5); + + // When using folders and route.tsx files + testFiles = [ + path.join(APP_DIR, "routes", "_a", "route.tsx"), + path.join(APP_DIR, "routes", "_a._index", "route.tsx"), + path.join(APP_DIR, "routes", "_a.a", "route.tsx"), + path.join(APP_DIR, "routes", "_b", "route.tsx"), + path.join(APP_DIR, "routes", "_b.b", "route.tsx"), + ]; + + routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + routes = Object.values(routeManifest); + + expect(consoleError).not.toHaveBeenCalled(); + expect(routes).toHaveLength(5); + }); + + test("nested pathless layouts should not collide", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "nested._a.tsx"), + path.join(APP_DIR, "routes", "nested._a._index.tsx"), + path.join(APP_DIR, "routes", "nested._a.a.tsx"), + path.join(APP_DIR, "routes", "nested._b.tsx"), + path.join(APP_DIR, "routes", "nested._b.b.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(consoleError).not.toHaveBeenCalled(); + expect(routes).toHaveLength(5); + + // When using folders and route.tsx files + testFiles = [ + path.join(APP_DIR, "routes", "nested._a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._a._index", "route.tsx"), + path.join(APP_DIR, "routes", "nested._a.a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b.b", "route.tsx"), + ]; + + routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + routes = Object.values(routeManifest); + + expect(consoleError).not.toHaveBeenCalled(); + expect(routes).toHaveLength(5); + }); + + test("legit collisions without nested pathless layouts should collide (paths)", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "nested._a.tsx"), + path.join(APP_DIR, "routes", "nested._a.a.tsx"), + path.join(APP_DIR, "routes", "nested._b.tsx"), + path.join(APP_DIR, "routes", "nested._b.a.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/nested/a", [ + "routes/nested._a.a.tsx", + "routes/nested._b.a.tsx", + ]) + ); + expect(routes).toHaveLength(3); + + // When using folders and route.tsx files + consoleError.mockClear(); + testFiles = [ + path.join(APP_DIR, "routes", "nested._a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._a.a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b.a", "route.tsx"), + ]; + + routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + routes = Object.values(routeManifest); + + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/nested/a", [ + "routes/nested._a.a/route.tsx", + "routes/nested._b.a/route.tsx", + ]) + ); + expect(routes).toHaveLength(3); + }); + + test("legit collisions without nested pathless layouts should collide (index routes)", () => { + let testFiles = [ + path.join(APP_DIR, "routes", "nested._a.tsx"), + path.join(APP_DIR, "routes", "nested._a._index.tsx"), + path.join(APP_DIR, "routes", "nested._b.tsx"), + path.join(APP_DIR, "routes", "nested._b._index.tsx"), + ]; + + let routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + let routes = Object.values(routeManifest); + + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/nested", [ + "routes/nested._a._index.tsx", + "routes/nested._b._index.tsx", + ]) + ); + expect(routes).toHaveLength(3); + + // When using folders and route.tsx files + consoleError.mockClear(); + testFiles = [ + path.join(APP_DIR, "routes", "nested._a", "route.tsx"), + path.join(APP_DIR, "routes", "nested._a._index", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b", "route.tsx"), + path.join(APP_DIR, "routes", "nested._b._index", "route.tsx"), + ]; + + routeManifest = flatRoutesUniversal(APP_DIR, testFiles); + + routes = Object.values(routeManifest); + + expect(consoleError).toHaveBeenCalledWith( + getRoutePathConflictErrorMessage("/nested", [ + "routes/nested._a._index/route.tsx", + "routes/nested._b._index/route.tsx", + ]) + ); + expect(routes).toHaveLength(3); + }); + }); +}); diff --git a/packages/remix-dev/__tests__/github-mocks.ts b/packages/remix-dev/__tests__/github-mocks.ts new file mode 100644 index 0000000000..69e4574a66 --- /dev/null +++ b/packages/remix-dev/__tests__/github-mocks.ts @@ -0,0 +1,358 @@ +import * as path from "node:path"; +import fsp from "node:fs/promises"; +import invariant from "tiny-invariant"; +import type { setupServer } from "msw/node"; +import { rest } from "msw"; + +type RequestHandler = Parameters[0]; + +async function isDirectory(d: string) { + try { + return (await fsp.lstat(d)).isDirectory(); + } catch { + return false; + } +} +async function isFile(d: string) { + try { + return (await fsp.lstat(d)).isFile(); + } catch { + return false; + } +} + +type GHContentsDescription = { + name: string; + path: string; + sha: string; + size: number; + url: string; + html_url: string; + git_url: string; + download_url: string | null; + type: "dir" | "file"; + _links: { + self: string; + git: string; + html: string; + }; +}; + +type GHContent = { + sha: string; + node_id: string; + size: number; + url: string; + content: string; + encoding: "base64"; +}; + +type ResponseResolver = Parameters[1]; + +let sendTarball: ResponseResolver = async (req, res, ctx) => { + let { owner, repo } = req.params; + invariant(typeof owner === "string", "owner must be a string"); + invariant(typeof repo === "string", "repo must be a string"); + + let pathToTarball: string; + if (owner === "remix-run" && repo === "examples") { + pathToTarball = path.join(__dirname, "fixtures", "examples-main.tar.gz"); + } else if (owner === "remix-run" && repo === "remix") { + pathToTarball = path.join(__dirname, "fixtures", "remix-repo.tar.gz"); + } else if (owner === "fake-remix-tester" && repo === "nested-dir") { + pathToTarball = path.join(__dirname, "fixtures", "nested-dir-repo.tar.gz"); + } else { + pathToTarball = path.join(__dirname, "fixtures", "stack.tar.gz"); + } + + let fileBuffer = await fsp.readFile(pathToTarball); + + return res( + ctx.body(fileBuffer), + ctx.set("Content-Type", "application/x-gzip") + ); +}; + +let githubHandlers: Array = [ + rest.head( + `https://github.com/remix-run/remix/tree/main/:type/:name`, + async (_req, res, ctx) => { + return res(ctx.status(200)); + } + ), + rest.head( + `https://github.com/remix-run/examples/tree/main/:type/:name`, + async (_req, res, ctx) => { + return res(ctx.status(200)); + } + ), + rest.head( + `https://github.com/error-username/:status`, + async (req, res, ctx) => { + return res(ctx.status(Number(req.params.status))); + } + ), + rest.head(`https://github.com/:owner/:repo`, async (req, res, ctx) => { + return res(ctx.status(200)); + }), + rest.head( + `https://api.github.com/repos/error-username/:status`, + async (req, res, ctx) => { + return res(ctx.status(Number(req.params.status))); + } + ), + rest.head( + `https://api.github.com/repos/private-org/private-repo`, + async (req, res, ctx) => { + let status = + req.headers.get("Authorization") === "token valid-token" ? 200 : 404; + return res(ctx.status(status)); + } + ), + rest.head( + `https://api.github.com/repos/:owner/:repo`, + async (req, res, ctx) => { + return res(ctx.status(200)); + } + ), + rest.head( + `https://github.com/:owner/:repo/tree/:branch/:path*`, + async (req, res, ctx) => { + return res(ctx.status(200)); + } + ), + rest.get( + `https://api.github.com/repos/:owner/:repo/git/trees/:branch`, + async (req, res, ctx) => { + let { owner, repo } = req.params; + + return res( + ctx.status(200), + ctx.json({ + sha: "7d906ff5bbb79401a4a8ec1e1799845ed502c0a1", + url: `https://api.github.com/repos/${owner}/${repo}/trees/7d906ff5bbb79401a4a8ec1e1799845ed502c0a1`, + tree: [ + { + path: "package.json", + mode: "040000", + type: "blob", + sha: "a405cd8355516db9c96e1467fb14b74c97ac0a65", + size: 138, + url: `https://api.github.com/repos/${owner}/${repo}/git/blobs/a405cd8355516db9c96e1467fb14b74c97ac0a65`, + }, + { + path: "stack", + mode: "040000", + type: "tree", + sha: "3f350a670e8fefd58535a9e1878539dc19afb4b5", + url: `https://api.github.com/repos/${owner}/${repo}/trees/3f350a670e8fefd58535a9e1878539dc19afb4b5`, + }, + ], + }) + ); + } + ), + rest.get( + `https://api.github.com/repos/:owner/:repo/contents/:path`, + async (req, res, ctx) => { + let { owner, repo } = req.params; + if (typeof req.params.path !== "string") { + throw new Error("req.params.path must be a string"); + } + let path = decodeURIComponent(req.params.path).trim(); + let isMockable = owner === "remix-run" && repo === "remix"; + + if (!isMockable) { + let message = `Attempting to get content description for unmockable resource: ${owner}/${repo}/${path}`; + console.error(message); + throw new Error(message); + } + + let localPath = path.join(__dirname, "../../..", path); + let isLocalDir = await isDirectory(localPath); + let isLocalFile = await isFile(localPath); + + if (!isLocalDir && !isLocalFile) { + return res( + ctx.status(404), + ctx.json({ + message: "Not Found", + documentation_url: + "https://docs.github.com/rest/reference/repos#get-repository-content", + }) + ); + } + + if (isLocalFile) { + let encoding = "base64" as const; + let content = await fsp.readFile(localPath, { encoding: "utf-8" }); + return res( + ctx.status(200), + ctx.json({ + content: Buffer.from(content, "utf-8").toString(encoding), + encoding, + }) + ); + } + + let dirList = await fsp.readdir(localPath); + + let contentDescriptions = await Promise.all( + dirList.map(async (name): Promise => { + let relativePath = path.join(path, name); + // NOTE: this is a cheat-code so we don't have to determine the sha of the file + // and our sha endpoint handler doesn't have to do a reverse-lookup. + let sha = relativePath; + let fullPath = path.join(localPath, name); + let isDir = await isDirectory(fullPath); + let size = isDir ? 0 : (await fsp.stat(fullPath)).size; + return { + name, + path: relativePath, + sha, + size, + url: `https://api.github.com/repos/${owner}/${repo}/contents/${path}?${req.url.searchParams}`, + html_url: `https://github.com/${owner}/${repo}/tree/main/${path}`, + git_url: `https://api.github.com/repos/${owner}/${repo}/git/trees/${sha}`, + download_url: null, + type: isDir ? "dir" : "file", + _links: { + self: `https://api.github.com/repos/${owner}/${repo}/contents/${path}${req.url.searchParams}`, + git: `https://api.github.com/repos/${owner}/${repo}/git/trees/${sha}`, + html: `https://github.com/${owner}/${repo}/tree/main/${path}`, + }, + }; + }) + ); + + return res(ctx.json(contentDescriptions)); + } + ), + rest.get( + `https://api.github.com/repos/:owner/:repo/git/blobs/:sha`, + async (req, res, ctx) => { + let { owner, repo } = req.params; + if (typeof req.params.sha !== "string") { + throw new Error("req.params.sha must be a string"); + } + let sha = decodeURIComponent(req.params.sha).trim(); + // if the sha includes a "/" that means it's not a sha but a relativePath + // and therefore the client is getting content it got from the local + // mock environment, not the actual github API. + if (!sha.includes("/")) { + let message = `Attempting to get content for sha, but no sha exists locally: ${sha}`; + console.error(message); + throw new Error(message); + } + + // NOTE: we cheat a bit and in the contents/:path handler, we set the sha to the relativePath + let relativePath = sha; + let fullPath = path.join(__dirname, "..", relativePath); + let encoding = "base64" as const; + let size = (await fsp.stat(fullPath)).size; + let content = await fsp.readFile(fullPath, { encoding: "utf-8" }); + + let resource: GHContent = { + sha, + node_id: `${sha}_node_id`, + size, + url: `https://api.github.com/repos/${owner}/${repo}/git/blobs/${sha}`, + content: Buffer.from(content, "utf-8").toString(encoding), + encoding, + }; + + return res(ctx.json(resource)); + } + ), + rest.get( + `https://api.github.com/repos/:owner/:repo/contents/:path*`, + async (req, res, ctx) => { + let { owner, repo } = req.params; + + let relativePath = req.params.path; + if (typeof relativePath !== "string") { + throw new Error("req.params.path must be a string"); + } + let fullPath = path.join(__dirname, "..", relativePath); + let encoding = "base64" as const; + let size = (await fsp.stat(fullPath)).size; + let content = await fsp.readFile(fullPath, { encoding: "utf-8" }); + let sha = `${relativePath}_sha`; + + let resource: GHContent = { + sha, + node_id: `${req.params.path}_node_id`, + size, + url: `https://api.github.com/repos/${owner}/${repo}/git/blobs/${sha}`, + content: Buffer.from(content, "utf-8").toString(encoding), + encoding, + }; + + return res(ctx.json(resource)); + } + ), + rest.get( + `https://codeload.github.com/private-org/private-repo/tar.gz/:branch`, + (req, res, ctx) => { + if (req.headers.get("Authorization") !== "token valid-token") { + return res(ctx.status(404)); + } + req.params.owner = "private-org"; + req.params.repo = "private-repo"; + return sendTarball(req, res, ctx); + } + ), + rest.get( + `https://codeload.github.com/:owner/:repo/tar.gz/:branch`, + sendTarball + ), + rest.get( + `https://api.github.com/repos/private-org/private-repo/tarball`, + (req, res, ctx) => { + if (req.headers.get("Authorization") !== "token valid-token") { + return res(ctx.status(404)); + } + req.params.owner = "private-org"; + req.params.repo = "private-repo"; + + return sendTarball(req, res, ctx); + } + ), + rest.get( + `https://api.github.com/repos/private-org/private-repo/releases/tags/:tag`, + (req, res, ctx) => { + if (req.headers.get("Authorization") !== "token valid-token") { + return res(ctx.status(404)); + } + let { tag } = req.params; + return res( + ctx.status(200), + ctx.json({ + assets: [ + { + browser_download_url: `https://github.com/private-org/private-repo/releases/download/${tag}/stack.tar.gz`, + id: "working-asset-id", + }, + ], + }) + ); + } + ), + rest.get( + `https://api.github.com/repos/private-org/private-repo/releases/assets/working-asset-id`, + (req, res, ctx) => { + if (req.headers.get("Authorization") !== "token valid-token") { + return res(ctx.status(404)); + } + req.params.owner = "private-org"; + req.params.repo = "private-repo"; + return sendTarball(req, res, ctx); + } + ), + rest.get(`https://api.github.com/repos/:owner/:repo/tarball`, sendTarball), + rest.get(`https://api.github.com/repos/:repo*`, async (req, res, ctx) => { + return res(ctx.json({ default_branch: "main" })); + }), +]; + +export { githubHandlers }; diff --git a/packages/remix-dev/__tests__/init-test.ts b/packages/remix-dev/__tests__/init-test.ts new file mode 100644 index 0000000000..4a65e06901 --- /dev/null +++ b/packages/remix-dev/__tests__/init-test.ts @@ -0,0 +1,152 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import fse from "fs-extra"; +import stripAnsi from "strip-ansi"; + +import { run } from "../cli/run"; + +// this is so we can mock execSync for "npm install" and the like +jest.mock("child_process", () => { + let cp = jest.requireActual( + "child_process" + ) as typeof import("child_process"); + let installDepsCmdPattern = /^(npm|yarn|pnpm) install$/; + let configGetCmdPattern = /^(npm|yarn|pnpm) config get/; + + return { + ...cp, + execSync: jest.fn( + (command: string, options: Parameters[1]) => { + // this prevents us from having to run the install process + // and keeps our console output clean + if ( + installDepsCmdPattern.test(command) || + configGetCmdPattern.test(command) + ) { + return "sample stdout"; + } + return cp.execSync(command, options); + } + ), + }; +}); + +const TEMP_DIR = path.join( + fse.realpathSync(os.tmpdir()), + `remix-tests-${Math.random().toString(32).slice(2)}` +); + +beforeAll(async () => { + await fse.remove(TEMP_DIR); + await fse.ensureDir(TEMP_DIR); +}); + +afterAll(async () => { + await fse.remove(TEMP_DIR); +}); + +let output: string; +let originalLog = console.log; +let originalWarn = console.warn; +let originalError = console.error; + +beforeEach(async () => { + output = ""; + function hijackLog(message: unknown = "", ...rest: Array) { + // if you need to debug stuff, then use: + // console.log('debug:', 'whatever you need to say'); + if (typeof message === "string" && message.startsWith("debug:")) { + return originalLog(message, ...rest); + } + let messageString = + typeof message === "string" ? message : JSON.stringify(message, null, 2); + if (rest[0]) { + throw new Error( + "Our tests are not set up to handle multiple arguments to console.log." + ); + } + output += "\n" + stripAnsi(messageString).replace(TEMP_DIR, ""); + } + console.log = hijackLog; + console.warn = hijackLog; + console.error = hijackLog; +}); + +afterEach(() => { + console.log = originalLog; + console.warn = originalWarn; + console.error = originalError; +}); + +describe("the init command", () => { + let tempDirs = new Set(); + let originalCwd = process.cwd(); + + beforeEach(() => { + process.chdir(TEMP_DIR); + jest.clearAllMocks(); + }); + + afterEach(async () => { + process.chdir(originalCwd); + for (let dir of tempDirs) { + await fse.remove(dir); + } + tempDirs = new Set(); + }); + + async function getProjectDir(name: string) { + let tmpDir = path.join(TEMP_DIR, name); + tempDirs.add(tmpDir); + return tmpDir; + } + + it("runs remix.init script when using `remix init`", async () => { + let projectDir = await getProjectDir("remix-init-manual"); + + fse.copySync( + path.join(__dirname, "fixtures", "successful-remix-init"), + projectDir + ); + process.chdir(path.join(projectDir)); + await run(["init"]); + + expect(output).toBe(""); + expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeFalsy(); + }); + + it("It keeps the `remix.init` script when using the `--no-delete` flag", async () => { + let projectDir = await getProjectDir("remix-init-manual"); + + fse.copySync( + path.join(__dirname, "fixtures", "successful-remix-init"), + projectDir + ); + process.chdir(projectDir); + await run(["init", "--no-delete"]); + + expect(output).toBe(""); + expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); + }); + + it("throws an error when invalid remix.init script when manually ran", async () => { + let projectDir = await getProjectDir("invalid-remix-init-manual"); + + fse.copySync( + path.join(__dirname, "fixtures", "failing-remix-init"), + projectDir + ); + process.chdir(projectDir); + await expect(run(["init"])).rejects.toThrowError( + `🚨 Oops, remix.init failed` + ); + // we should keep remix.init around if the init script fails + expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); + }); +}); + +/* +eslint + @typescript-eslint/consistent-type-imports: "off", +*/ diff --git a/packages/remix-dev/__tests__/msw.ts b/packages/remix-dev/__tests__/msw.ts new file mode 100644 index 0000000000..36915d39dd --- /dev/null +++ b/packages/remix-dev/__tests__/msw.ts @@ -0,0 +1,33 @@ +import path from "node:path"; +import fsp from "node:fs/promises"; +import { setupServer } from "msw/node"; +import { rest } from "msw"; + +import { githubHandlers } from "./github-mocks"; + +type RequestHandler = Parameters[0]; + +let miscHandlers: Array = [ + rest.head( + "https://example.com/error/:status/remix-stack.tar.gz", + async (req, res, ctx) => { + return res(ctx.status(Number(req.params.status))); + } + ), + rest.head("https://example.com/remix-stack.tar.gz", async (req, res, ctx) => { + return res(ctx.status(200)); + }), + rest.get("https://example.com/remix-stack.tar.gz", async (req, res, ctx) => { + let fileBuffer = await fsp.readFile( + path.join(__dirname, "fixtures", "stack.tar.gz") + ); + + return res( + ctx.body(fileBuffer), + ctx.set("Content-Type", "application/x-gzip") + ); + }), +]; + +let server = setupServer(...githubHandlers, ...miscHandlers); +export { server }; diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts new file mode 100644 index 0000000000..b521261815 --- /dev/null +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -0,0 +1,77 @@ +import path from "node:path"; + +import type { RemixConfig } from "../config"; +import { readConfig } from "../config"; + +const remixRoot = path.join(__dirname, "fixtures", "stack"); + +describe("readConfig", () => { + let config: RemixConfig; + beforeEach(async () => { + config = await readConfig(remixRoot); + }); + + it("generates a config", async () => { + expect(config).toMatchInlineSnapshot( + { + rootDirectory: expect.any(String), + appDirectory: expect.any(String), + cacheDirectory: expect.any(String), + serverBuildPath: expect.any(String), + assetsBuildDirectory: expect.any(String), + relativeAssetsBuildDirectory: expect.any(String), + entryClientFilePath: expect.any(String), + entryServerFilePath: expect.any(String), + tsconfigPath: expect.any(String), + }, + ` + { + "appDirectory": Any, + "assetsBuildDirectory": Any, + "browserNodeBuiltinsPolyfill": undefined, + "cacheDirectory": Any, + "dev": {}, + "entryClientFile": "entry.client.tsx", + "entryClientFilePath": Any, + "entryServerFile": "entry.server.tsx", + "entryServerFilePath": Any, + "future": { + "unstable_singleFetch": false, + "v3_fetcherPersist": false, + "v3_relativeSplatPath": false, + "v3_throwAbortReason": false, + }, + "mdx": undefined, + "postcss": true, + "publicPath": "/build/", + "relativeAssetsBuildDirectory": Any, + "rootDirectory": Any, + "routes": { + "root": { + "file": "root.tsx", + "id": "root", + "path": "", + }, + }, + "serverBuildPath": Any, + "serverBuildTargetEntryModule": "export * from "@remix-run/dev/server-build";", + "serverConditions": undefined, + "serverDependenciesToBundle": [], + "serverEntryPoint": undefined, + "serverMainFields": [ + "module", + "main", + ], + "serverMinify": false, + "serverMode": "production", + "serverModuleFormat": "esm", + "serverNodeBuiltinsPolyfill": undefined, + "serverPlatform": "node", + "tailwind": true, + "tsconfigPath": Any, + "watchPaths": [], + } + ` + ); + }); +}); diff --git a/packages/remix-dev/__tests__/reveal-test.ts b/packages/remix-dev/__tests__/reveal-test.ts new file mode 100644 index 0000000000..b22faa821f --- /dev/null +++ b/packages/remix-dev/__tests__/reveal-test.ts @@ -0,0 +1,106 @@ +import os from "node:os"; +import path from "node:path"; +import fse from "fs-extra"; + +import { run } from "../cli/run"; + +const TEMP_DIR = path.join( + fse.realpathSync(os.tmpdir()), + `remix-tests-${Math.random().toString(32).slice(2)}` +); + +beforeAll(async () => { + await fse.remove(TEMP_DIR); + await fse.ensureDir(TEMP_DIR); +}); + +afterAll(async () => { + await fse.remove(TEMP_DIR); +}); + +let originalLog = console.log; +let originalWarn = console.warn; +let originalError = console.error; + +beforeEach(async () => { + console.log = jest.fn(); + console.warn = jest.fn(); + console.error = jest.fn(); +}); + +afterEach(() => { + console.log = originalLog; + console.warn = originalWarn; + console.error = originalError; +}); + +// keep the console clear +jest.mock("ora", () => { + return jest.fn(() => ({ + start: jest.fn(() => ({ + stop: jest.fn(), + clear: jest.fn(), + })), + })); +}); + +describe("the reveal command", () => { + let tempDirs = new Set(); + let originalCwd = process.cwd(); + + beforeEach(() => { + process.chdir(TEMP_DIR); + }); + + afterEach(async () => { + process.chdir(originalCwd); + for (let dir of tempDirs) { + await fse.remove(dir); + } + tempDirs = new Set(); + }); + + async function getProjectDir(name: string) { + let tmpDir = path.join(TEMP_DIR, name); + tempDirs.add(tmpDir); + return tmpDir; + } + + let runtimes = ["node", "cloudflare", "deno"] as const; + + for (let runtime of runtimes) { + it(`generates a "${runtime}" specific entry.server.tsx file in the app directory`, async () => { + let projectDir = await getProjectDir(`entry.server.${runtime}`); + fse.copySync(path.join(__dirname, "fixtures", runtime), projectDir); + + let entryClientFile = path.join(projectDir, "app", "entry.client.tsx"); + let entryServerFile = path.join(projectDir, "app", "entry.server.tsx"); + + expect(fse.existsSync(entryServerFile)).toBeFalsy(); + expect(fse.existsSync(entryClientFile)).toBeFalsy(); + + await run(["reveal", "entry.server", projectDir]); + await run(["reveal", "entry.client", projectDir]); + + expect(fse.existsSync(entryServerFile)).toBeTruthy(); + expect(fse.existsSync(entryClientFile)).toBeTruthy(); + }); + + it(`generates a "${runtime}" specific entry.server.jsx file in the app directory`, async () => { + let projectDir = await getProjectDir(`entry.server.${runtime}-js`); + fse.copySync(path.join(__dirname, "fixtures", runtime), projectDir); + + let entryClientFile = path.join(projectDir, "app", "entry.client.jsx"); + let entryServerFile = path.join(projectDir, "app", "entry.server.jsx"); + + expect(fse.existsSync(entryServerFile)).toBeFalsy(); + expect(fse.existsSync(entryClientFile)).toBeFalsy(); + + await run(["reveal", "entry.server", projectDir, "--no-typescript"]); + await run(["reveal", "entry.client", projectDir, "--no-typescript"]); + + expect(fse.existsSync(entryServerFile)).toBeTruthy(); + expect(fse.existsSync(entryClientFile)).toBeTruthy(); + }); + } +}); diff --git a/packages/remix-dev/__tests__/setupAfterEnv.ts b/packages/remix-dev/__tests__/setupAfterEnv.ts new file mode 100644 index 0000000000..6a8ce7089e --- /dev/null +++ b/packages/remix-dev/__tests__/setupAfterEnv.ts @@ -0,0 +1,3 @@ +export let jestTimeout = process.platform === "win32" ? 20_000 : 10_000; + +jest.setTimeout(jestTimeout); diff --git a/packages/remix-dev/__tests__/utils/captureError.ts b/packages/remix-dev/__tests__/utils/captureError.ts new file mode 100644 index 0000000000..f29f0bafae --- /dev/null +++ b/packages/remix-dev/__tests__/utils/captureError.ts @@ -0,0 +1,14 @@ +class NoErrorThrownError extends Error {} + +export default async ( + erroring: Promise | (() => Promise) +) => { + try { + let promise = typeof erroring === "function" ? erroring() : erroring; + await promise; + throw new NoErrorThrownError(); + } catch (error: unknown) { + if (error instanceof NoErrorThrownError) throw error; + return error; + } +}; diff --git a/packages/remix-dev/__tests__/utils/cli.ts b/packages/remix-dev/__tests__/utils/cli.ts new file mode 100644 index 0000000000..9340243ec4 --- /dev/null +++ b/packages/remix-dev/__tests__/utils/cli.ts @@ -0,0 +1,101 @@ +import execa from "execa"; +import path from "node:path"; +import glob from "fast-glob"; +import fse from "fs-extra"; + +import captureError from "./captureError"; + +export const isExecaError = (error: unknown): error is execa.ExecaError => { + if (!(error instanceof Error)) return false; + return "exitCode" in error; +}; + +/** + * Read the details (`stat`) for a file or directory, + * or return `undefined` if the file or directory does not exist. + */ +const safeStat = (fileOrDir: string): fse.Stats | undefined => { + try { + return fse.statSync(fileOrDir); + } catch (error: unknown) { + let systemError = error as { code?: string }; + if (!systemError.code) throw error; + if (systemError.code !== "ENOENT") throw error; + throw error; + } +}; + +/** + * Find the latest modified time (`mtime`) across all files (recursively) in a directory. + */ +const mtimeDir = async (dir: string): Promise => { + let files = await glob(`**/*.{js,jsx,ts,tsx}`, { + cwd: dir, + ignore: [ + "**/node_modules/**", + "**/dist/**", + "**/.git/**", + "**/__tests__/**", + ], + }); + + let maxMtime: Date = new Date(0); + if (files.length === 0) return maxMtime; + for (let file of files) { + let filepath = path.resolve(dir, file); + let stat = safeStat(filepath); + if (stat === undefined) continue; + if (stat.mtime > maxMtime) { + maxMtime = stat.mtime; + } + } + return maxMtime; +}; + +export const run = async (args: string[], options: execa.Options = {}) => { + // // Running build `.js` is ~8x faster than running source `.ts` via `esbuild-register`, + // // so unless source code changes are not yet reflected in the build, prefer running the built `.js`. + // // To get speed ups in dev, make sure you build before running tests or are running `pnpm watch` + let sourceDir = path.resolve(__dirname, "../.."); + let sourceTS = path.resolve(sourceDir, "cli.ts"); + // // when the most recent change happened _anywhere_ within `packages/remix-dev/` + + let sourceModified = await mtimeDir(sourceDir); + + let buildDir = path.resolve( + __dirname, + "../../../../build/node_modules/@remix-run/dev" + ); + let builtJS = path.resolve(buildDir, "dist/cli.js"); + let buildModified = await mtimeDir(buildDir); + + // sometimes `pnpm watch` is so fast that the build mtime is reported + // to be _before_ the mtime for the change in source that _caused_ the build + // so we only use source if changes there are at least 5ms newer than latest build change + let thresholdMs = 5; + let isBuildUpToDate = + buildModified.valueOf() + thresholdMs >= sourceModified.valueOf(); + + let result = await execa( + "node", + [ + ...(isBuildUpToDate + ? [builtJS] + : ["--require", require.resolve("esbuild-register"), sourceTS]), + ...args, + ], + { + ...options, + env: { ...process.env, NO_COLOR: "1", ...(options?.env ?? {}) }, + } + ); + return result; +}; + +export const shouldError = async (args: string[]) => { + let error = await captureError(async () => { + await run(args); + }); + if (!isExecaError(error)) throw error; + return error; +}; diff --git a/packages/remix-dev/__tests__/utils/eol.ts b/packages/remix-dev/__tests__/utils/eol.ts new file mode 100644 index 0000000000..bb39313511 --- /dev/null +++ b/packages/remix-dev/__tests__/utils/eol.ts @@ -0,0 +1,2 @@ +export const normalize = (text: string, normalized = "\n") => + text.replace(/\r?\n/g, normalized); diff --git a/packages/remix-dev/__tests__/utils/git.ts b/packages/remix-dev/__tests__/utils/git.ts new file mode 100644 index 0000000000..8fdb2f2743 --- /dev/null +++ b/packages/remix-dev/__tests__/utils/git.ts @@ -0,0 +1,18 @@ +import execa from "execa"; + +export const initialCommit = async (projectDir: string) => { + let run = (cmd: string, args: string[]) => + execa(cmd, args, { cwd: projectDir }); + let commands = [ + ["init"], + + ["config", "user.name", '"github-actions[bot]"'], + ["config", "user.email", '"github-actions[bot]@users.noreply.github.com"'], + + ["add", "."], + ["commit", "--message", '"initial commit"'], + ]; + for (let command of commands) { + await run("git", command); + } +}; diff --git a/packages/remix-dev/__tests__/utils/withApp.ts b/packages/remix-dev/__tests__/utils/withApp.ts new file mode 100644 index 0000000000..b4c77861f5 --- /dev/null +++ b/packages/remix-dev/__tests__/utils/withApp.ts @@ -0,0 +1,41 @@ +import os from "node:os"; +import path from "node:path"; +import fse from "fs-extra"; + +const retry = async ( + callback: () => Promise, + times: number, + delayMs: number = 0 +) => { + try { + await callback(); + } catch (error: unknown) { + if (times === 0) throw error; + setTimeout(() => retry(callback, times - 1), delayMs); + } +}; + +export default async ( + fixture: string, + callback: (projectDir: string) => Promise +): Promise => { + let TEMP_DIR = path.join( + fse.realpathSync(os.tmpdir()), + `remix-tests-${Math.random().toString(32).slice(2)}` + ); + + let projectDir = path.join(TEMP_DIR); + await fse.remove(TEMP_DIR); + await fse.ensureDir(TEMP_DIR); + fse.copySync(fixture, projectDir); + try { + let result = await callback(projectDir); + return result; + } finally { + // Windows sometimes throws `EBUSY: resource busy or locked, rmdir` + // errors when attempting to removing the temporary directory. + // Retrying a couple times seems to get it to succeed. + // See https://github.com/jprichardson/node-fs-extra/issues?q=EBUSY%3A+resource+busy+or+locked%2C+rmdir + await retry(async () => await fse.remove(TEMP_DIR), 3, 200); + } +}; diff --git a/packages/remix-dev/assets-manifest.d.ts b/packages/remix-dev/assets-manifest.d.ts new file mode 100644 index 0000000000..a4c114c241 --- /dev/null +++ b/packages/remix-dev/assets-manifest.d.ts @@ -0,0 +1,6 @@ +import type { AssetsManifest } from "@remix-run/dev"; + +declare const manifest: AssetsManifest; + +export type { AssetsManifest }; +export default manifest; diff --git a/packages/remix-dev/cache.ts b/packages/remix-dev/cache.ts new file mode 100644 index 0000000000..22a494d316 --- /dev/null +++ b/packages/remix-dev/cache.ts @@ -0,0 +1,7 @@ +import { put, get } from "cacache"; + +export const putJson = async (cachePath: string, key: string, data: any) => + put(cachePath, key, JSON.stringify(data)); + +export const getJson = async (cachePath: string, key: string) => + get(cachePath, key).then((obj) => JSON.parse(obj.data.toString("utf-8"))); diff --git a/packages/remix-dev/channel.ts b/packages/remix-dev/channel.ts new file mode 100644 index 0000000000..7dbf4753a3 --- /dev/null +++ b/packages/remix-dev/channel.ts @@ -0,0 +1,28 @@ +import type { Result } from "./result"; + +type Resolve = (value: V | PromiseLike) => void; +type Reject = (reason?: any) => void; + +export type Type = { + ok: (value: V) => void; + err: (reason?: any) => void; + result: Promise>; +}; + +export const create = (): Type => { + let _resolve: Resolve<{ ok: true; value: V }>; + let _reject: Reject; + + let promise: Promise> = new Promise>( + (resolve, reject) => { + _resolve = resolve; + _reject = reject; + } + ).catch((error) => ({ ok: false, error } as const)); + + return { + ok: (value: V) => _resolve!({ ok: true, value } as const), + err: _reject!, + result: promise, + }; +}; diff --git a/packages/remix-dev/cli.ts b/packages/remix-dev/cli.ts new file mode 100644 index 0000000000..4932e23036 --- /dev/null +++ b/packages/remix-dev/cli.ts @@ -0,0 +1,11 @@ +import { cli } from "./index"; + +cli.run().then( + () => { + process.exit(0); + }, + (error: unknown) => { + if (error) console.error(error); + process.exit(1); + } +); diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts new file mode 100644 index 0000000000..7eeded3c27 --- /dev/null +++ b/packages/remix-dev/cli/commands.ts @@ -0,0 +1,435 @@ +import * as path from "node:path"; +import { execSync } from "node:child_process"; +import fse from "fs-extra"; +import getPort, { makeRange } from "get-port"; +import prettyMs from "pretty-ms"; +import PackageJson from "@npmcli/package-json"; +import pc from "picocolors"; +import exitHook from "exit-hook"; + +import * as colors from "../colors"; +import * as compiler from "../compiler"; +import * as devServer from "../devServer"; +import * as devServer_unstable from "../devServer_unstable"; +import type { RemixConfig } from "../config"; +import type { ViteDevOptions } from "../vite/dev"; +import type { ViteBuildOptions } from "../vite/build"; +import { readConfig } from "../config"; +import { formatRoutes, type RoutesFormat } from "../config/format"; +import { loadVitePluginContext } from "../vite/plugin"; +import { detectPackageManager } from "./detectPackageManager"; +import { transpile as convertFileToJS } from "./useJavascript"; +import type { Options } from "../compiler/options"; +import { createFileWatchCache } from "../compiler/fileWatchCache"; +import { logger } from "../tux"; +import * as profiler from "../vite/profiler"; + +type InitFlags = { + deleteScript?: boolean; +}; + +export async function init( + projectDir: string, + { deleteScript = true }: InitFlags = {} +) { + let initScriptDir = path.join(projectDir, "remix.init"); + let initScript = path.resolve(initScriptDir, "index.js"); + + if (!(await fse.pathExists(initScript))) { + return; + } + + let initPackageJson = path.resolve(initScriptDir, "package.json"); + let packageManager = detectPackageManager() ?? "npm"; + + if (await fse.pathExists(initPackageJson)) { + execSync(`${packageManager} install`, { + cwd: initScriptDir, + stdio: "ignore", + }); + } + + let initFn = require(initScript); + if (typeof initFn !== "function" && initFn.default) { + initFn = initFn.default; + } + try { + await initFn({ packageManager, rootDirectory: projectDir }); + + if (deleteScript) { + await fse.remove(initScriptDir); + } + } catch (error: unknown) { + if (error instanceof Error) { + error.message = `${colors.error("🚨 Oops, remix.init failed")}\n\n${ + error.message + }`; + } + throw error; + } +} + +/** + * Keep the function around in v2 so that users with `remix setup` in a script + * or postinstall hook can still run a build, but inform them that it's no + * longer necessary, and we can remove it in v3. + * @deprecated + */ +export function setup() { + console.warn( + "WARNING: The setup command is no longer necessary as of v2. This is a no-op. Please remove this from your dev and CI scripts, as it will be removed in v3." + ); +} + +export async function routes( + remixRoot?: string, + flags: { + config?: string; + json?: boolean; + } = {} +): Promise { + let ctx = await loadVitePluginContext({ + root: remixRoot, + configFile: flags.config, + }); + + let routes = + ctx?.remixConfig.routes || + // v3 TODO: Remove this and require the presence of a Vite config + (await readConfig(remixRoot)).routes; + + let format: RoutesFormat = flags.json ? "json" : "jsx"; + console.log(formatRoutes(routes, format)); +} + +export async function build( + remixRoot: string, + mode?: string, + sourcemap: boolean = false +): Promise { + mode = mode ?? "production"; + + logger.info(`building...` + pc.gray(` (NODE_ENV=${mode})`)); + + if (mode === "production" && sourcemap) { + logger.warn("🚨 source maps enabled in production", { + details: [ + "You are using `--sourcemap` to enable source maps in production,", + "making your server-side code publicly visible in the browser.", + "This is highly discouraged!", + "If you insist, ensure that you are using environment variables for secrets", + "and are not hard-coding them in your source.", + ], + }); + } + + let start = Date.now(); + let config = await readConfig(remixRoot); + let options: Options = { + mode, + sourcemap, + }; + if (mode === "development") { + let resolved = await resolveDev(config); + options.REMIX_DEV_ORIGIN = resolved.REMIX_DEV_ORIGIN; + } + + let fileWatchCache = createFileWatchCache(); + + fse.emptyDirSync(config.assetsBuildDirectory); + await compiler + .build({ config, options, fileWatchCache, logger }) + .catch((thrown) => { + compiler.logThrown(thrown); + process.exit(1); + }); + + logger.info("built" + pc.gray(` (${prettyMs(Date.now() - start)})`)); +} + +export async function viteBuild( + root?: string, + options: ViteBuildOptions = {} +): Promise { + if (!root) { + root = process.env.REMIX_ROOT || process.cwd(); + } + + let { build } = await import("../vite/build"); + if (options.profile) { + await profiler.start(); + } + try { + await build(root, options); + } finally { + await profiler.stop(logger.info); + } +} + +export async function watch( + remixRootOrConfig: string | RemixConfig, + mode?: string +): Promise { + mode = mode ?? "development"; + console.log(`Watching Remix app in ${mode} mode...`); + + let config = + typeof remixRootOrConfig === "object" + ? remixRootOrConfig + : await readConfig(remixRootOrConfig); + + let resolved = await resolveDev(config); + void devServer.liveReload(config, { ...resolved, mode }); + return await new Promise(() => {}); +} + +export async function dev( + remixRoot: string, + flags: { + command?: string; + manual?: boolean; + port?: number; + tlsKey?: string; + tlsCert?: string; + } = {} +) { + console.log(`\n 💿 remix dev\n`); + + if (process.env.NODE_ENV && process.env.NODE_ENV !== "development") { + logger.warn(`overriding NODE_ENV=${process.env.NODE_ENV} to development`); + } + process.env.NODE_ENV = "development"; + + let config = await readConfig(remixRoot); + + let resolved = await resolveDevServe(config, flags); + devServer_unstable.serve(config, resolved); + + // keep `remix dev` alive by waiting indefinitely + await new Promise(() => {}); +} + +export async function viteDev(root: string, options: ViteDevOptions = {}) { + let { dev } = await import("../vite/dev"); + if (options.profile) { + await profiler.start(); + } + exitHook(() => profiler.stop(console.info)); + await dev(root, options); + + // keep `remix vite-dev` alive by waiting indefinitely + await new Promise(() => {}); +} + +let clientEntries = ["entry.client.tsx", "entry.client.js", "entry.client.jsx"]; +let serverEntries = ["entry.server.tsx", "entry.server.js", "entry.server.jsx"]; +let entries = ["entry.client", "entry.server"]; + +let conjunctionListFormat = new Intl.ListFormat("en", { + style: "long", + type: "conjunction", +}); + +let disjunctionListFormat = new Intl.ListFormat("en", { + style: "long", + type: "disjunction", +}); + +export async function generateEntry( + entry: string, + remixRoot: string, + flags: { + typescript?: boolean; + config?: string; + } = {} +) { + let ctx = await loadVitePluginContext({ + root: remixRoot, + configFile: flags.config, + }); + + let { rootDirectory, appDirectory } = ctx + ? { + rootDirectory: ctx.rootDirectory, + appDirectory: ctx.remixConfig.appDirectory, + } + : // v3 TODO: Remove this and require the presence of a Vite config + await readConfig(remixRoot); + + // if no entry passed, attempt to create both + if (!entry) { + await generateEntry("entry.client", remixRoot, flags); + await generateEntry("entry.server", remixRoot, flags); + return; + } + + if (!entries.includes(entry)) { + let entriesArray = Array.from(entries); + let list = conjunctionListFormat.format(entriesArray); + + console.error( + colors.error(`Invalid entry file. Valid entry files are ${list}`) + ); + return; + } + + let pkgJson = await PackageJson.load(rootDirectory); + let deps = pkgJson.content.dependencies ?? {}; + + let serverRuntime = deps["@remix-run/deno"] + ? "deno" + : deps["@remix-run/cloudflare"] + ? "cloudflare" + : deps["@remix-run/node"] + ? "node" + : undefined; + + if (!serverRuntime) { + let serverRuntimes = [ + "@remix-run/deno", + "@remix-run/cloudflare", + "@remix-run/node", + ]; + let formattedList = disjunctionListFormat.format(serverRuntimes); + console.error( + colors.error( + `Could not determine server runtime. Please install one of the following: ${formattedList}` + ) + ); + return; + } + + let defaultsDirectory = path.resolve(__dirname, "..", "config", "defaults"); + let defaultEntryClient = path.resolve(defaultsDirectory, "entry.client.tsx"); + let defaultEntryServer = path.resolve( + defaultsDirectory, + ctx?.remixConfig.ssr === false && + ctx?.remixConfig.future.unstable_singleFetch !== true + ? `entry.server.spa.tsx` + : `entry.server.${serverRuntime}.tsx` + ); + + let isServerEntry = entry === "entry.server"; + + let contents = isServerEntry + ? await createServerEntry(rootDirectory, appDirectory, defaultEntryServer) + : await createClientEntry(rootDirectory, appDirectory, defaultEntryClient); + + let useTypeScript = flags.typescript ?? true; + let outputExtension = useTypeScript ? "tsx" : "jsx"; + let outputEntry = `${entry}.${outputExtension}`; + let outputFile = path.resolve(appDirectory, outputEntry); + + if (!useTypeScript) { + let javascript = convertFileToJS(contents, { + cwd: rootDirectory, + filename: isServerEntry ? defaultEntryServer : defaultEntryClient, + }); + await fse.writeFile(outputFile, javascript, "utf-8"); + } else { + await fse.writeFile(outputFile, contents, "utf-8"); + } + + console.log( + colors.blue( + `Entry file ${entry} created at ${path.relative( + rootDirectory, + outputFile + )}.` + ) + ); +} + +async function checkForEntry( + rootDirectory: string, + appDirectory: string, + entries: string[] +) { + for (let entry of entries) { + let entryPath = path.resolve(appDirectory, entry); + let exists = await fse.pathExists(entryPath); + if (exists) { + let relative = path.relative(rootDirectory, entryPath); + console.error(colors.error(`Entry file ${relative} already exists.`)); + return process.exit(1); + } + } +} + +async function createServerEntry( + rootDirectory: string, + appDirectory: string, + inputFile: string +) { + await checkForEntry(rootDirectory, appDirectory, serverEntries); + let contents = await fse.readFile(inputFile, "utf-8"); + return contents; +} + +async function createClientEntry( + rootDirectory: string, + appDirectory: string, + inputFile: string +) { + await checkForEntry(rootDirectory, appDirectory, clientEntries); + let contents = await fse.readFile(inputFile, "utf-8"); + return contents; +} + +let findPort = async () => getPort({ port: makeRange(3001, 3100) }); + +let resolveDev = async ( + config: RemixConfig, + flags: { + port?: number; + tlsKey?: string; + tlsCert?: string; + } = {} +) => { + let { dev } = config; + + let port = flags.port ?? dev.port ?? (await findPort()); + + let tlsKey = flags.tlsKey ?? dev.tlsKey; + if (tlsKey) tlsKey = path.resolve(tlsKey); + let tlsCert = flags.tlsCert ?? dev.tlsCert; + if (tlsCert) tlsCert = path.resolve(tlsCert); + let isTLS = tlsKey && tlsCert; + + let REMIX_DEV_ORIGIN = process.env.REMIX_DEV_ORIGIN; + if (REMIX_DEV_ORIGIN === undefined) { + let scheme = isTLS ? "https" : "http"; + REMIX_DEV_ORIGIN = `${scheme}://localhost:${port}`; + } + + return { + port, + tlsKey, + tlsCert, + REMIX_DEV_ORIGIN: new URL(REMIX_DEV_ORIGIN), + }; +}; + +let resolveDevServe = async ( + config: RemixConfig, + flags: { + command?: string; + manual?: boolean; + port?: number; + tlsKey?: string; + tlsCert?: string; + } = {} +) => { + let { dev } = config; + + let resolved = await resolveDev(config, flags); + + let command = flags.command ?? dev.command; + let manual = flags.manual ?? dev.manual ?? false; + + return { + ...resolved, + command, + manual, + }; +}; diff --git a/packages/remix-dev/cli/detectPackageManager.ts b/packages/remix-dev/cli/detectPackageManager.ts new file mode 100644 index 0000000000..79b7c8277e --- /dev/null +++ b/packages/remix-dev/cli/detectPackageManager.ts @@ -0,0 +1,23 @@ +type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; + +/** + * Determine which package manager the user prefers. + * + * npm, pnpm and Yarn set the user agent environment variable + * that can be used to determine which package manager ran + * the command. + */ +export const detectPackageManager = (): PackageManager | undefined => { + let { npm_config_user_agent } = process.env; + if (!npm_config_user_agent) return undefined; + try { + let pkgManager = npm_config_user_agent.split("/")[0]; + if (pkgManager === "npm") return "npm"; + if (pkgManager === "pnpm") return "pnpm"; + if (pkgManager === "yarn") return "yarn"; + if (pkgManager === "bun") return "bun"; + return undefined; + } catch { + return undefined; + } +}; diff --git a/packages/remix-dev/cli/index.ts b/packages/remix-dev/cli/index.ts new file mode 100644 index 0000000000..3ed50b63ed --- /dev/null +++ b/packages/remix-dev/cli/index.ts @@ -0,0 +1 @@ +export { run } from "./run"; diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts new file mode 100644 index 0000000000..87bf43e4db --- /dev/null +++ b/packages/remix-dev/cli/run.ts @@ -0,0 +1,289 @@ +import arg from "arg"; +import semver from "semver"; + +import * as colors from "../colors"; +import * as commands from "./commands"; + +const helpText = ` +${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow( + "M" +)} ${colors.logoPink("I")} ${colors.logoRed("X")} + + ${colors.heading("Usage")}: + $ remix init [${colors.arg("projectDir")}] + $ remix vite:build [${colors.arg("projectDir")}] + $ remix vite:dev [${colors.arg("projectDir")}] + $ remix build [${colors.arg("projectDir")}] + $ remix dev [${colors.arg("projectDir")}] + $ remix routes [${colors.arg("projectDir")}] + $ remix watch [${colors.arg("projectDir")}] + + ${colors.heading("Options")}: + --help, -h Print this help message and exit + --version, -v Print the CLI version and exit + --no-color Disable ANSI colors in console output + \`vite:build\` Options (Passed through to Vite): + --assetsInlineLimit Static asset base64 inline threshold in bytes (default: 4096) (number) + --clearScreen Allow/disable clear screen when logging (boolean) + --config, -c Use specified config file (string) + --emptyOutDir Force empty outDir when it's outside of root (boolean) + --logLevel, -l Info | warn | error | silent (string) + --minify Enable/disable minification, or specify minifier to use (default: "esbuild") (boolean | "terser" | "esbuild") + --mode, -m Set env mode (string) + --profile Start built-in Node.js inspector + --sourcemapClient Output source maps for client build (default: false) (boolean | "inline" | "hidden") + --sourcemapServer Output source maps for server build (default: false) (boolean | "inline" | "hidden") + \`build\` Options: + --sourcemap Generate source maps for production + \`vite:dev\` Options (Passed through to Vite): + --clearScreen Allow/disable clear screen when logging (boolean) + --config, -c Use specified config file (string) + --cors Enable CORS (boolean) + --force Force the optimizer to ignore the cache and re-bundle (boolean) + --host Specify hostname (string) + --logLevel, -l Info | warn | error | silent (string) + --mode, -m Set env mode (string) + --open Open browser on startup (boolean | string) + --port Specify port (number) + --profile Start built-in Node.js inspector + --strictPort Exit if specified port is already in use (boolean) + \`dev\` Options: + --command, -c Command used to run your app server + --manual Enable manual mode + --port Port for the dev server. Default: any open port + --tls-key Path to TLS key (key.pem) + --tls-cert Path to TLS certificate (cert.pem) + \`init\` Options: + --no-delete Skip deleting the \`remix.init\` script + \`routes\` Options: + --config, -c Use specified Vite config file (string) + --json Print the routes as JSON + \`reveal\` Options: + --config, -c Use specified Vite config file (string) + --no-typescript Generate plain JavaScript files + + ${colors.heading("Values")}: + - ${colors.arg("projectDir")} The Remix project directory + - ${colors.arg("remixPlatform")} \`node\` or \`cloudflare\` + + ${colors.heading("Initialize a project:")}: + + Remix project templates may contain a \`remix.init\` directory + with a script that initializes the project. This script automatically + runs during \`remix create\`, but if you ever need to run it manually + (e.g. to test it out) you can: + + $ remix init + + ${colors.heading("Build your project (Vite)")}: + + $ remix vite:build + + ${colors.heading("Run your project locally in development (Vite)")}: + + $ remix vite:dev + + ${colors.heading("Build your project (Classic compiler)")}: + + $ remix build + $ remix build --sourcemap + $ remix build my-app + + ${colors.heading( + "Run your project locally in development (Classic compiler)" + )}: + + $ remix dev + $ remix dev -c "node ./server.js" + + ${colors.heading( + "Start your server separately and watch for changes (Classic compiler)" + )}: + + # custom server start command, for example: + $ remix watch + + # in a separate tab: + $ node --inspect --require ./node_modules/dotenv/config --require ./mocks ./build/server.js + + ${colors.heading("Show all routes in your app")}: + + $ remix routes + $ remix routes my-app + $ remix routes --json + $ remix routes --config vite.remix.config.ts + + ${colors.heading("Reveal the used entry point")}: + + $ remix reveal entry.client + $ remix reveal entry.server + $ remix reveal entry.client --no-typescript + $ remix reveal entry.server --no-typescript + $ remix reveal entry.server --config vite.remix.config.ts +`; + +/** + * Programmatic interface for running the Remix CLI with the given command line + * arguments. + */ +export async function run(argv: string[] = process.argv.slice(2)) { + // Check the node version + let versions = process.versions; + if (versions && versions.node && semver.major(versions.node) < 18) { + throw new Error( + `️🚨 Oops, Node v${versions.node} detected. Remix requires a Node version greater than 18.` + ); + } + + let isBooleanFlag = (arg: string) => { + let index = argv.indexOf(arg); + let nextArg = argv[index + 1]; + return !nextArg || nextArg.startsWith("-"); + }; + + let args = arg( + { + "--no-delete": Boolean, + "--dry": Boolean, + "--force": Boolean, + "--help": Boolean, + "-h": "--help", + "--json": Boolean, + "--token": String, + "--typescript": Boolean, + "--no-typescript": Boolean, + "--version": Boolean, + "-v": "--version", + + // dev server + "--command": String, + "--manual": Boolean, + "--port": Number, + "-p": "--port", + "--tls-key": String, + "--tls-cert": String, + + ...(argv[0].startsWith("vite:") || + argv[0] === "reveal" || + argv[0] === "routes" + ? // Handle commands that support Vite's --config flag + { + "--config": String, + "-c": "--config", + } + : { + // Handle non Vite config commands + "-c": "--command", + }), + + ...(argv[0].startsWith("vite:") + ? { + // Vite commands + // --config, --force and --port are already defined above + "--assetsInlineLimit": Number, + "--clearScreen": Boolean, + "--cors": Boolean, + "--emptyOutDir": Boolean, + "--host": isBooleanFlag("--host") ? Boolean : String, + "--logLevel": String, + "-l": "--logLevel", + "--minify": String, + "--mode": String, + "-m": "--mode", + "--open": isBooleanFlag("--open") ? Boolean : String, + "--strictPort": Boolean, + "--profile": Boolean, + "--sourcemapClient": isBooleanFlag("--sourcemapClient") + ? Boolean + : String, + "--sourcemapServer": isBooleanFlag("--sourcemapServer") + ? Boolean + : String, + } + : { + // Non Vite commands + "--sourcemap": Boolean, + }), + }, + { + argv, + } + ); + + let input = args._; + + let flags: any = Object.entries(args).reduce((acc, [key, value]) => { + key = key.replace(/^--/, ""); + acc[key] = value; + return acc; + }, {} as any); + + if (flags.help) { + console.log(helpText); + return; + } + if (flags.version) { + let version = require("../package.json").version; + console.log(version); + return; + } + + if (flags["tls-key"]) { + flags.tlsKey = flags["tls-key"]; + delete flags["tls-key"]; + } + if (flags["tls-cert"]) { + flags.tlsCert = flags["tls-cert"]; + delete flags["tls-cert"]; + } + + if (args["--no-delete"]) { + flags.delete = false; + } + flags.interactive = flags.interactive ?? require.main === module; + if (args["--no-typescript"]) { + flags.typescript = false; + } + + let command = input[0]; + + // Note: Keep each case in this switch statement small. + switch (command) { + case "init": + await commands.init(input[1] || process.env.REMIX_ROOT || process.cwd(), { + deleteScript: flags.delete, + }); + break; + case "routes": + await commands.routes(input[1], flags); + break; + case "build": + if (!process.env.NODE_ENV) process.env.NODE_ENV = "production"; + await commands.build(input[1], process.env.NODE_ENV, flags.sourcemap); + break; + case "vite:build": + await commands.viteBuild(input[1], flags); + break; + case "watch": + if (!process.env.NODE_ENV) process.env.NODE_ENV = "development"; + await commands.watch(input[1], process.env.NODE_ENV); + break; + case "setup": + commands.setup(); + break; + case "reveal": { + // TODO: simplify getting started guide + await commands.generateEntry(input[1], input[2], flags); + break; + } + case "dev": + await commands.dev(input[1], flags); + break; + case "vite:dev": + await commands.viteDev(input[1], flags); + break; + default: + // `remix ./my-project` is shorthand for `remix dev ./my-project` + await commands.dev(input[0], flags); + } +} diff --git a/packages/remix-dev/cli/useJavascript.ts b/packages/remix-dev/cli/useJavascript.ts new file mode 100644 index 0000000000..ac6297d743 --- /dev/null +++ b/packages/remix-dev/cli/useJavascript.ts @@ -0,0 +1,30 @@ +import * as babel from "@babel/core"; +// @ts-expect-error These modules don't have types +import babelPluginSyntaxJSX from "@babel/plugin-syntax-jsx"; +// @ts-expect-error These modules don't have types +import babelPresetTypeScript from "@babel/preset-typescript"; +import prettier from "prettier"; + +export function transpile( + tsx: string, + options: { + cwd?: string; + filename?: string; + } = {} +): string { + let mjs = babel.transformSync(tsx, { + compact: false, + cwd: options.cwd, + filename: options.filename, + plugins: [babelPluginSyntaxJSX], + presets: [[babelPresetTypeScript, { jsx: "preserve" }]], + retainLines: true, + }); + if (!mjs || !mjs.code) throw new Error("Could not parse TypeScript"); + + /** + * Babel's `compact` and `retainLines` options are both bad at formatting code. + * Use Prettier for nicer formatting. + */ + return prettier.format(mjs.code, { parser: "babel" }); +} diff --git a/packages/remix-dev/colors.ts b/packages/remix-dev/colors.ts new file mode 100644 index 0000000000..054f1c7856 --- /dev/null +++ b/packages/remix-dev/colors.ts @@ -0,0 +1,29 @@ +import chalk from "chalk"; + +// https://no-color.org/ +const useColor = chalk.supportsColor && !process.env.NO_COLOR; + +const identity = (x: T) => x; +const safe = (style: chalk.Chalk) => (useColor ? style : identity); + +export const heading = safe(chalk.underline); +export const arg = safe(chalk.yellowBright); +export const error = safe(chalk.red); +export const warning = safe(chalk.yellow); +export const hint = safe(chalk.blue); + +export const logoBlue = safe(chalk.blueBright); +export const logoGreen = safe(chalk.greenBright); +export const logoYellow = safe(chalk.yellowBright); +export const logoPink = safe(chalk.magentaBright); +export const logoRed = safe(chalk.redBright); + +// raw styles +export const bold = safe(chalk.bold); + +// raw colors +export const blue = safe(chalk.blue); +export const cyan = safe(chalk.cyan); +export const gray = safe(chalk.gray); +export const red = safe(chalk.red); +export const yellow = safe(chalk.yellow); diff --git a/packages/remix-dev/compiler/analysis.ts b/packages/remix-dev/compiler/analysis.ts new file mode 100644 index 0000000000..9b575bdf2e --- /dev/null +++ b/packages/remix-dev/compiler/analysis.ts @@ -0,0 +1,14 @@ +import fs from "fs-extra"; +import path from "node:path"; +import type { Metafile } from "esbuild"; + +import type { Context } from "./context"; + +export let writeMetafile = ( + ctx: Context, + filename: string, + metafile: Metafile +) => { + let buildDir = path.dirname(ctx.config.serverBuildPath); + fs.outputFileSync(path.join(buildDir, filename), JSON.stringify(metafile)); +}; diff --git a/packages/remix-dev/compiler/build.ts b/packages/remix-dev/compiler/build.ts new file mode 100644 index 0000000000..3f2e384d20 --- /dev/null +++ b/packages/remix-dev/compiler/build.ts @@ -0,0 +1,7 @@ +import * as Compiler from "./compiler"; +import type { Context } from "./context"; + +export async function build(ctx: Context): Promise { + let compiler = await Compiler.create(ctx); + await compiler.compile(); +} diff --git a/packages/remix-dev/compiler/cancel.ts b/packages/remix-dev/compiler/cancel.ts new file mode 100644 index 0000000000..cf6b026d5c --- /dev/null +++ b/packages/remix-dev/compiler/cancel.ts @@ -0,0 +1,7 @@ +export const CANCEL_PREFIX = "remix-compile-cancel"; + +export class Cancel extends Error { + constructor(message: string) { + super(`${CANCEL_PREFIX}: ${message}`); + } +} diff --git a/packages/remix-dev/compiler/compiler.ts b/packages/remix-dev/compiler/compiler.ts new file mode 100644 index 0000000000..4a0f7eea7a --- /dev/null +++ b/packages/remix-dev/compiler/compiler.ts @@ -0,0 +1,144 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import type { Context } from "./context"; +import * as CSS from "./css"; +import * as JS from "./js"; +import * as Server from "./server"; +import * as Channel from "../channel"; +import type { Manifest } from "../manifest"; +import { create as createManifest, write as writeManifest } from "./manifest"; +import type { LazyValue } from "./lazyValue"; +import { createLazyValue } from "./lazyValue"; +import { err, ok } from "../result"; +import { Cancel } from "./cancel"; + +type Compiler = { + compile: (options?: { + onManifest?: (manifest: Manifest) => void; + }) => Promise; + cancel: () => Promise; + dispose: () => Promise; +}; + +export let create = async (ctx: Context): Promise => { + // these variables _should_ be scoped to a build, not a compiler + // but esbuild doesn't have an API for passing build-specific arguments for rebuilds + // so instead use a mutable reference (`refs`) that is compiler-scoped + // and gets reset on each build + let refs = { + lazyCssBundleHref: undefined as unknown as LazyValue, + manifestChannel: undefined as unknown as Channel.Type, + }; + + let subcompiler = { + css: await CSS.createCompiler(ctx), + js: await JS.createCompiler(ctx, refs), + server: await Server.createCompiler(ctx, refs), + }; + let cancel = async () => { + // resolve channels with error so that downstream tasks don't hang waiting for results from upstream tasks + refs.lazyCssBundleHref.cancel(); + refs.manifestChannel.err(); + + // optimization: cancel tasks + await Promise.all([ + subcompiler.css.cancel(), + subcompiler.js.cancel(), + subcompiler.server.cancel(), + ]); + }; + + let compile = async ( + options: { onManifest?: (manifest: Manifest) => void } = {} + ) => { + let error: unknown | undefined = undefined; + let errCancel = (thrown: unknown) => { + if (error === undefined) { + error = thrown; + } + void cancel(); + return err(thrown); + }; + + // keep track of manually written artifacts + let writes: { + js?: Promise; + cssBundle?: Promise; + manifest?: Promise; + server?: Promise; + } = {}; + + // reset refs for this compilation + refs.manifestChannel = Channel.create(); + refs.lazyCssBundleHref = createLazyValue({ + async get() { + let { bundleOutputFile, outputFiles } = await subcompiler.css.compile(); + + if (bundleOutputFile) { + writes.cssBundle = CSS.writeBundle(ctx, outputFiles); + } + + return ( + bundleOutputFile && + ctx.config.publicPath + + path.relative( + ctx.config.assetsBuildDirectory, + path.resolve(bundleOutputFile.path) + ) + ); + }, + onCancel: ({ reject }) => { + reject(new Cancel("css-bundle")); + }, + }); + + // kickoff compilations in parallel + let tasks = { + js: subcompiler.js.compile().then(ok, errCancel), + server: subcompiler.server.compile().then(ok, errCancel), + }; + + // js compilation (implicitly writes artifacts/js) + let js = await tasks.js; + if (!js.ok) throw error ?? js.error; + let { metafile, outputFiles, hmr } = js.value; + writes.js = JS.write(ctx.config, outputFiles); + + // artifacts/manifest + let manifest = await createManifest({ + config: ctx.config, + metafile, + hmr, + fileWatchCache: ctx.fileWatchCache, + }); + refs.manifestChannel.ok(manifest); + options.onManifest?.(manifest); + writes.manifest = writeManifest(ctx.config, manifest); + + // server compilation + let server = await tasks.server; + if (!server.ok) throw error ?? server.error; + // artifacts/server + writes.server = Server.write(ctx.config, server.value).then(() => { + // write the version to a sentinel file _after_ the server has been written + // this allows the app server to watch for changes to `version.txt` + // avoiding race conditions when the app server would attempt to reload a partially written server build + let versionTxt = path.join( + path.dirname(ctx.config.serverBuildPath), + "version.txt" + ); + fs.writeFileSync(versionTxt, manifest.version); + }); + + await Promise.all(Object.values(writes)); + return manifest; + }; + return { + compile, + cancel, + dispose: async () => { + await Promise.all(Object.values(subcompiler).map((sub) => sub.dispose())); + }, + }; +}; diff --git a/packages/remix-dev/compiler/context.ts b/packages/remix-dev/compiler/context.ts new file mode 100644 index 0000000000..b924b25466 --- /dev/null +++ b/packages/remix-dev/compiler/context.ts @@ -0,0 +1,11 @@ +import type { RemixConfig } from "../config"; +import type { Logger } from "../tux"; +import type { FileWatchCache } from "./fileWatchCache"; +import type { Options } from "./options"; + +export type Context = { + config: RemixConfig; + options: Options; + fileWatchCache: FileWatchCache; + logger: Logger; +}; diff --git a/packages/remix-dev/compiler/css/bundle.ts b/packages/remix-dev/compiler/css/bundle.ts new file mode 100644 index 0000000000..cc239d3fbf --- /dev/null +++ b/packages/remix-dev/compiler/css/bundle.ts @@ -0,0 +1,60 @@ +import * as path from "node:path"; +import fse from "fs-extra"; +import type * as esbuild from "esbuild"; +import postcss from "postcss"; +import postcssDiscardDuplicates from "postcss-discard-duplicates"; + +import type { Context } from "../context"; + +export let write = async (ctx: Context, outputFiles: esbuild.OutputFile[]) => { + let cssBundleFile = outputFiles.find((outputFile) => + isBundle(ctx, outputFile, ".css") + ); + if (!cssBundleFile) return; + + let cssBundlePath = cssBundleFile.path; + + let { css, map } = await postcss([ + // We need to discard duplicate rules since "composes" + // in CSS Modules can result in duplicate styles + postcssDiscardDuplicates(), + ]).process(cssBundleFile.text, { + from: cssBundlePath, + to: cssBundlePath, + map: ctx.options.sourcemap && { + prev: outputFiles.find((outputFile) => + isBundle(ctx, outputFile, ".css.map") + )?.text, + inline: false, + annotation: false, + sourcesContent: true, + }, + }); + + await fse.ensureDir(path.dirname(cssBundlePath)); + + await Promise.all([ + fse.writeFile(cssBundlePath, css), + ctx.options.mode !== "production" && map + ? fse.writeFile(`${cssBundlePath}.map`, map.toString()) // Write our updated source map rather than esbuild's + : null, + ...outputFiles + .filter((outputFile) => !/\.(css|js|map)$/.test(outputFile.path)) + .map(async (asset) => { + await fse.ensureDir(path.dirname(asset.path)); + await fse.writeFile(asset.path, asset.contents); + }), + ]); +}; + +export let isBundle = ( + ctx: Context, + outputFile: esbuild.OutputFile, + extension: ".css" | ".css.map" +): boolean => { + return ( + path.dirname(outputFile.path) === ctx.config.assetsBuildDirectory && + path.basename(outputFile.path).startsWith("css-bundle") && + outputFile.path.endsWith(extension) + ); +}; diff --git a/packages/remix-dev/compiler/css/compiler.ts b/packages/remix-dev/compiler/css/compiler.ts new file mode 100644 index 0000000000..deea877289 --- /dev/null +++ b/packages/remix-dev/compiler/css/compiler.ts @@ -0,0 +1,95 @@ +import { builtinModules as nodeBuiltins } from "node:module"; +import * as esbuild from "esbuild"; + +import { loaders } from "../utils/loaders"; +import { cssFilePlugin } from "../plugins/cssImports"; +import { absoluteCssUrlsPlugin } from "../plugins/absoluteCssUrlsPlugin"; +import { emptyModulesPlugin } from "../plugins/emptyModules"; +import { mdxPlugin } from "../plugins/mdx"; +import { externalPlugin } from "../plugins/external"; +import { cssModulesPlugin } from "../plugins/cssModuleImports"; +import { cssSideEffectImportsPlugin } from "../plugins/cssSideEffectImports"; +import { vanillaExtractPlugin } from "../plugins/vanillaExtract"; +import { + cssBundleEntryModulePlugin, + cssBundleEntryModuleId, +} from "./plugins/bundleEntry"; +import type { Context } from "../context"; +import { isBundle } from "./bundle"; +import { writeMetafile } from "../analysis"; + +const createEsbuildConfig = (ctx: Context): esbuild.BuildOptions => { + return { + entryPoints: { + "css-bundle": cssBundleEntryModuleId, + }, + outdir: ctx.config.assetsBuildDirectory, + platform: "browser", + format: "esm", + // Node built-ins (and any polyfills) are guaranteed to never contain CSS, + // and the JS from this build will never be executed, so we can safely skip + // bundling them and leave any imports of them as-is in the generated JS. + // Any issues with Node built-ins will be caught by the browser JS build. + external: nodeBuiltins, + loader: loaders, + bundle: true, + logLevel: "silent", + sourcemap: ctx.options.sourcemap, + // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to + // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted + // behavior can only be avoided by creating an empty tsconfig file in the root directory. + tsconfig: ctx.config.tsconfigPath, + mainFields: ["browser", "module", "main"], + treeShaking: true, + minify: ctx.options.mode === "production", + entryNames: "[dir]/[name]-[hash]", + chunkNames: "_shared/[name]-[hash]", + assetNames: "_assets/[name]-[hash]", + publicPath: ctx.config.publicPath, + define: { + "process.env.NODE_ENV": JSON.stringify(ctx.options.mode), + }, + jsx: "automatic", + jsxDev: ctx.options.mode !== "production", + plugins: [ + cssBundleEntryModulePlugin(ctx), + cssModulesPlugin(ctx, { outputCss: true }), + vanillaExtractPlugin(ctx, { outputCss: true }), + cssSideEffectImportsPlugin(ctx), + cssFilePlugin(ctx), + absoluteCssUrlsPlugin(), + externalPlugin(/^https?:\/\//, { sideEffects: false }), + mdxPlugin(ctx), + // Skip compilation of common packages/scopes known not to include CSS imports + emptyModulesPlugin(ctx, /^(@remix-run|react|react-dom)(\/.*)?$/, { + includeNodeModules: true, + }), + emptyModulesPlugin(ctx, /\.server(\.[jt]sx?)?$/), + externalPlugin(/^node:.*/, { sideEffects: false }), + ], + supported: { + "import-meta": true, + }, + }; +}; + +export let create = async (ctx: Context) => { + let compiler = await esbuild.context({ + ...createEsbuildConfig(ctx), + write: false, + metafile: true, + }); + let compile = async () => { + let { outputFiles, metafile } = await compiler.rebuild(); + writeMetafile(ctx, "metafile.css.json", metafile); + let bundleOutputFile = outputFiles.find((outputFile) => + isBundle(ctx, outputFile, ".css") + ); + return { bundleOutputFile, outputFiles }; + }; + return { + compile, + cancel: compiler.cancel, + dispose: compiler.dispose, + }; +}; diff --git a/packages/remix-dev/compiler/css/index.ts b/packages/remix-dev/compiler/css/index.ts new file mode 100644 index 0000000000..1523b36509 --- /dev/null +++ b/packages/remix-dev/compiler/css/index.ts @@ -0,0 +1,2 @@ +export { create as createCompiler } from "./compiler"; +export { write as writeBundle } from "./bundle"; diff --git a/packages/remix-dev/compiler/css/plugins/bundleEntry.ts b/packages/remix-dev/compiler/css/plugins/bundleEntry.ts new file mode 100644 index 0000000000..4eaa3f4cb0 --- /dev/null +++ b/packages/remix-dev/compiler/css/plugins/bundleEntry.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import type { Plugin } from "esbuild"; + +import type { Context } from "../../context"; + +export const cssBundleEntryModuleId = "__remix_cssBundleEntryModule__"; +const filter = new RegExp(`^${cssBundleEntryModuleId}$`); + +/** + * Creates a virtual module that imports all browser build entry points so that + * all reachable CSS can be included in a single file at the end of the build. + */ +export function cssBundleEntryModulePlugin({ config }: Context): Plugin { + return { + name: "css-bundle-entry-module", + setup(build) { + build.onResolve({ filter }, ({ path }) => { + return { + path, + namespace: "css-bundle-entry-module", + }; + }); + + build.onLoad({ filter }, async () => { + return { + resolveDir: config.appDirectory, + loader: "js", + contents: [ + // These need to be exports to avoid tree shaking + `export * as entryClient from ${JSON.stringify( + path.resolve(config.rootDirectory, config.entryClientFilePath) + )};`, + ...Object.keys(config.routes).map((key, index) => { + let route = config.routes[key]; + return `export * as route${index} from ${JSON.stringify( + `./${route.file}` + )};`; + }), + ].join("\n"), + }; + }); + }, + }; +} diff --git a/packages/remix-dev/compiler/fileWatchCache.ts b/packages/remix-dev/compiler/fileWatchCache.ts new file mode 100644 index 0000000000..b981e579bd --- /dev/null +++ b/packages/remix-dev/compiler/fileWatchCache.ts @@ -0,0 +1,199 @@ +import picomatch from "picomatch"; +import path from "node:path"; + +type CacheValue = { + cacheValue: T; +} & ( + | { fileDependencies?: Set; globDependencies: Set } + | { fileDependencies: Set; globDependencies?: Set } +); + +export interface FileWatchCache { + get(key: string): Promise> | undefined; + set(key: string, promise: Promise>): Promise>; + /** + * #description Get a cache value, or lazily set the value if it doesn't exist + * and then return the new cache value. This lets you interact with the cache + * in a single expression. + */ + getOrSet( + key: string, + lazySetter: () => Promise> + ): Promise>; + invalidateFile(path: string): void; +} + +const globMatchers = new Map>(); +function getGlobMatcher(glob: string) { + let matcher = globMatchers.get(glob); + + if (!matcher) { + matcher = picomatch(normalizeSlashes(glob)); + globMatchers.set(glob, matcher); + } + + return matcher; +} + +export function createFileWatchCache(): FileWatchCache { + let promiseForCacheKey = new Map>>(); + + let fileDepsForCacheKey = new Map>(); + let cacheKeysForFileDep = new Map>(); + + // Glob dependencies are primarily here to support Tailwind. + // Tailwind directives like `@tailwind utilities` output a bunch of + // CSS that changes based on the usage of class names in any file matching + // the globs specified in the `content` array in the Tailwind config, so + // those globs become a dependency of any CSS file using these directives. + let globDepsForCacheKey = new Map>(); + let cacheKeysForGlobDep = new Map>(); + + function invalidateCacheKey(invalidatedCacheKey: string): void { + // If it's not a cache key (or doesn't have a cache entry), bail out + if (!promiseForCacheKey.has(invalidatedCacheKey)) { + return; + } + + promiseForCacheKey.delete(invalidatedCacheKey); + + // Since we keep track of the mapping between cache key and file + // dependencies, we clear all references to the invalidated cache key. + // These will be repopulated when "set" or "getOrSet" are called. + let fileDeps = fileDepsForCacheKey.get(invalidatedCacheKey); + if (fileDeps) { + for (let fileDep of fileDeps) { + cacheKeysForFileDep.get(fileDep)?.delete(invalidatedCacheKey); + } + fileDepsForCacheKey.delete(invalidatedCacheKey); + } + + // Since we keep track of the mapping between cache key and glob + // dependencies, we clear all references to the invalidated cache key. + // These will be repopulated when "set" or "getOrSet" are called. + let globDeps = globDepsForCacheKey.get(invalidatedCacheKey); + if (globDeps) { + for (let glob of globDeps) { + cacheKeysForGlobDep.get(glob)?.delete(invalidatedCacheKey); + } + globDepsForCacheKey.delete(invalidatedCacheKey); + } + } + + function invalidateFile(invalidatedFile: string): void { + // Invalidate all cache entries that depend on the file. + let cacheKeys = cacheKeysForFileDep.get(invalidatedFile); + if (cacheKeys) { + for (let cacheKey of cacheKeys) { + invalidateCacheKey(cacheKey); + } + } + + // Invalidate all cache entries that depend on a glob that matches the file. + // Any glob could match the file, so we have to check all globs. + for (let [glob, cacheKeys] of cacheKeysForGlobDep) { + let match = getGlobMatcher(glob); + if (match && match(normalizeSlashes(invalidatedFile))) { + for (let cacheKey of cacheKeys) { + invalidateCacheKey(cacheKey); + } + } + } + } + + function get(key: string): Promise> | undefined { + return promiseForCacheKey.get(key); + } + + function set( + key: string, + promise: Promise> + ): Promise> { + promiseForCacheKey.set(key, promise); + + void promise + .catch(() => { + // Swallow errors to prevent the build from crashing and remove the + // rejected promise from the cache so consumers can retry + if (promiseForCacheKey.get(key) === promise) { + promiseForCacheKey.delete(key); + } + + return null; + }) + .then((promiseValue) => { + // If the promise was rejected, don't attempt to track dependencies + if (promiseValue === null) { + return; + } + + if (promiseForCacheKey.get(key) !== promise) { + // This cache key was invalidated before the promise resolved + // so we don't want to track the dependencies. + return; + } + + let { fileDependencies, globDependencies } = promiseValue; + + // Track all file dependencies for this entry point so we can invalidate + // all cache entries that depend on a file that was invalidated. + if (fileDependencies) { + let fileDeps = fileDepsForCacheKey.get(key); + if (!fileDeps) { + fileDeps = new Set(); + fileDepsForCacheKey.set(key, fileDeps); + } + for (let fileDep of fileDependencies) { + fileDeps.add(fileDep); + + let cacheKeys = cacheKeysForFileDep.get(fileDep); + if (!cacheKeys) { + cacheKeys = new Set(); + cacheKeysForFileDep.set(fileDep, cacheKeys); + } + cacheKeys.add(key); + } + } + + // Track all glob dependencies for this entry point so we can invalidate + // all cache entries that depend on a glob that matches the invalided file. + if (globDependencies) { + let globDeps = globDepsForCacheKey.get(key); + if (!globDeps) { + globDeps = new Set(); + globDepsForCacheKey.set(key, globDeps); + } + for (let glob of globDependencies) { + globDeps.add(glob); + + let cacheKeys = cacheKeysForGlobDep.get(glob); + if (!cacheKeys) { + cacheKeys = new Set(); + cacheKeysForGlobDep.set(glob, cacheKeys); + } + cacheKeys.add(key); + } + } + }); + + return promise; + } + + function getOrSet( + key: string, + lazySetter: () => Promise> + ): Promise> { + return promiseForCacheKey.get(key) || set(key, lazySetter()); + } + + return { + get, + set, + getOrSet, + invalidateFile, + }; +} + +function normalizeSlashes(file: string) { + return file.split(path.win32.sep).join("/"); +} diff --git a/packages/remix-dev/compiler/index.ts b/packages/remix-dev/compiler/index.ts new file mode 100644 index 0000000000..435aa20bdc --- /dev/null +++ b/packages/remix-dev/compiler/index.ts @@ -0,0 +1,5 @@ +export { build } from "./build"; +export { type WatchOptions, watch } from "./watch"; + +export { type Options as CompileOptions } from "./options"; +export { logThrown } from "./utils/log"; diff --git a/packages/remix-dev/compiler/js/compiler.ts b/packages/remix-dev/compiler/js/compiler.ts new file mode 100644 index 0000000000..1a5ea772af --- /dev/null +++ b/packages/remix-dev/compiler/js/compiler.ts @@ -0,0 +1,186 @@ +import * as path from "node:path"; +import { builtinModules as nodeBuiltins } from "node:module"; +import * as esbuild from "esbuild"; + +import type { RemixConfig } from "../../config"; +import { type Manifest } from "../../manifest"; +import { getAppDependencies } from "../../dependencies"; +import { loaders } from "../utils/loaders"; +import { browserRouteModulesPlugin } from "./plugins/routes"; +import { cssFilePlugin } from "../plugins/cssImports"; +import { absoluteCssUrlsPlugin } from "../plugins/absoluteCssUrlsPlugin"; +import { emptyModulesPlugin } from "../plugins/emptyModules"; +import { mdxPlugin } from "../plugins/mdx"; +import { externalPlugin } from "../plugins/external"; +import { browserNodeBuiltinsPolyfillPlugin } from "./plugins/browserNodeBuiltinsPolyfill"; +import { cssBundlePlugin } from "../plugins/cssBundlePlugin"; +import { cssModulesPlugin } from "../plugins/cssModuleImports"; +import { cssSideEffectImportsPlugin } from "../plugins/cssSideEffectImports"; +import { vanillaExtractPlugin } from "../plugins/vanillaExtract"; +import invariant from "../../invariant"; +import { hmrPlugin } from "./plugins/hmr"; +import type { LazyValue } from "../lazyValue"; +import type { Context } from "../context"; +import { writeMetafile } from "../analysis"; + +type Compiler = { + // produce ./public/build/ + compile: () => Promise<{ + metafile: esbuild.Metafile; + outputFiles: esbuild.OutputFile[]; + hmr?: Manifest["hmr"]; + }>; + cancel: () => Promise; + dispose: () => Promise; +}; + +const getFakeBuiltins = (remixConfig: RemixConfig): string[] => { + let dependencies = Object.keys(getAppDependencies(remixConfig)); + let fakeBuiltins = nodeBuiltins.filter((mod) => dependencies.includes(mod)); + return fakeBuiltins; +}; + +const createEsbuildConfig = ( + ctx: Context, + refs: { lazyCssBundleHref: LazyValue } +): esbuild.BuildOptions => { + let entryPoints: Record = { + "entry.client": ctx.config.entryClientFilePath, + }; + + for (let id of Object.keys(ctx.config.routes)) { + entryPoints[id] = ctx.config.routes[id].file; + // All route entry points are virtual modules that will be loaded by the + // browserEntryPointsPlugin. This allows us to tree-shake server-only code + // that we don't want to run in the browser (i.e. action & loader). + entryPoints[id] += "?browser"; + } + + if (ctx.options.mode === "development") { + let defaultsDirectory = path.resolve( + __dirname, + "..", + "..", + "config", + "defaults" + ); + entryPoints["__remix_entry_dev"] = path.join( + defaultsDirectory, + "entry.dev.ts" + ); + } + + let fakeBuiltins = getFakeBuiltins(ctx.config); + if (fakeBuiltins.length > 0) { + throw new Error( + `It appears you're using a module that is built in to Node, but you installed it as a dependency which could cause problems. Please remove ${fakeBuiltins.join( + ", " + )} before continuing.` + ); + } + + let plugins: esbuild.Plugin[] = [ + browserRouteModulesPlugin(ctx, /\?browser$/), + cssBundlePlugin(refs), + cssModulesPlugin(ctx, { outputCss: false }), + vanillaExtractPlugin(ctx, { outputCss: false }), + cssSideEffectImportsPlugin(ctx, { + hmr: ctx.options.mode === "development", + }), + cssFilePlugin(ctx), + absoluteCssUrlsPlugin(), + externalPlugin(/^https?:\/\//, { sideEffects: false }), + mdxPlugin(ctx), + emptyModulesPlugin(ctx, /\.server(\.[jt]sx?)?$/), + emptyModulesPlugin(ctx, /^@remix-run\/(deno|cloudflare|node)(\/.*)?$/, { + includeNodeModules: true, + }), + browserNodeBuiltinsPolyfillPlugin(ctx), + ]; + + if (ctx.options.mode === "development") { + plugins.push(hmrPlugin(ctx)); + } + + return { + entryPoints, + outdir: ctx.config.assetsBuildDirectory, + platform: "browser", + format: "esm", + loader: loaders, + bundle: true, + logLevel: "silent", + splitting: true, + sourcemap: ctx.options.sourcemap, + // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to + // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted + // behavior can only be avoided by creating an empty tsconfig file in the root directory. + tsconfig: ctx.config.tsconfigPath, + mainFields: ["browser", "module", "main"], + treeShaking: true, + minify: ctx.options.mode === "production", + entryNames: "[dir]/[name]-[hash]", + chunkNames: "_shared/[name]-[hash]", + assetNames: "_assets/[name]-[hash]", + publicPath: ctx.config.publicPath, + define: { + "process.env.NODE_ENV": JSON.stringify(ctx.options.mode), + "process.env.REMIX_DEV_ORIGIN": JSON.stringify( + ctx.options.REMIX_DEV_ORIGIN ?? "" + ), + ...(ctx.options.mode === "production" + ? { + "import.meta.hot": "undefined", + } + : {}), + }, + jsx: "automatic", + jsxDev: ctx.options.mode !== "production", + plugins, + supported: { + "import-meta": true, + }, + }; +}; + +export const create = async ( + ctx: Context, + refs: { lazyCssBundleHref: LazyValue } +): Promise => { + let compiler = await esbuild.context({ + ...createEsbuildConfig(ctx, refs), + write: false, + metafile: true, + }); + + let compile = async () => { + let { metafile, outputFiles } = await compiler.rebuild(); + writeMetafile(ctx, "metafile.js.json", metafile); + + let hmr: Manifest["hmr"] | undefined = undefined; + if (ctx.options.mode === "development") { + let hmrRuntimeOutput = Object.entries(metafile.outputs).find( + ([_, output]) => output.inputs["hmr-runtime:remix:hmr"] + )?.[0]; + invariant(hmrRuntimeOutput, "Expected to find HMR runtime in outputs"); + let hmrRuntime = + ctx.config.publicPath + + path.relative( + ctx.config.assetsBuildDirectory, + path.resolve(hmrRuntimeOutput) + ); + hmr = { + runtime: hmrRuntime, + timestamp: Date.now(), + }; + } + + return { metafile, hmr, outputFiles }; + }; + + return { + compile, + cancel: compiler.cancel, + dispose: compiler.dispose, + }; +}; diff --git a/packages/remix-dev/compiler/js/index.ts b/packages/remix-dev/compiler/js/index.ts new file mode 100644 index 0000000000..c67a1d153e --- /dev/null +++ b/packages/remix-dev/compiler/js/index.ts @@ -0,0 +1,2 @@ +export { create as createCompiler } from "./compiler"; +export { write } from "./write"; diff --git a/packages/remix-dev/compiler/js/plugins/browserNodeBuiltinsPolyfill.ts b/packages/remix-dev/compiler/js/plugins/browserNodeBuiltinsPolyfill.ts new file mode 100644 index 0000000000..f655a19d80 --- /dev/null +++ b/packages/remix-dev/compiler/js/plugins/browserNodeBuiltinsPolyfill.ts @@ -0,0 +1,37 @@ +import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; + +import type { Context } from "../../context"; + +export const browserNodeBuiltinsPolyfillPlugin = (ctx: Context) => + nodeModulesPolyfillPlugin({ + // Rename plugin to improve error message attribution + name: "browser-node-builtins-polyfill-plugin", + // Only pass through the "modules" and "globals" options to ensure we + // don't leak the full plugin API to Remix consumers. + modules: ctx.config.browserNodeBuiltinsPolyfill?.modules ?? {}, + globals: ctx.config.browserNodeBuiltinsPolyfill?.globals ?? {}, + // Mark any unpolyfilled Node builtins in the build output as errors. + fallback: "error", + formatError({ moduleName, importer, polyfillExists }) { + let normalizedModuleName = moduleName.replace("node:", ""); + let modulesConfigKey = /^[a-z_]+$/.test(normalizedModuleName) + ? normalizedModuleName + : JSON.stringify(normalizedModuleName); + + return { + text: (polyfillExists + ? [ + `Node builtin "${moduleName}" (imported by "${importer}") must be polyfilled for the browser. `, + `You can enable this polyfill in your Remix config, `, + `e.g. \`browserNodeBuiltinsPolyfill: { modules: { ${modulesConfigKey}: true } }\``, + ] + : [ + `Node builtin "${moduleName}" (imported by "${importer}") doesn't have a browser polyfill available. `, + `You can stub it out with an empty object in your Remix config `, + `e.g. \`browserNodeBuiltinsPolyfill: { modules: { ${modulesConfigKey}: "empty" } }\` `, + "but note that this may cause runtime errors if the module is used in your browser code.", + ] + ).join(""), + }; + }, + }); diff --git a/packages/remix-dev/compiler/js/plugins/hmr.ts b/packages/remix-dev/compiler/js/plugins/hmr.ts new file mode 100644 index 0000000000..4ddc0f58ca --- /dev/null +++ b/packages/remix-dev/compiler/js/plugins/hmr.ts @@ -0,0 +1,239 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type * as esbuild from "esbuild"; + +import type { RemixConfig } from "../../../config"; +import type { Context } from "../../context"; + +export let hmrPlugin = ({ config }: Context): esbuild.Plugin => { + return { + name: "remix-hmr", + setup: async (build) => { + let cache = new Map(); + + build.onResolve({ filter: /^remix:hmr$/ }, (args) => { + return { + namespace: "hmr-runtime", + path: args.path, + }; + }); + build.onLoad({ filter: /.*/, namespace: "hmr-runtime" }, () => { + let reactRefreshRuntime = require + .resolve("react-refresh/runtime") + .replace(/\\/g, "/"); + let contents = ` +import RefreshRuntime from "${reactRefreshRuntime}"; + +declare global { + interface Window { + $RefreshReg$: any; + $RefreshSig$: any; + } +} + +var prevRefreshReg = window.$RefreshReg$; +var prevRefreshSig = window.$RefreshSig$; + +window.$RefreshReg$ = (type, id) => { + const fullId = id; + RefreshRuntime.register(type, fullId); +}; +window.$RefreshReg$ = prevRefreshReg; +window.$RefreshSig$ = prevRefreshSig; +window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; +window.$RefreshRuntime$ = RefreshRuntime; + +window.$RefreshRuntime$.injectIntoGlobalHook(window); +window.$RefreshReg$ = () => {}; +window.$RefreshSig$ = () => (type) => type; + +if (!window.__hmr__) { + window.__hmr__ = { + contexts: {}, + }; +} + +export function createHotContext(id: string): ImportMetaHot { + let callback: undefined | ((mod: ModuleNamespace) => void); + let disposed = false; + + let hot = { + accept: (dep, cb) => { + if (typeof dep !== "string") { + cb = dep; + dep = undefined; + } + if (dep) { + if (window.__hmr__.contexts[dep]) { + window.__hmr__.contexts[dep].dispose(); + } + window.__hmr__.contexts[dep] = createHotContext(dep); + window.__hmr__.contexts[dep].accept(cb); + return; + } + if (disposed) { + throw new Error("import.meta.hot.accept() called after dispose()"); + } + if (callback) { + throw new Error("import.meta.hot.accept() already called"); + } + callback = cb; + }, + dispose: () => { + disposed = true; + }, + emit(self: ModuleNamespace) { + if (callback) { + callback(self); + return true; + } + return false; + }, + }; + + if (window.__hmr__.contexts[id]) { + window.__hmr__.contexts[id].dispose(); + } + window.__hmr__.contexts[id] = hot; + + return hot; +} + +declare global { + interface Window { + __hmr__: any; + } +} + `; + return { loader: "ts", contents, resolveDir: config.appDirectory }; + }); + + // This is only needed within the Remix repo because the symlink to the + // `packages/remix-react` folder doesn't match the regex below + let remixReactPath = require.resolve( + "@remix-run/react/dist/esm/browser.js", + { paths: [config.rootDirectory] } + ); + + build.onLoad({ filter: /.*/, namespace: "file" }, async (args) => { + if ( + args.path !== remixReactPath && + !args.path.match( + /@remix-run[/\\]react[/\\]dist[/\\]esm[/\\]browser.js$/ + ) && + !args.path.match(/react-router[-dom]?[/\\]$/) && + (!args.path.match(/\.[tj]sx?$/) || + !fs.existsSync(args.path) || + !args.path.startsWith(config.appDirectory)) + ) { + return undefined; + } + + let sourceCode = fs.readFileSync(args.path, "utf8"); + + let value = cache.get(args.path); + + if (!value || value.sourceCode !== sourceCode) { + let resultCode = await applyHMR( + sourceCode, + args, + config, + !!build.initialOptions.sourcemap, + args.path.startsWith(config.appDirectory) + ? fs.statSync(args.path).mtimeMs + : undefined + ); + value = { + sourceCode, + output: { + contents: resultCode, + loader: args.path.endsWith(".ts") ? "ts" : "tsx", + resolveDir: path.dirname(args.path), + }, + }; + cache.set(args.path, value); + } + + return value.output; + }); + }, + }; +}; + +export async function applyHMR( + sourceCode: string, + args: esbuild.OnLoadArgs, + remixConfig: RemixConfig, + sourcemap: boolean, + lastModified?: number +) { + let babel = await import("@babel/core"); + // @ts-expect-error + let babelPresetTypescript = await import("@babel/preset-typescript"); + // @ts-expect-error + let babelJsx = await import("@babel/plugin-syntax-jsx"); + // @ts-expect-error + let reactRefresh = await import("react-refresh/babel"); + // @ts-expect-error + let babelDecorators = await import("@babel/plugin-syntax-decorators"); + + let IS_FAST_REFRESH_ENABLED = /\$RefreshReg\$\(/; + + // add import.meta.hot to the module + let argsPath = args.path; + let hmrId = JSON.stringify( + path.relative(remixConfig.rootDirectory, argsPath) + ); + let hmrPrefix = `import * as __hmr__ from "remix:hmr"; +if (import.meta) { +import.meta.hot = __hmr__.createHotContext( +//@ts-expect-error +$id$ +); +${lastModified ? `import.meta.hot.lastModified = "${lastModified}";` : ""} +} +// REMIX HMR END +\n`.replace(/\$id\$/g, hmrId); + let sourceCodeWithHMR = hmrPrefix + sourceCode; + + // run babel to add react-refresh + let transformResult = babel.transformSync(sourceCodeWithHMR, { + filename: argsPath, + ast: false, + compact: false, + sourceMaps: sourcemap, + configFile: false, + babelrc: false, + presets: [babelPresetTypescript.default], + plugins: [ + [babelDecorators.default, { legacy: true }], + babelJsx.default, + [reactRefresh.default, { skipEnvCheck: true }], + ], + }); + + let jsWithReactRefresh = transformResult?.code ?? sourceCodeWithHMR; + + // auto opt-in to accepting fast refresh updates if the module + // has react components + if (!IS_FAST_REFRESH_ENABLED.test(jsWithReactRefresh)) { + return "// REMIX HMR BEGIN\n" + sourceCodeWithHMR; + } + return ( + `// REMIX HMR BEGIN +if (!window.$RefreshReg$ || !window.$RefreshSig$ || !window.$RefreshRuntime$) { + console.warn('remix:hmr: React Fast Refresh only works when the Remix compiler is running in development mode.'); +} else { + var prevRefreshReg = window.$RefreshReg$; + var prevRefreshSig = window.$RefreshSig$; + window.$RefreshReg$ = (type, id) => { + window.$RefreshRuntime$.register(type, ${JSON.stringify(hmrId)} + id); + } + window.$RefreshSig$ = window.$RefreshRuntime$.createSignatureFunctionForTransform; +}\n` + + jsWithReactRefresh + + `\n +window.$RefreshReg$ = prevRefreshReg; +window.$RefreshSig$ = prevRefreshSig;` + ); +} diff --git a/packages/remix-dev/compiler/js/plugins/routes.ts b/packages/remix-dev/compiler/js/plugins/routes.ts new file mode 100644 index 0000000000..33b5e5411c --- /dev/null +++ b/packages/remix-dev/compiler/js/plugins/routes.ts @@ -0,0 +1,107 @@ +import * as path from "node:path"; +import type esbuild from "esbuild"; + +import type { RemixConfig } from "../../../config"; +import { getRouteModuleExports } from "../../utils/routeExports"; +import invariant from "../../../invariant"; +import type { Context } from "../../context"; + +type Route = RemixConfig["routes"][string]; + +// If you change this, make sure you update loadRouteModuleWithBlockingLinks in +// remix-react/routes.ts +const browserSafeRouteExports: { [name: string]: boolean } = { + clientAction: true, + clientLoader: true, + ErrorBoundary: true, + HydrateFallback: true, + Layout: true, + default: true, + handle: true, + links: true, + meta: true, + shouldRevalidate: true, +}; + +/** + * This plugin loads route modules for the browser build, using module shims + * that re-export only the route module exports that are safe for the browser. + */ +export function browserRouteModulesPlugin( + { config, fileWatchCache }: Context, + suffixMatcher: RegExp +): esbuild.Plugin { + return { + name: "browser-route-modules", + async setup(build) { + let routesByFile: Map = Object.keys(config.routes).reduce( + (map, key) => { + let route = config.routes[key]; + map.set(route.file, route); + return map; + }, + new Map() + ); + + build.onResolve({ filter: suffixMatcher }, (args) => { + return { + path: args.path, + namespace: "browser-route-module", + }; + }); + + build.onLoad( + { filter: suffixMatcher, namespace: "browser-route-module" }, + async (args) => { + let theExports; + let file = args.path.replace(suffixMatcher, ""); + let route = routesByFile.get(file); + + try { + invariant(route, `Cannot get route by path: ${args.path}`); + + let cacheKey = `module-exports:${route.id}`; + let { cacheValue: sourceExports } = await fileWatchCache.getOrSet( + cacheKey, + async () => { + let file = path.resolve( + config.appDirectory, + config.routes[route!.id].file + ); + return { + cacheValue: await getRouteModuleExports(config, route!.id), + fileDependencies: new Set([file]), + }; + } + ); + + theExports = sourceExports.filter( + (ex) => !!browserSafeRouteExports[ex] + ); + } catch (error: any) { + return { + errors: [ + { + text: error.message, + pluginName: "browser-route-module", + }, + ], + }; + } + + let contents = "module.exports = {};"; + if (theExports.length !== 0) { + let spec = `{ ${theExports.join(", ")} }`; + contents = `export ${spec} from ${JSON.stringify(`./${file}`)};`; + } + + return { + contents, + resolveDir: config.appDirectory, + loader: "js", + }; + } + ); + }, + }; +} diff --git a/packages/remix-dev/compiler/js/write.ts b/packages/remix-dev/compiler/js/write.ts new file mode 100644 index 0000000000..58a9c81e64 --- /dev/null +++ b/packages/remix-dev/compiler/js/write.ts @@ -0,0 +1,14 @@ +import * as path from "node:path"; +import type { OutputFile } from "esbuild"; +import fse from "fs-extra"; + +import type { RemixConfig } from "../../config"; + +export async function write(config: RemixConfig, outputFiles: OutputFile[]) { + await fse.ensureDir(path.dirname(config.assetsBuildDirectory)); + + for (let file of outputFiles) { + await fse.ensureDir(path.dirname(file.path)); + await fse.writeFile(file.path, file.contents); + } +} diff --git a/packages/remix-dev/compiler/lazyValue.ts b/packages/remix-dev/compiler/lazyValue.ts new file mode 100644 index 0000000000..cb62817150 --- /dev/null +++ b/packages/remix-dev/compiler/lazyValue.ts @@ -0,0 +1,45 @@ +import * as Channel from "../channel"; + +export type LazyValue = { + get: () => Promise; + cancel: () => void; +}; + +export const createLazyValue = (args: { + get: () => Promise; + onCancel?: (args: { + resolve: (value: T) => void; + reject: (err?: any) => void; + }) => void; +}): LazyValue => { + let channel: Channel.Type | undefined; + + return { + async get() { + // Create channel and request lazy value on first `get` call + if (!channel) { + channel = Channel.create(); + try { + channel.ok(await args.get()); + } catch (err) { + channel.err(err); + } + } + + // Share the same result with all callers + let result = await channel.result; + + if (!result.ok) { + throw result.error; + } + + return result.value; + }, + cancel() { + args.onCancel?.({ + resolve: (value) => channel?.ok(value), + reject: (error) => channel?.err(error), + }); + }, + }; +}; diff --git a/packages/remix-dev/compiler/manifest.ts b/packages/remix-dev/compiler/manifest.ts new file mode 100644 index 0000000000..b75da0a1b8 --- /dev/null +++ b/packages/remix-dev/compiler/manifest.ts @@ -0,0 +1,195 @@ +import * as path from "node:path"; +import { promises as fsp } from "node:fs"; +import type * as esbuild from "esbuild"; + +import type { RemixConfig } from "../config"; +import invariant from "../invariant"; +import { type Manifest } from "../manifest"; +import { getRouteModuleExports } from "./utils/routeExports"; +import { getHash } from "./utils/crypto"; +import { type FileWatchCache } from "./fileWatchCache"; + +type Route = RemixConfig["routes"][string]; + +export async function create({ + config, + metafile, + hmr, + fileWatchCache, +}: { + config: RemixConfig; + metafile: esbuild.Metafile; + hmr?: Manifest["hmr"]; + fileWatchCache: FileWatchCache; +}): Promise { + function resolveUrl(outputPath: string): string { + return createUrl( + config.publicPath, + path.relative(config.assetsBuildDirectory, path.resolve(outputPath)) + ); + } + + function resolveImports( + imports: esbuild.Metafile["outputs"][string]["imports"] + ): string[] { + return imports + .filter((im) => im.kind === "import-statement") + .map((im) => resolveUrl(im.path)); + } + + let routesByFile: Map = Object.keys(config.routes).reduce( + (map, key) => { + let route = config.routes[key]; + map.set( + route.file, + map.has(route.file) ? [...map.get(route.file), route] : [route] + ); + return map; + }, + new Map() + ); + + let entry: Manifest["entry"] | undefined; + let routes: Manifest["routes"] = {}; + + for (let key of Object.keys(metafile.outputs).sort()) { + let output = metafile.outputs[key]; + if (!output.entryPoint) continue; + + if (path.resolve(output.entryPoint) === config.entryClientFilePath) { + entry = { + module: resolveUrl(key), + imports: resolveImports(output.imports), + }; + // Only parse routes otherwise dynamic imports can fall into here and fail the build + } else if (output.entryPoint.startsWith("browser-route-module:")) { + let entryPointFile = output.entryPoint.replace( + /(^browser-route-module:|\?browser$)/g, + "" + ); + let groupedRoute = routesByFile.get(entryPointFile); + invariant( + groupedRoute, + `Cannot get route(s) for entry point ${output.entryPoint}` + ); + for (let route of groupedRoute) { + let cacheKey = `module-exports:${route.id}`; + let { cacheValue: sourceExports } = await fileWatchCache.getOrSet( + cacheKey, + async () => { + let file = path.resolve( + config.appDirectory, + config.routes[route.id].file + ); + return { + cacheValue: await getRouteModuleExports(config, route.id), + fileDependencies: new Set([file]), + }; + } + ); + + routes[route.id] = { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + module: resolveUrl(key), + imports: resolveImports(output.imports), + hasAction: sourceExports.includes("action"), + hasLoader: sourceExports.includes("loader"), + hasClientAction: sourceExports.includes("clientAction"), + hasClientLoader: sourceExports.includes("clientLoader"), + hasErrorBoundary: sourceExports.includes("ErrorBoundary"), + }; + } + } + } + + invariant(entry, `Missing output for entry point`); + + optimizeRoutes(routes, entry.imports); + + let fingerprintedValues = { + entry, + routes, + }; + + let version = getHash(JSON.stringify(fingerprintedValues)).slice(0, 8); + + let nonFingerprintedValues = { + version, + hmr, + }; + + return { + ...fingerprintedValues, + ...nonFingerprintedValues, + }; +} + +export const write = async (config: RemixConfig, assetsManifest: Manifest) => { + let filename = `manifest-${assetsManifest.version.toUpperCase()}.js`; + + assetsManifest.url = config.publicPath + filename; + + await writeFileSafe( + path.join(config.assetsBuildDirectory, filename), + `window.__remixManifest=${JSON.stringify(assetsManifest)};` + ); +}; + +async function writeFileSafe(file: string, contents: string): Promise { + await fsp.mkdir(path.dirname(file), { recursive: true }); + await fsp.writeFile(file, contents); + return file; +} + +function createUrl(publicPath: string, file: string): string { + return publicPath + file.split(path.win32.sep).join("/"); +} + +type ImportsCache = { [routeId: string]: string[] }; + +function optimizeRoutes( + routes: Manifest["routes"], + entryImports: string[] +): void { + // This cache is an optimization that allows us to avoid pruning the same + // route's imports more than once. + let importsCache: ImportsCache = Object.create(null); + + for (let key in routes) { + optimizeRouteImports(key, routes, entryImports, importsCache); + } +} + +function optimizeRouteImports( + routeId: string, + routes: Manifest["routes"], + parentImports: string[], + importsCache: ImportsCache +): string[] { + if (importsCache[routeId]) return importsCache[routeId]; + + let route = routes[routeId]; + + if (route.parentId) { + parentImports = parentImports.concat( + optimizeRouteImports(route.parentId, routes, parentImports, importsCache) + ); + } + + let routeImports = (route.imports || []).filter( + (url) => !parentImports.includes(url) + ); + + // Setting `route.imports = undefined` prevents `imports: []` from showing up + // in the manifest JSON when there are no imports. + route.imports = routeImports.length > 0 ? routeImports : undefined; + + // Cache so the next lookup for this route is faster. + importsCache[routeId] = routeImports; + + return routeImports; +} diff --git a/packages/remix-dev/compiler/options.ts b/packages/remix-dev/compiler/options.ts new file mode 100644 index 0000000000..fe31834edd --- /dev/null +++ b/packages/remix-dev/compiler/options.ts @@ -0,0 +1,8 @@ +type Mode = "development" | "production" | "test"; + +export type Options = { + mode: Mode | Omit; + sourcemap: boolean; + + REMIX_DEV_ORIGIN?: URL; // TODO: required in v2 +}; diff --git a/packages/remix-dev/compiler/plugins/absoluteCssUrlsPlugin.ts b/packages/remix-dev/compiler/plugins/absoluteCssUrlsPlugin.ts new file mode 100644 index 0000000000..21df14e5a4 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/absoluteCssUrlsPlugin.ts @@ -0,0 +1,23 @@ +import path from "node:path"; +import type { Plugin, PluginBuild } from "esbuild"; + +/** + * This plugin treats absolute paths in 'url()' css rules as external to prevent + * breaking changes + */ +export const absoluteCssUrlsPlugin = (): Plugin => { + return { + name: "absolute-css-urls-plugin", + setup: async (build: PluginBuild) => { + build.onResolve({ filter: /.*/ }, async (args) => { + let { kind, path: resolvePath } = args; + if (kind === "url-token" && path.isAbsolute(resolvePath)) { + return { + path: resolvePath, + external: true, + }; + } + }); + }, + }; +}; diff --git a/packages/remix-dev/compiler/plugins/cssBundlePlugin.ts b/packages/remix-dev/compiler/plugins/cssBundlePlugin.ts new file mode 100644 index 0000000000..960dbd5dfe --- /dev/null +++ b/packages/remix-dev/compiler/plugins/cssBundlePlugin.ts @@ -0,0 +1,38 @@ +import type { Plugin } from "esbuild"; + +import type { LazyValue } from "../lazyValue"; + +const pluginName = "css-bundle-plugin"; +const namespace = `${pluginName}-ns`; + +/** + * This plugin lazily requests the CSS bundle href and then injects it into the + * JS for `@remix-run/css-bundle`. This ensures we only run the CSS bundle build + * if necessary and that changes to the CSS bundle result in an HMR update. + */ +export function cssBundlePlugin(refs: { + lazyCssBundleHref: LazyValue; +}): Plugin { + return { + name: pluginName, + async setup(build) { + build.onResolve({ filter: /^@remix-run\/css-bundle$/ }, async (args) => { + return { + path: args.path, + namespace, + }; + }); + + build.onLoad({ filter: /.*/, namespace }, async () => { + let cssBundleHref = await refs.lazyCssBundleHref.get(); + + return { + loader: "js", + contents: `export const cssBundleHref = ${ + cssBundleHref ? JSON.stringify(cssBundleHref) : "undefined" + };`, + }; + }); + }, + }; +} diff --git a/packages/remix-dev/compiler/plugins/cssImports.ts b/packages/remix-dev/compiler/plugins/cssImports.ts new file mode 100644 index 0000000000..8fe9aaaaaf --- /dev/null +++ b/packages/remix-dev/compiler/plugins/cssImports.ts @@ -0,0 +1,203 @@ +import * as path from "node:path"; +import fse from "fs-extra"; +import esbuild from "esbuild"; + +import invariant from "../../invariant"; +import type { Context } from "../context"; +import { + getPostcssProcessor, + populateDependenciesFromMessages, +} from "../utils/postcss"; +import { absoluteCssUrlsPlugin } from "./absoluteCssUrlsPlugin"; + +const isExtendedLengthPath = /^\\\\\?\\/; + +function normalizePathSlashes(p: string) { + return isExtendedLengthPath.test(p) ? p : p.replace(/\\/g, "/"); +} + +/** + * This plugin loads css files with the "css" loader (bundles and moves assets to assets directory) + * and exports the url of the css file as its default export. + */ +export function cssFilePlugin(ctx: Context): esbuild.Plugin { + return { + name: "css-file", + + async setup(build) { + let { + absWorkingDir, + assetNames, + chunkNames, + conditions, + define, + external, + sourceRoot, + treeShaking, + tsconfig, + format, + loader, + mainFields, + nodePaths, + platform, + publicPath, + target, + } = build.initialOptions; + + build.onLoad({ filter: /\.css$/ }, async (args) => { + let cacheKey = `css-file:${args.path}`; + let { + cacheValue: { + contents, + watchFiles, + warnings, + outputFilesWithoutEntry, + }, + } = await ctx.fileWatchCache.getOrSet(cacheKey, async () => { + let fileDependencies = new Set([args.path]); + let globDependencies = new Set(); + + // eslint-disable-next-line prefer-let/prefer-let -- Avoid needing to repeatedly check for null since const can't be reassigned + const postcssProcessor = await getPostcssProcessor(ctx); + + let { metafile, outputFiles, warnings, errors } = await esbuild.build( + { + absWorkingDir, + assetNames, + chunkNames, + conditions, + define, + external, + format, + mainFields, + nodePaths, + platform, + publicPath, + sourceRoot, + target, + treeShaking, + tsconfig, + minify: ctx.options.mode === "production", + bundle: true, + minifySyntax: true, + metafile: true, + write: false, + sourcemap: Boolean(ctx.options.sourcemap && postcssProcessor), // We only need source maps if we're processing the CSS with PostCSS + splitting: false, + outdir: ctx.config.assetsBuildDirectory, + entryNames: assetNames, + entryPoints: [args.path], + loader: { + ...loader, + ".css": "css", + }, + plugins: [ + absoluteCssUrlsPlugin(), + ...(postcssProcessor + ? [ + { + name: "postcss-plugin", + async setup(build) { + build.onLoad( + { filter: /\.css$/, namespace: "file" }, + async (args) => { + let contents = await fse.readFile( + args.path, + "utf-8" + ); + + let { css, messages } = + await postcssProcessor.process(contents, { + from: args.path, + to: args.path, + map: ctx.options.sourcemap, + }); + + populateDependenciesFromMessages({ + messages, + fileDependencies, + globDependencies, + }); + + return { + contents: css, + loader: "css", + }; + } + ); + }, + } satisfies esbuild.Plugin, + ] + : []), + ], + } + ); + + if (errors && errors.length) { + throw { errors }; + } + + invariant(metafile, "metafile is missing"); + let { outputs } = metafile; + let entry = Object.keys(outputs).find( + (out) => outputs[out].entryPoint + ); + invariant(entry, "entry point not found"); + + let normalizedEntry = path.resolve( + ctx.config.rootDirectory, + normalizePathSlashes(entry) + ); + let entryFile = outputFiles.find((file) => { + return ( + path.resolve( + ctx.config.rootDirectory, + normalizePathSlashes(file.path) + ) === normalizedEntry + ); + }); + + invariant(entryFile, "entry file not found"); + + let outputFilesWithoutEntry = outputFiles.filter( + (file) => file !== entryFile + ); + + // add all css assets to dependencies + for (let { inputs } of Object.values(outputs)) { + for (let input of Object.keys(inputs)) { + let resolvedInput = path.resolve(input); + fileDependencies.add(resolvedInput); + } + } + + return { + cacheValue: { + contents: entryFile.contents, + // add all dependencies to watchFiles + watchFiles: Array.from(fileDependencies), + warnings, + outputFilesWithoutEntry, + }, + fileDependencies, + globDependencies, + }; + }); + + // write all assets + await Promise.all( + outputFilesWithoutEntry.map(({ path: filepath, contents }) => + fse.outputFile(filepath, contents) + ) + ); + + return { + contents, + loader: "file", + watchFiles, + warnings, + }; + }); + }, + }; +} diff --git a/packages/remix-dev/compiler/plugins/cssModuleImports.ts b/packages/remix-dev/compiler/plugins/cssModuleImports.ts new file mode 100644 index 0000000000..cee560cd20 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/cssModuleImports.ts @@ -0,0 +1,173 @@ +import path from "node:path"; +import type { Plugin, PluginBuild } from "esbuild"; +import fse from "fs-extra"; +import postcss from "postcss"; +import postcssModules from "postcss-modules"; + +import { + loadPostcssPlugins, + populateDependenciesFromMessages, +} from "../utils/postcss"; +import type { Context } from "../context"; + +const pluginName = "css-modules-plugin"; +const namespace = `${pluginName}-ns`; +const cssModulesFilter = /\.module\.css$/; +const compiledCssQuery = "?css-modules-plugin-compiled-css"; +const compiledCssFilter = /\?css-modules-plugin-compiled-css$/; + +interface PluginData { + resolveDir: string; + compiledCss: string; +} + +export const cssModulesPlugin = ( + { config, options, fileWatchCache }: Context, + { outputCss }: { outputCss: boolean } +): Plugin => { + return { + name: pluginName, + setup: async (build: PluginBuild) => { + build.onResolve( + { filter: cssModulesFilter, namespace: "file" }, + async (args) => { + let resolvedPath = ( + await build.resolve(args.path, { + resolveDir: args.resolveDir, + kind: args.kind, + }) + ).path; + + return { + path: resolvedPath, + }; + } + ); + + build.onLoad({ filter: cssModulesFilter }, async (args) => { + let { path: absolutePath } = args; + let resolveDir = path.dirname(absolutePath); + + let cacheKey = `css-module:${absolutePath}?mode=${options.mode}`; + let { cacheValue } = await fileWatchCache.getOrSet( + cacheKey, + async () => { + let fileContents = await fse.readFile(absolutePath, "utf8"); + let exports: Record = {}; + + let fileDependencies = new Set([absolutePath]); + let globDependencies = new Set(); + + let postcssPlugins = await loadPostcssPlugins({ config }); + + let { css: compiledCss, messages } = await postcss([ + ...postcssPlugins, + postcssModules({ + generateScopedName: + options.mode === "production" + ? "[hash:base64:5]" + : "[name]__[local]__[hash:base64:5]", + getJSON: function (_, json) { + exports = json; + }, + async resolve(id, importer) { + let resolvedPath = ( + await build.resolve(id, { + resolveDir: path.dirname(importer), + kind: "require-resolve", + }) + ).path; + + // Since postcss-modules doesn't add `dependency` messages the + // way other plugins do, we mark any files that are passed to + // the `resolve` callback as dependencies of this CSS Module + fileDependencies.add(resolvedPath); + + return resolvedPath; + }, + }), + ]).process(fileContents, { + from: absolutePath, + to: absolutePath, + }); + + // Since we're also running with arbitrary user-defined PostCSS + // plugins, we need to manage dependencies declared by other plugins + populateDependenciesFromMessages({ + messages, + fileDependencies, + globDependencies, + }); + + let compiledJsWithoutCssImport = `export default ${JSON.stringify( + exports + )};`; + + // Each .module.css file ultimately resolves as a JS file that imports + // a virtual CSS file containing the compiled CSS, and exports the + // object that maps local names to generated class names. The compiled + // CSS file contents are passed to the virtual CSS file via pluginData. + let compiledJsWithCssImport = [ + `import "./${path.basename(absolutePath)}${compiledCssQuery}";`, + compiledJsWithoutCssImport, + ].join("\n"); + + return { + cacheValue: { + // We need to cache both variants of the compiled JS since the + // cache is shared between different builds. This allows each + // build to ask for the JS variant it needs without needing to + // generate its own custom JS on every build. + compiledJsWithCssImport, + compiledJsWithoutCssImport, + compiledCss, + }, + fileDependencies, + }; + } + ); + + let { + compiledJsWithCssImport, + compiledJsWithoutCssImport, + compiledCss, + } = cacheValue; + + let pluginData: PluginData = { + resolveDir, + compiledCss, + }; + + return { + contents: outputCss + ? compiledJsWithCssImport + : compiledJsWithoutCssImport, + loader: "js" as const, + pluginData, + }; + }); + + build.onResolve({ filter: compiledCssFilter }, async (args) => { + let pluginData: PluginData = args.pluginData; + let absolutePath = path.resolve(args.resolveDir, args.path); + + return { + namespace, + path: path.relative(config.rootDirectory, absolutePath), + pluginData, + }; + }); + + build.onLoad({ filter: compiledCssFilter, namespace }, async (args) => { + let pluginData: PluginData = args.pluginData; + let { resolveDir, compiledCss } = pluginData; + + return { + resolveDir, + contents: compiledCss, + loader: "css" as const, + }; + }); + }, + }; +}; diff --git a/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts b/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts new file mode 100644 index 0000000000..1daa196509 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts @@ -0,0 +1,209 @@ +import path from "node:path"; +import type { Plugin } from "esbuild"; +import fse from "fs-extra"; +import { parse, type ParserOptions } from "@babel/parser"; +import traverse from "@babel/traverse"; +import generate from "@babel/generator"; + +import { getCachedPostcssProcessor } from "../utils/postcss"; +import { applyHMR } from "../js/plugins/hmr"; +import type { Context } from "../context"; + +const pluginName = "css-side-effects-plugin"; +const namespace = `${pluginName}-ns`; +const cssSideEffectSuffix = "?__remix_sideEffect__"; +const cssSideEffectFilter = new RegExp( + `\\.css${cssSideEffectSuffix.replace("?", "\\?")}$` +); + +export function isCssSideEffectImportPath(path: string): boolean { + return cssSideEffectFilter.test(path); +} + +const extensions = ["js", "jsx", "ts", "tsx", "mjs", "cjs"] as const; +const allJsFilesFilter = new RegExp(`\\.(${extensions.join("|")})$`); + +type Loader = "js" | "jsx" | "ts" | "tsx"; +type Extension = `.${typeof extensions[number]}`; + +const loaderForExtension: Record = { + ".js": "jsx", // Remix supports JSX in JS files + ".jsx": "jsx", + ".ts": "ts", + ".tsx": "tsx", + ".mjs": "js", + ".cjs": "js", +}; + +/** + * This plugin detects side-effect imports of CSS files and adds a suffix + * to the import path, e.g. `import "./styles.css"` is transformed to + * `import "./styles.css?__remix_sideEffect__"`). This allows them to be + * differentiated from non-side-effect imports so that they can be added + * to the CSS bundle. This is primarily designed to support packages that + * import plain CSS files directly within JS files. + */ +export const cssSideEffectImportsPlugin = ( + ctx: Context, + { hmr = false } = {} +): Plugin => { + return { + name: pluginName, + setup: async (build) => { + build.onLoad( + { filter: allJsFilesFilter, namespace: "file" }, + async (args) => { + let cacheKey = `css-side-effect-imports-plugin:${args.path}&hmr=${hmr}`; + let { cacheValue } = await ctx.fileWatchCache.getOrSet( + cacheKey, + async () => { + let fileDependencies = new Set([args.path]); + + let code = await fse.readFile(args.path, "utf8"); + + // Don't process file if it doesn't contain any references to CSS files + if (!code.includes(".css")) { + return { + fileDependencies, + cacheValue: null, + }; + } + + let loader = + loaderForExtension[path.extname(args.path) as Extension]; + let contents = addSuffixToCssSideEffectImports(loader, code); + + if (args.path.startsWith(ctx.config.appDirectory) && hmr) { + contents = await applyHMR( + contents, + args, + ctx.config, + !!build.initialOptions.sourcemap + ); + } + + return { + fileDependencies, + cacheValue: { + contents, + loader, + }, + }; + } + ); + + if (!cacheValue) { + return null; + } + + return { + contents: cacheValue.contents, + loader: cacheValue.loader, + }; + } + ); + + build.onResolve( + { filter: cssSideEffectFilter, namespace: "file" }, + async (args) => { + let resolvedPath = ( + await build.resolve(args.path, { + resolveDir: args.resolveDir, + kind: args.kind, + }) + ).path; + + // If the resolved path isn't a CSS file then we don't want + // to handle it. In our case this is specifically done to + // avoid matching Vanilla Extract's .css.ts/.js files. + if (!resolvedPath.split("?")[0].endsWith(".css")) { + return null; + } + + return { + path: path.relative(ctx.config.rootDirectory, resolvedPath), + namespace, + }; + } + ); + + build.onLoad({ filter: /\.css$/, namespace }, async (args) => { + let absolutePath = path.resolve(ctx.config.rootDirectory, args.path); + let postcssProcessor = await getCachedPostcssProcessor(ctx); + + return { + contents: postcssProcessor + ? await postcssProcessor({ path: absolutePath }) + : await fse.readFile(absolutePath, "utf8"), + resolveDir: path.dirname(absolutePath), + loader: "css", + }; + }); + }, + }; +}; + +const additionalLanguageFeatures: ParserOptions["plugins"] = ["decorators"]; + +const babelPluginsForLoader: Record = { + js: ["jsx", ...additionalLanguageFeatures], // Remix supports JSX in JS files + jsx: ["jsx", ...additionalLanguageFeatures], + ts: ["typescript", ...additionalLanguageFeatures], + tsx: ["typescript", "jsx", ...additionalLanguageFeatures], +}; + +export function addSuffixToCssSideEffectImports( + loader: Loader, + code: string +): string { + let ast = parse(code, { + sourceType: "module", + plugins: babelPluginsForLoader[loader], + }); + + traverse(ast, { + // Handle `import "./styles.css"` + ImportDeclaration(path) { + if ( + path.node.specifiers.length === 0 && // i.e. nothing was imported + path.node.source.value.endsWith(".css") + ) { + path.node.source.value += cssSideEffectSuffix; + } + }, + + // Handle `require("./styles.css")` + CallExpression(path) { + if ( + path.node.callee.type === "Identifier" && + path.node.callee.name === "require" && + // Require call must be its own statement, + // not nested within another expression, + (path.parent.type === "ExpressionStatement" || + // or, the statement must only consist of a + // ternary or logical expression, without + // assigning the result to a variable. + ((path.parent.type === "ConditionalExpression" || + path.parent.type === "LogicalExpression") && + path.parentPath.parent.type === "ExpressionStatement")) + ) { + let specifier = path.node.arguments[0]; + + if ( + specifier && + specifier.type === "StringLiteral" && + specifier.value.endsWith(".css") + ) { + specifier.value += cssSideEffectSuffix; + } + } + }, + }); + + let result = generate(ast, { + retainLines: true, + compact: false, + }).code; + + return result; +} diff --git a/packages/remix-dev/compiler/plugins/emptyModules.ts b/packages/remix-dev/compiler/plugins/emptyModules.ts new file mode 100644 index 0000000000..5fd7bf8069 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/emptyModules.ts @@ -0,0 +1,43 @@ +import * as path from "node:path"; +import type esbuild from "esbuild"; + +import type { Context } from "../context"; + +/** + * This plugin substitutes an empty module for any modules in the `app` + * directory that match the given `filter`. + */ +export function emptyModulesPlugin( + { config }: Context, + filter: RegExp, + { includeNodeModules = false } = {} +): esbuild.Plugin { + return { + name: "empty-modules", + setup(build) { + build.onResolve({ filter }, (args) => { + if ( + includeNodeModules || + // Limit this behavior to modules found in only the `app` directory. + // This allows node_modules to use the `.server.js` and `.client.js` + // naming conventions with different semantics. + path + .resolve(args.resolveDir, args.path) + .startsWith(config.appDirectory) + ) { + return { path: args.path, namespace: "empty-module" }; + } + }); + + build.onLoad({ filter: /.*/, namespace: "empty-module" }, () => { + return { + // Use an empty CommonJS module here instead of ESM to avoid "No + // matching export" errors in esbuild for stuff that is imported + // from this file. + contents: "module.exports = {};", + loader: "js", + }; + }); + }, + }; +} diff --git a/packages/remix-dev/compiler/plugins/external.ts b/packages/remix-dev/compiler/plugins/external.ts new file mode 100644 index 0000000000..a5de2d17df --- /dev/null +++ b/packages/remix-dev/compiler/plugins/external.ts @@ -0,0 +1,17 @@ +import type { Plugin } from "esbuild"; + +export const externalPlugin = ( + filter: RegExp, + options: { + sideEffects?: boolean; + } = {} +): Plugin => { + return { + name: "external", + setup(build) { + build.onResolve({ filter }, () => { + return { external: true, sideEffects: options.sideEffects }; + }); + }, + }; +}; diff --git a/packages/remix-dev/compiler/plugins/mdx.ts b/packages/remix-dev/compiler/plugins/mdx.ts new file mode 100644 index 0000000000..4548dfc494 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/mdx.ts @@ -0,0 +1,149 @@ +import { promises as fsp } from "node:fs"; +import * as path from "node:path"; +import type * as esbuild from "esbuild"; +import { remarkMdxFrontmatter } from "remark-mdx-frontmatter"; + +import { getLoaderForFile } from "../utils/loaders"; +import { createMatchPath } from "../utils/tsconfig"; +import type { Context } from "../context"; + +export function mdxPlugin({ config }: Pick): esbuild.Plugin { + return { + name: "remix-mdx", + async setup(build) { + let [mdx, { default: remarkFrontmatter }] = await Promise.all([ + import("@mdx-js/mdx"), + import("remark-frontmatter") as any, + ]); + + build.onResolve({ filter: /\.mdx?$/ }, (args) => { + let matchPath = createMatchPath(config.tsconfigPath); + // Resolve paths according to tsconfig paths property + function resolvePath(id: string) { + if (!matchPath) { + return id; + } + return ( + matchPath(id, undefined, undefined, [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mdx", + ".md", + ]) || id + ); + } + + let resolvedPath = resolvePath(args.path); + let resolved = path.resolve(args.resolveDir, resolvedPath); + + return { + path: path.relative(config.appDirectory, resolved), + namespace: "mdx", + }; + }); + + build.onLoad({ filter: /\.mdx?$/ }, async (args) => { + let absolutePath = path.join(config.appDirectory, args.path); + + return processMDX( + mdx, + remarkFrontmatter, + config, + args.path, + absolutePath + ); + }); + }, + }; +} + +export async function processMDX( + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + mdx: typeof import("@mdx-js/mdx"), + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + remarkFrontmatter: typeof import("remark-frontmatter")["default"], + config: Pick["config"], + argsPath: string, + absolutePath: string +) { + try { + let fileContents = await fsp.readFile(absolutePath, "utf-8"); + + let rehypePlugins = []; + let remarkPlugins: any[] = [ + remarkFrontmatter, + [remarkMdxFrontmatter, { name: "attributes" }], + ]; + + switch (typeof config.mdx) { + case "object": + rehypePlugins.push(...(config.mdx.rehypePlugins || [])); + remarkPlugins.push(...(config.mdx.remarkPlugins || [])); + + break; + case "function": + let mdxConfig = await config.mdx(argsPath); + rehypePlugins.push(...(mdxConfig?.rehypePlugins || [])); + remarkPlugins.push(...(mdxConfig?.remarkPlugins || [])); + break; + } + + let remixExports = ` +export const filename = ${JSON.stringify(path.basename(argsPath))}; +export const headers = typeof attributes !== "undefined" && attributes.headers; +export const meta = typeof attributes !== "undefined" && attributes.meta; +export const handle = typeof attributes !== "undefined" && attributes.handle; + `; + + let compiled = await mdx.compile(fileContents, { + jsx: true, + jsxRuntime: "automatic", + rehypePlugins, + remarkPlugins, + }); + + let contents = ` +${compiled.value} +${remixExports}`; + + let errors: esbuild.PartialMessage[] = []; + let warnings: esbuild.PartialMessage[] = []; + + compiled.messages.forEach((message) => { + let toPush = message.fatal ? errors : warnings; + toPush.push({ + location: + message.line || message.column + ? { + column: + typeof message.column === "number" + ? message.column + : undefined, + line: + typeof message.line === "number" ? message.line : undefined, + } + : undefined, + text: message.message, + detail: typeof message.note === "string" ? message.note : undefined, + }); + }); + + return { + errors: errors.length ? errors : undefined, + warnings: warnings.length ? warnings : undefined, + contents, + resolveDir: path.dirname(absolutePath), + loader: getLoaderForFile(argsPath), + }; + } catch (err: any) { + return { + errors: [ + { + text: err.message, + }, + ], + }; + } +} diff --git a/packages/remix-dev/compiler/plugins/vanillaExtract.ts b/packages/remix-dev/compiler/plugins/vanillaExtract.ts new file mode 100644 index 0000000000..8e5d229208 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/vanillaExtract.ts @@ -0,0 +1,161 @@ +import { dirname, resolve } from "node:path"; +import type { Compiler } from "@vanilla-extract/integration"; +import { cssFileFilter, createCompiler } from "@vanilla-extract/integration"; +import type { Plugin } from "esbuild"; + +import type { Options } from "../options"; +import { loaders } from "../utils/loaders"; +import { getPostcssProcessor } from "../utils/postcss"; +import type { Context } from "../context"; +import { getAppDependencies } from "../../dependencies"; + +const pluginName = "vanilla-extract-plugin"; +const namespace = `${pluginName}-ns`; +const virtualCssFileFilter = /\.vanilla.css$/; + +const staticAssetRegexp = new RegExp( + `(${Object.keys(loaders) + .filter((ext) => ext !== ".css" && loaders[ext] === "file") + .join("|")})$` +); + +let compiler: Compiler | undefined; +function getCompiler(root: string, mode: Options["mode"]) { + compiler = + compiler || + createCompiler({ + root, + identifiers: mode === "production" ? "short" : "debug", + vitePlugins: [ + { + name: "remix-assets", + enforce: "pre", + async resolveId(source) { + // Handle root-relative imports within Vanilla Extract files + if (source.startsWith("~")) { + return await this.resolve(source.replace("~", "")); + } + // Handle static asset JS imports + if (source.startsWith("/") && staticAssetRegexp.test(source)) { + return { + external: true, + id: "~" + source, + }; + } + }, + transform(code) { + // Translate Vite's fs import format for root-relative imports + return code.replace(/\/@fs\/~\//g, "~/"); + }, + }, + ], + }); + + return compiler; +} + +export function vanillaExtractPlugin( + { config, options }: Context, + { outputCss }: { outputCss: boolean } +): Plugin { + return { + name: pluginName, + async setup(build) { + let appDependencies = getAppDependencies(config, true); + if (!appDependencies["@vanilla-extract/css"]) { + return; + } + + let root = config.appDirectory; + + // Resolve virtual CSS files first to avoid resolving the same + // file multiple times since this filter is more specific and + // doesn't require a file system lookup. + build.onResolve({ filter: virtualCssFileFilter }, (args) => { + return { + path: args.path, + namespace, + }; + }); + + // Mark all .css.ts/js files as having side effects. This is to ensure + // that all usages of `globalStyle` are included in the CSS bundle, even + // if a .css.ts/js file has no exports or is otherwise tree-shaken. + let preventInfiniteLoop = {}; + build.onResolve( + { filter: /\.css(\.(j|t)sx?)?(\?.*)?$/, namespace: "file" }, + async (args) => { + if (args.pluginData === preventInfiniteLoop) { + return null; + } + + let resolvedPath = ( + await build.resolve(args.path, { + resolveDir: args.resolveDir, + kind: args.kind, + pluginData: preventInfiniteLoop, + }) + ).path; + + if (!cssFileFilter.test(resolvedPath)) { + return null; + } + + return { + path: resolvedPath, + sideEffects: true, + }; + } + ); + + build.onLoad( + { filter: virtualCssFileFilter, namespace }, + async ({ path }) => { + let [relativeFilePath] = path.split(".vanilla.css"); + let compiler = getCompiler(root, options.mode); + let { css, filePath } = compiler.getCssForFile(relativeFilePath); + let resolveDir = dirname(resolve(root, filePath)); + + let postcssProcessor = await getPostcssProcessor({ + config, + postcssContext: { vanillaExtract: true }, + }); + + if (postcssProcessor) { + css = ( + await postcssProcessor.process(css, { + from: path, + to: path, + }) + ).css; + } + + return { + contents: css, + loader: "css", + resolveDir, + }; + } + ); + + build.onLoad({ filter: cssFileFilter }, async ({ path: filePath }) => { + let compiler = getCompiler(root, options.mode); + let { source, watchFiles } = await compiler.processVanillaFile( + filePath, + { outputCss } + ); + + return { + contents: source, + resolveDir: dirname(filePath), + loader: "js", + watchFiles: (Array.from(watchFiles) || []).map((watchFile) => + watchFile.startsWith("~") + ? resolve(root, watchFile.replace("~", ".")) + : watchFile + ), + }; + }); + }, + }; +} diff --git a/packages/remix-dev/compiler/server/compiler.ts b/packages/remix-dev/compiler/server/compiler.ts new file mode 100644 index 0000000000..1fbfd76ae9 --- /dev/null +++ b/packages/remix-dev/compiler/server/compiler.ts @@ -0,0 +1,138 @@ +import * as esbuild from "esbuild"; + +import { type Manifest } from "../../manifest"; +import { loaders } from "../utils/loaders"; +import { cssModulesPlugin } from "../plugins/cssModuleImports"; +import { cssSideEffectImportsPlugin } from "../plugins/cssSideEffectImports"; +import { vanillaExtractPlugin } from "../plugins/vanillaExtract"; +import { cssFilePlugin } from "../plugins/cssImports"; +import { absoluteCssUrlsPlugin } from "../plugins/absoluteCssUrlsPlugin"; +import { emptyModulesPlugin } from "../plugins/emptyModules"; +import { serverNodeBuiltinsPolyfillPlugin } from "./plugins/serverNodeBuiltinsPolyfill"; +import { mdxPlugin } from "../plugins/mdx"; +import { serverAssetsManifestPlugin } from "./plugins/manifest"; +import { serverBareModulesPlugin } from "./plugins/bareImports"; +import { serverEntryModulePlugin } from "./plugins/entry"; +import { serverRouteModulesPlugin } from "./plugins/routes"; +import { externalPlugin } from "../plugins/external"; +import type * as Channel from "../../channel"; +import type { Context } from "../context"; +import type { LazyValue } from "../lazyValue"; +import { cssBundlePlugin } from "../plugins/cssBundlePlugin"; +import { writeMetafile } from "../analysis"; + +type Compiler = { + // produce ./build/index.js + compile: () => Promise; + cancel: () => Promise; + dispose: () => Promise; +}; + +const createEsbuildConfig = ( + ctx: Context, + refs: { + manifestChannel: Channel.Type; + lazyCssBundleHref: LazyValue; + } +): esbuild.BuildOptions => { + let stdin: esbuild.StdinOptions | undefined; + let entryPoints: string[] | undefined; + + if (ctx.config.serverEntryPoint) { + entryPoints = [ctx.config.serverEntryPoint]; + } else { + stdin = { + contents: ctx.config.serverBuildTargetEntryModule, + resolveDir: ctx.config.rootDirectory, + loader: "ts", + }; + } + + let plugins: esbuild.Plugin[] = [ + cssBundlePlugin(refs), + cssModulesPlugin(ctx, { outputCss: false }), + vanillaExtractPlugin(ctx, { outputCss: false }), + cssSideEffectImportsPlugin(ctx), + cssFilePlugin(ctx), + absoluteCssUrlsPlugin(), + externalPlugin(/^https?:\/\//, { sideEffects: false }), + mdxPlugin(ctx), + emptyModulesPlugin(ctx, /\.client(\.[jt]sx?)?$/), + serverRouteModulesPlugin(ctx), + serverEntryModulePlugin(ctx), + serverAssetsManifestPlugin(refs), + serverBareModulesPlugin(ctx), + externalPlugin(/^node:.*/, { sideEffects: false }), + ]; + + if (ctx.config.serverNodeBuiltinsPolyfill) { + plugins.unshift(serverNodeBuiltinsPolyfillPlugin(ctx)); + } + + return { + absWorkingDir: ctx.config.rootDirectory, + stdin, + entryPoints, + outfile: ctx.config.serverBuildPath, + conditions: ctx.config.serverConditions, + platform: ctx.config.serverPlatform, + format: ctx.config.serverModuleFormat, + treeShaking: true, + // The type of dead code elimination we want to do depends on the + // minify syntax property: https://github.com/evanw/esbuild/issues/672#issuecomment-1029682369 + // Dev builds are leaving code that should be optimized away in the + // bundle causing server / testing code to be shipped to the browser. + // These are properly optimized away in prod builds today, and this + // PR makes dev mode behave closer to production in terms of dead + // code elimination / tree shaking is concerned. + minifySyntax: true, + minify: ctx.options.mode === "production" && ctx.config.serverMinify, + mainFields: ctx.config.serverMainFields, + target: "node18", + loader: loaders, + bundle: true, + logLevel: "silent", + // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to + // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted + // behavior can only be avoided by creating an empty tsconfig file in the root directory. + tsconfig: ctx.config.tsconfigPath, + sourcemap: ctx.options.sourcemap, // use linked (true) to fix up .map file + // The server build needs to know how to generate asset URLs for imports + // of CSS and other files. + assetNames: "_assets/[name]-[hash]", + publicPath: ctx.config.publicPath, + define: { + "process.env.NODE_ENV": JSON.stringify(ctx.options.mode), + "process.env.REMIX_DEV_ORIGIN": JSON.stringify( + ctx.options.REMIX_DEV_ORIGIN ?? "" + ), + }, + jsx: "automatic", + jsxDev: ctx.options.mode !== "production", + plugins, + }; +}; + +export const create = async ( + ctx: Context, + refs: { + manifestChannel: Channel.Type; + lazyCssBundleHref: LazyValue; + } +): Promise => { + let compiler = await esbuild.context({ + ...createEsbuildConfig(ctx, refs), + write: false, + metafile: true, + }); + let compile = async () => { + let { outputFiles, metafile } = await compiler.rebuild(); + writeMetafile(ctx, "metafile.server.json", metafile); + return outputFiles; + }; + return { + compile, + cancel: compiler.cancel, + dispose: compiler.dispose, + }; +}; diff --git a/packages/remix-dev/compiler/server/index.ts b/packages/remix-dev/compiler/server/index.ts new file mode 100644 index 0000000000..c67a1d153e --- /dev/null +++ b/packages/remix-dev/compiler/server/index.ts @@ -0,0 +1,2 @@ +export { create as createCompiler } from "./compiler"; +export { write } from "./write"; diff --git a/packages/remix-dev/compiler/server/plugins/bareImports.ts b/packages/remix-dev/compiler/server/plugins/bareImports.ts new file mode 100644 index 0000000000..0eafa15a92 --- /dev/null +++ b/packages/remix-dev/compiler/server/plugins/bareImports.ts @@ -0,0 +1,111 @@ +import { isAbsolute } from "node:path"; +import type { Plugin } from "esbuild"; + +import { + serverBuildVirtualModule, + assetsManifestVirtualModule, +} from "../virtualModules"; +import { isCssSideEffectImportPath } from "../../plugins/cssSideEffectImports"; +import { createMatchPath } from "../../utils/tsconfig"; +import type { Context } from "../../context"; +import { getLoaderForFile } from "../../utils/loaders"; + +/** + * A plugin responsible for resolving bare module ids based on server target. + * This includes externalizing for node based platforms, and bundling for single file + * environments such as cloudflare. + */ +export function serverBareModulesPlugin(ctx: Context): Plugin { + // Resolve paths according to tsconfig paths property + let matchPath = ctx.config.tsconfigPath + ? createMatchPath(ctx.config.tsconfigPath) + : undefined; + function resolvePath(id: string) { + if (!matchPath) { + return id; + } + return ( + matchPath(id, undefined, undefined, [".ts", ".tsx", ".js", ".jsx"]) || id + ); + } + + return { + name: "server-bare-modules", + setup(build) { + build.onResolve({ filter: /.*/ }, ({ importer, path }) => { + // If it's not a bare module ID, bundle it. + if (!isBareModuleId(resolvePath(path))) { + return undefined; + } + + // Always bundle @remix-run/css-bundle + if (path === "@remix-run/css-bundle") { + return undefined; + } + + // To prevent `import xxx from "remix"` from ending up in the bundle + // we "bundle" remix but the other modules where the code lives. + if (path === "remix") { + return undefined; + } + + // These are our virtual modules, always bundle them because there is no + // "real" file on disk to externalize. + if ( + path === serverBuildVirtualModule.id || + path === assetsManifestVirtualModule.id + ) { + return undefined; + } + + // Skip assets that are treated as files (.css, .svg, .png, etc.). + // Otherwise, esbuild would emit code that would attempt to require() + // or import these files --- which aren't JavaScript! + let loader; + try { + loader = getLoaderForFile(path); + } catch (e) { + if ( + !( + e instanceof Error && + e.message.startsWith("Cannot get loader for file") + ) + ) { + throw e; + } + } + if (loader === "file") { + return undefined; + } + + // Always bundle CSS side-effect imports. + if (isCssSideEffectImportPath(path)) { + return undefined; + } + + if (ctx.config.serverDependenciesToBundle === "all") { + return undefined; + } + + for (let pattern of ctx.config.serverDependenciesToBundle) { + // bundle it if the path matches the pattern + if ( + typeof pattern === "string" ? path === pattern : pattern.test(path) + ) { + return undefined; + } + } + + // Externalize everything else if we've gotten here. + return { + path, + external: true, + }; + }); + }, + }; +} + +function isBareModuleId(id: string): boolean { + return !id.startsWith("node:") && !id.startsWith(".") && !isAbsolute(id); +} diff --git a/packages/remix-dev/compiler/server/plugins/entry.ts b/packages/remix-dev/compiler/server/plugins/entry.ts new file mode 100644 index 0000000000..a7c9634b94 --- /dev/null +++ b/packages/remix-dev/compiler/server/plugins/entry.ts @@ -0,0 +1,73 @@ +import type { Plugin } from "esbuild"; + +import type { Context } from "../../context"; +import { + serverBuildVirtualModule, + assetsManifestVirtualModule, +} from "../virtualModules"; + +/** + * Creates a virtual module called `@remix-run/dev/server-build` that exports the + * compiled server build for consumption in remix request handlers. This allows + * for you to consume the build in a custom server entry that is also fed through + * the compiler. + */ +export function serverEntryModulePlugin({ config, options }: Context): Plugin { + let filter = serverBuildVirtualModule.filter; + + return { + name: "server-entry-module", + setup(build) { + build.onResolve({ filter }, ({ path }) => { + return { + path, + namespace: "server-entry-module", + }; + }); + + build.onLoad({ filter }, async () => { + return { + resolveDir: config.appDirectory, + loader: "js", + contents: ` +import * as entryServer from ${JSON.stringify(config.entryServerFilePath)}; +${Object.keys(config.routes) + .map((key, index) => { + // IMPORTANT: Any values exported from this generated module must also be + // typed in `packages/remix-dev/server-build.ts` to avoid tsc errors. + let route = config.routes[key]; + return `import * as route${index} from ${JSON.stringify( + `./${route.file}` + )};`; + }) + .join("\n")} + export const mode = ${JSON.stringify(options.mode)}; + export { default as assets } from ${JSON.stringify( + assetsManifestVirtualModule.id + )}; + export const assetsBuildDirectory = ${JSON.stringify( + config.relativeAssetsBuildDirectory + )}; + export const future = ${JSON.stringify(config.future)}; + export const publicPath = ${JSON.stringify(config.publicPath)}; + export const entry = { module: entryServer }; + export const routes = { + ${Object.keys(config.routes) + .map((key, index) => { + let route = config.routes[key]; + return `${JSON.stringify(key)}: { + id: ${JSON.stringify(route.id)}, + parentId: ${JSON.stringify(route.parentId)}, + path: ${JSON.stringify(route.path)}, + index: ${JSON.stringify(route.index)}, + caseSensitive: ${JSON.stringify(route.caseSensitive)}, + module: route${index} + }`; + }) + .join(",\n ")} + };`, + }; + }); + }, + }; +} diff --git a/packages/remix-dev/compiler/server/plugins/manifest.ts b/packages/remix-dev/compiler/server/plugins/manifest.ts new file mode 100644 index 0000000000..19d847f94f --- /dev/null +++ b/packages/remix-dev/compiler/server/plugins/manifest.ts @@ -0,0 +1,41 @@ +import type { Plugin } from "esbuild"; +import jsesc from "jsesc"; + +import type * as Channel from "../../../channel"; +import { type Manifest } from "../../../manifest"; +import { assetsManifestVirtualModule } from "../virtualModules"; +import { Cancel } from "../../cancel"; + +/** + * Creates a virtual module called `@remix-run/dev/assets-manifest` that exports + * the assets manifest. This is used in the server entry module to access the + * assets manifest in the server build. + */ +export function serverAssetsManifestPlugin(refs: { + manifestChannel: Channel.Type; +}): Plugin { + let filter = assetsManifestVirtualModule.filter; + + return { + name: "server-assets-manifest", + setup(build) { + build.onResolve({ filter }, ({ path }) => { + return { + path, + namespace: "server-assets-manifest", + }; + }); + + build.onLoad({ filter }, async () => { + let manifest = await refs.manifestChannel.result; + if (!manifest.ok) throw new Cancel("server"); + return { + contents: `export default ${jsesc(manifest.value, { + es6: true, + })};`, + loader: "js", + }; + }); + }, + }; +} diff --git a/packages/remix-dev/compiler/server/plugins/routes.ts b/packages/remix-dev/compiler/server/plugins/routes.ts new file mode 100644 index 0000000000..d843acbf09 --- /dev/null +++ b/packages/remix-dev/compiler/server/plugins/routes.ts @@ -0,0 +1,48 @@ +import * as path from "node:path"; +import fse from "fs-extra"; +import type esbuild from "esbuild"; + +import { getLoaderForFile } from "../../utils/loaders"; +import type { Context } from "../../context"; + +/** + * This plugin loads route modules for the server build and prevents errors + * while adding new files in development mode. + */ +export function serverRouteModulesPlugin({ config }: Context): esbuild.Plugin { + return { + name: "server-route-modules", + setup(build) { + let routeFiles = new Set( + Object.keys(config.routes).map((key) => + path.resolve(config.appDirectory, config.routes[key].file) + ) + ); + + build.onResolve({ filter: /.*/ }, (args) => { + if (routeFiles.has(args.path)) { + return { path: args.path, namespace: "route" }; + } + }); + + build.onLoad({ filter: /.*/, namespace: "route" }, async (args) => { + let file = args.path; + let contents = await fse.readFile(file, "utf-8"); + + // Default to `export {}` if the file is empty so esbuild interprets + // this file as ESM instead of CommonJS with `default: {}`. This helps + // in development when creating new files. + // See https://github.com/evanw/esbuild/issues/1043 + if (!/\S/.test(contents)) { + return { contents: "export {}", loader: "js" }; + } + + return { + contents, + resolveDir: path.dirname(file), + loader: getLoaderForFile(file), + }; + }); + }, + }; +} diff --git a/packages/remix-dev/compiler/server/plugins/serverNodeBuiltinsPolyfill.ts b/packages/remix-dev/compiler/server/plugins/serverNodeBuiltinsPolyfill.ts new file mode 100644 index 0000000000..6165ea4d62 --- /dev/null +++ b/packages/remix-dev/compiler/server/plugins/serverNodeBuiltinsPolyfill.ts @@ -0,0 +1,17 @@ +import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; + +import type { Context } from "../../context"; + +export const serverNodeBuiltinsPolyfillPlugin = (ctx: Context) => + nodeModulesPolyfillPlugin({ + // Rename plugin to improve error message attribution + name: "server-node-builtins-polyfill-plugin", + // Only pass through the "modules" and "globals" options to ensure we + // don't leak the full plugin API to Remix consumers. + modules: ctx.config.serverNodeBuiltinsPolyfill?.modules ?? {}, + globals: ctx.config.serverNodeBuiltinsPolyfill?.globals ?? {}, + // Since the server environment may provide its own Node polyfills, + // we don't define any fallback behavior here and allow all Node + // builtins to be marked as external + fallback: "none", + }); diff --git a/packages/remix-dev/compiler/server/virtualModules.ts b/packages/remix-dev/compiler/server/virtualModules.ts new file mode 100644 index 0000000000..e7df45fbd9 --- /dev/null +++ b/packages/remix-dev/compiler/server/virtualModules.ts @@ -0,0 +1,14 @@ +interface VirtualModule { + id: string; + filter: RegExp; +} + +export const serverBuildVirtualModule: VirtualModule = { + id: "@remix-run/dev/server-build", + filter: /^@remix-run\/dev\/server-build$/, +}; + +export const assetsManifestVirtualModule: VirtualModule = { + id: "@remix-run/dev/assets-manifest", + filter: /^@remix-run\/dev\/assets-manifest$/, +}; diff --git a/packages/remix-dev/compiler/server/write.ts b/packages/remix-dev/compiler/server/write.ts new file mode 100644 index 0000000000..680b8e8b69 --- /dev/null +++ b/packages/remix-dev/compiler/server/write.ts @@ -0,0 +1,48 @@ +import * as path from "node:path"; +import type * as esbuild from "esbuild"; +import fse from "fs-extra"; + +import type { RemixConfig } from "../../config"; + +export async function write( + config: RemixConfig, + outputFiles: esbuild.OutputFile[] +) { + await fse.ensureDir(path.dirname(config.serverBuildPath)); + + for (let file of outputFiles) { + if ([".js", ".cjs", ".mjs"].some((ext) => file.path.endsWith(ext))) { + // fix sourceMappingURL to be relative to current path instead of /build + let filename = file.path.substring(file.path.lastIndexOf(path.sep) + 1); + let escapedFilename = filename.replace(/([.[\]])/g, "\\$1"); + let pattern = `(//# sourceMappingURL=)(.*)${escapedFilename}`; + let contents = Buffer.from(file.contents).toString("utf-8"); + contents = contents.replace(new RegExp(pattern), `$1${filename}`); + await fse.writeFile(file.path, contents); + } else if (file.path.endsWith(".map")) { + // Don't write CSS source maps to server build output + if (file.path.endsWith(".css.map")) { + break; + } + + // remove route: prefix from source filenames so breakpoints work + let contents = Buffer.from(file.contents).toString("utf-8"); + contents = contents.replace(/"route:/gm, '"'); + await fse.writeFile(file.path, contents); + } else { + let assetPath = path.join( + config.assetsBuildDirectory, + file.path.replace(path.dirname(config.serverBuildPath), "") + ); + + // Don't write CSS bundle from server build to browser assets directory, + // especially since the file name doesn't contain a content hash + if (assetPath === path.join(config.assetsBuildDirectory, "index.css")) { + break; + } + + await fse.ensureDir(path.dirname(assetPath)); + await fse.writeFile(assetPath, file.contents); + } + } +} diff --git a/packages/remix-dev/compiler/utils/crypto.ts b/packages/remix-dev/compiler/utils/crypto.ts new file mode 100644 index 0000000000..71b754228c --- /dev/null +++ b/packages/remix-dev/compiler/utils/crypto.ts @@ -0,0 +1,19 @@ +import * as fs from "node:fs"; +import type { BinaryLike } from "node:crypto"; +import { createHash } from "node:crypto"; + +export function getHash(source: BinaryLike): string { + return createHash("sha256").update(source).digest("hex"); +} + +export async function getFileHash(file: string): Promise { + return new Promise((accept, reject) => { + let hash = createHash("sha256"); + fs.createReadStream(file) + .on("error", (error) => reject(error)) + .on("data", (data) => hash.update(data)) + .on("close", () => { + accept(hash.digest("hex")); + }); + }); +} diff --git a/packages/remix-dev/compiler/utils/loaders.ts b/packages/remix-dev/compiler/utils/loaders.ts new file mode 100644 index 0000000000..3eeb02a702 --- /dev/null +++ b/packages/remix-dev/compiler/utils/loaders.ts @@ -0,0 +1,55 @@ +import * as path from "node:path"; +import type * as esbuild from "esbuild"; + +export const loaders: { [ext: string]: esbuild.Loader } = { + ".aac": "file", + ".avif": "file", + ".css": "file", + ".csv": "file", + ".eot": "file", + ".fbx": "file", + ".flac": "file", + ".gif": "file", + ".glb": "file", + ".gltf": "file", + ".gql": "text", + ".graphql": "text", + ".hdr": "file", + ".ico": "file", + ".jpeg": "file", + ".jpg": "file", + ".js": "jsx", + ".jsx": "jsx", + ".json": "json", + // We preprocess md and mdx files using @mdx-js/mdx and send through + // the JSX for esbuild to handle + ".md": "jsx", + ".mdx": "jsx", + ".mov": "file", + ".mp3": "file", + ".mp4": "file", + ".node": "copy", + ".ogg": "file", + ".otf": "file", + ".png": "file", + ".psd": "file", + ".sql": "text", + ".svg": "file", + ".ts": "ts", + ".tsx": "tsx", + ".ttf": "file", + ".wasm": "file", + ".wav": "file", + ".webm": "file", + ".webmanifest": "file", + ".webp": "file", + ".woff": "file", + ".woff2": "file", + ".zip": "file", +}; + +export function getLoaderForFile(file: string): esbuild.Loader { + let ext = path.extname(file); + if (ext in loaders) return loaders[ext]; + throw new Error(`Cannot get loader for file ${file}`); +} diff --git a/packages/remix-dev/compiler/utils/log.ts b/packages/remix-dev/compiler/utils/log.ts new file mode 100644 index 0000000000..38d3b3f666 --- /dev/null +++ b/packages/remix-dev/compiler/utils/log.ts @@ -0,0 +1,44 @@ +import esbuild from "esbuild"; + +import { CANCEL_PREFIX } from "../cancel"; + +let toError = (thrown: unknown): Error => { + if (thrown instanceof Error) return thrown; + try { + return new Error(JSON.stringify(thrown)); + } catch { + // fallback in case there's an error stringifying. + // for example, due to circular references. + return new Error(String(thrown)); + } +}; + +let isEsbuildError = (error: Error): error is esbuild.BuildFailure => { + return "warnings" in error && "errors" in error; +}; + +let logEsbuildError = (error: esbuild.BuildFailure) => { + let warnings = esbuild.formatMessagesSync(error.warnings, { + kind: "warning", + color: true, + }); + warnings.forEach((w) => console.warn(w)); + let errors = esbuild.formatMessagesSync( + // Filter out cancelation errors + error.errors.filter((e) => !e.text.startsWith(CANCEL_PREFIX)), + { + kind: "error", + color: true, + } + ); + errors.forEach((e) => console.error(e)); +}; + +export let logThrown = (thrown: unknown) => { + let error = toError(thrown); + if (isEsbuildError(error)) { + logEsbuildError(error); + return; + } + console.error(error.message); +}; diff --git a/packages/remix-dev/compiler/utils/postcss.ts b/packages/remix-dev/compiler/utils/postcss.ts new file mode 100644 index 0000000000..9198664292 --- /dev/null +++ b/packages/remix-dev/compiler/utils/postcss.ts @@ -0,0 +1,260 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import fse from "fs-extra"; +import loadConfig from "postcss-load-config"; +import type { AcceptedPlugin, Message, Processor } from "postcss"; +import postcss from "postcss"; + +import type { RemixConfig } from "../../config"; +import type { Options } from "../options"; +import type { FileWatchCache } from "../fileWatchCache"; +import { findConfig } from "../../config"; + +interface PostcssContext { + vanillaExtract: boolean; +} + +const defaultPostcssContext: PostcssContext = { + vanillaExtract: false, +}; + +function isPostcssEnabled(config: RemixConfig) { + return config.postcss || config.tailwind; +} + +function getCacheKey({ + config, + postcssContext, +}: { + config: RemixConfig; + postcssContext: PostcssContext; +}) { + return [config.rootDirectory, postcssContext.vanillaExtract].join("|"); +} + +let pluginsCache = new Map>(); +export async function loadPostcssPlugins({ + config, + postcssContext = defaultPostcssContext, +}: { + config: RemixConfig; + postcssContext?: PostcssContext; +}): Promise> { + if (!isPostcssEnabled(config)) { + return []; + } + + let { rootDirectory } = config; + let cacheKey = getCacheKey({ config, postcssContext }); + let cachedPlugins = pluginsCache.get(cacheKey); + if (cachedPlugins) { + return cachedPlugins; + } + + let plugins: Array = []; + + if (config.postcss) { + try { + let postcssConfig = await loadConfig( + // We're nesting our custom context values in a "remix" + // namespace to avoid clashing with other tools. + { remix: postcssContext } as loadConfig.ConfigContext, // Custom config extensions aren't type safe + rootDirectory + ); + + plugins.push(...postcssConfig.plugins); + } catch (err) { + // If they don't have a PostCSS config, just ignore it, + // otherwise rethrow the error. + if ( + err instanceof Error && + !/No PostCSS Config found/i.test(err.message) + ) { + throw err; + } + } + } + + if (config.tailwind) { + let tailwindPlugin = await loadTailwindPlugin(config); + if (tailwindPlugin && !hasTailwindPlugin(plugins, tailwindPlugin)) { + plugins.push(tailwindPlugin); + } + } + + pluginsCache.set(cacheKey, plugins); + return plugins; +} + +let processorCache = new Map(); +export async function getPostcssProcessor({ + config, + postcssContext = defaultPostcssContext, +}: { + config: RemixConfig; + postcssContext?: PostcssContext; +}): Promise { + if (!isPostcssEnabled(config)) { + return null; + } + + let cacheKey = getCacheKey({ config, postcssContext }); + let cachedProcessor = processorCache.get(cacheKey); + if (cachedProcessor !== undefined) { + return cachedProcessor; + } + + let plugins = await loadPostcssPlugins({ config, postcssContext }); + let processor = plugins.length > 0 ? postcss(plugins) : null; + + processorCache.set(cacheKey, processor); + return processor; +} + +function hasTailwindPlugin( + plugins: Array, + tailwindPlugin: AcceptedPlugin +) { + return plugins.some( + (plugin) => + plugin === tailwindPlugin || + (typeof plugin === "function" && plugin.name === "tailwindcss") || + ("postcssPlugin" in plugin && plugin.postcssPlugin === "tailwindcss") + ); +} + +let tailwindPluginCache = new Map(); +async function loadTailwindPlugin( + config: RemixConfig +): Promise { + if (!config.tailwind) { + return null; + } + + let { rootDirectory } = config; + let cacheKey = rootDirectory; + let cachedTailwindPlugin = tailwindPluginCache.get(cacheKey); + if (cachedTailwindPlugin !== undefined) { + return cachedTailwindPlugin; + } + + let tailwindPath: string | null = null; + + try { + // First ensure they have a Tailwind config + let tailwindConfigExtensions = [".js", ".cjs", ".mjs", ".ts"]; + let tailwindConfig = findConfig( + rootDirectory, + "tailwind.config", + tailwindConfigExtensions + ); + if (!tailwindConfig) throw new Error("No Tailwind config found"); + + // Load Tailwind from the project directory + tailwindPath = require.resolve("tailwindcss", { paths: [rootDirectory] }); + } catch { + // If they don't have a Tailwind config or Tailwind installed, just ignore it. + return null; + } + + let importedTailwindPlugin = tailwindPath + ? (await import(pathToFileURL(tailwindPath).href))?.default + : null; + + let tailwindPlugin: AcceptedPlugin | null = + importedTailwindPlugin && importedTailwindPlugin.postcss // Check that it declares itself as a PostCSS plugin + ? importedTailwindPlugin + : null; + + tailwindPluginCache.set(cacheKey, tailwindPlugin); + + return tailwindPlugin; +} + +// PostCSS plugin result objects can contain arbitrary messages returned +// from plugins. Here we look for messages that indicate a dependency +// on another file or glob. Here we target the generic dependency messages +// returned from 'postcss-import' and 'tailwindcss' plugins, but we may +// need to add more in the future depending on what other plugins do. +// More info: +// - https://postcss.org/docs/postcss-runner-guidelines +// - https://postcss.org/api/#result +// - https://postcss.org/api/#message +export function populateDependenciesFromMessages({ + messages, + fileDependencies, + globDependencies, +}: { + messages: Array; + fileDependencies: Set; + globDependencies: Set; +}): void { + for (let message of messages) { + if (message.type === "dependency" && typeof message.file === "string") { + fileDependencies.add(message.file); + continue; + } + + if ( + message.type === "dir-dependency" && + typeof message.dir === "string" && + typeof message.glob === "string" + ) { + globDependencies.add(path.join(message.dir, message.glob)); + continue; + } + } +} + +export async function getCachedPostcssProcessor({ + config, + options, + fileWatchCache, +}: { + config: RemixConfig; + options: Options; + fileWatchCache: FileWatchCache; +}) { + // eslint-disable-next-line prefer-let/prefer-let -- Avoid needing to repeatedly check for null since const can't be reassigned + const postcssProcessor = await getPostcssProcessor({ config }); + + if (!postcssProcessor) { + return null; + } + + return async function processCss(args: { path: string }) { + let cacheKey = `postcss:${args.path}?sourcemap=${options.sourcemap}`; + + let { cacheValue } = await fileWatchCache.getOrSet(cacheKey, async () => { + let contents = await fse.readFile(args.path, "utf-8"); + + let { css, messages } = await postcssProcessor.process(contents, { + from: args.path, + to: args.path, + map: options.sourcemap, + }); + + let fileDependencies = new Set(); + let globDependencies = new Set(); + + // Ensure the CSS file being passed to PostCSS is tracked as a + // dependency of this cache key since a change to this file should + // invalidate the cache, not just its sub-dependencies. + fileDependencies.add(args.path); + + populateDependenciesFromMessages({ + messages, + fileDependencies, + globDependencies, + }); + + return { + cacheValue: css, + fileDependencies, + globDependencies, + }; + }); + + return cacheValue; + }; +} diff --git a/packages/remix-dev/compiler/utils/routeExports.ts b/packages/remix-dev/compiler/utils/routeExports.ts new file mode 100644 index 0000000000..ba62a9f0e9 --- /dev/null +++ b/packages/remix-dev/compiler/utils/routeExports.ts @@ -0,0 +1,70 @@ +import * as path from "node:path"; +import * as esbuild from "esbuild"; + +import * as cache from "../../cache"; +import { mdxPlugin } from "../plugins/mdx"; +import { getFileHash } from "./crypto"; +import type { RemixConfig } from "../../config"; + +type CachedRouteExports = { hash: string; exports: string[] }; + +export async function getRouteModuleExports( + config: RemixConfig, + routeId: string +): Promise { + let file = path.resolve(config.appDirectory, config.routes[routeId].file); + let hash = await getFileHash(file); + let key = routeId + ".exports"; + + let cached: CachedRouteExports | null = null; + try { + cached = await cache.getJson(config.cacheDirectory, key); + } catch (error: unknown) { + // Ignore cache read errors. + } + + if (!cached || cached.hash !== hash) { + let exports = await _getRouteModuleExports(config, routeId); + cached = { hash, exports }; + try { + await cache.putJson(config.cacheDirectory, key, cached); + } catch (error: unknown) { + // Ignore cache put errors. + } + } + + // Layout routes can't have actions + if (routeId.match(/\/__[\s\w\d_-]+$/) && cached.exports.includes("action")) { + throw new Error(`Actions are not supported in layout routes: ${routeId}`); + } + + return cached.exports; +} + +async function _getRouteModuleExports( + config: RemixConfig, + routeId: string +): Promise { + let result = await esbuild.build({ + entryPoints: [ + path.resolve(config.appDirectory, config.routes[routeId].file), + ], + platform: "neutral", + format: "esm", + metafile: true, + write: false, + loader: { + ".js": "jsx", + }, + logLevel: "silent", + plugins: [mdxPlugin({ config })], + }); + let metafile = result.metafile!; + + for (let key in metafile.outputs) { + let output = metafile.outputs[key]; + if (output.entryPoint) return output.exports; + } + + throw new Error(`Unable to get exports for route ${routeId}`); +} diff --git a/packages/remix-dev/compiler/utils/tsconfig.ts b/packages/remix-dev/compiler/utils/tsconfig.ts new file mode 100644 index 0000000000..0254cd3e0a --- /dev/null +++ b/packages/remix-dev/compiler/utils/tsconfig.ts @@ -0,0 +1,29 @@ +import tsConfigPaths from "tsconfig-paths"; + +export function createMatchPath(tsconfigPath: string | undefined) { + // There is no tsconfig to match paths against. + if (!tsconfigPath) { + return undefined; + } + + // When passing a absolute path, loadConfig assumes that the path contains + // a tsconfig file. + // Ref.: https://github.com/dividab/tsconfig-paths/blob/v4.0.0/src/__tests__/config-loader.test.ts#L74 + let configLoaderResult = tsConfigPaths.loadConfig(tsconfigPath); + + if (configLoaderResult.resultType === "failed") { + if (configLoaderResult.message === "Missing baseUrl in compilerOptions") { + throw new Error( + `🚨 Oops! No baseUrl found, please set compilerOptions.baseUrl in your tsconfig or jsconfig` + ); + } + return undefined; + } + + return tsConfigPaths.createMatchPath( + configLoaderResult.absoluteBaseUrl, + configLoaderResult.paths, + configLoaderResult.mainFields, + configLoaderResult.addMatchAll + ); +} diff --git a/packages/remix-dev/compiler/watch.ts b/packages/remix-dev/compiler/watch.ts new file mode 100644 index 0000000000..157891066a --- /dev/null +++ b/packages/remix-dev/compiler/watch.ts @@ -0,0 +1,158 @@ +import chokidar from "chokidar"; +import debounce from "lodash.debounce"; +import * as path from "node:path"; + +import type { RemixConfig } from "../config"; +import { readConfig } from "../config"; +import * as Compiler from "./compiler"; +import type { Context } from "./context"; +import { logThrown } from "./utils/log"; +import { normalizeSlashes } from "../config/routes"; +import type { Manifest } from "../manifest"; + +function isEntryPoint(config: RemixConfig, file: string): boolean { + let configFile = path.join(config.rootDirectory, "remix.config.js"); + let appFile = path.relative(config.appDirectory, file); + let entryPoints = [ + configFile, + config.entryClientFile, + config.entryServerFile, + ...Object.values(config.routes).map((route) => route.file), + ]; + let normalized = normalizeSlashes(appFile); + return entryPoints.includes(normalized); +} + +export type WatchOptions = { + reloadConfig?(root: string): Promise; + onBuildStart?(ctx: Context): void; + onBuildManifest?(manifest: Manifest): void; + onBuildFinish?(ctx: Context, durationMs: number, ok: boolean): void; + onFileCreated?(file: string): void; + onFileChanged?(file: string): void; + onFileDeleted?(file: string): void; +}; + +function shouldIgnore(file: string): boolean { + let filename = path.basename(file); + return filename === ".DS_Store"; +} + +export async function watch( + ctx: Context, + { + reloadConfig = readConfig, + onBuildStart, + onBuildManifest, + onBuildFinish, + onFileCreated, + onFileChanged, + onFileDeleted, + }: WatchOptions = {} +): Promise<() => Promise> { + let start = Date.now(); + let compiler = await Compiler.create(ctx); + let compile = () => + compiler.compile({ onManifest: onBuildManifest }).catch((thrown) => { + if ( + thrown instanceof Error && + thrown.message === "The service is no longer running" + ) { + ctx.logger.error("esbuild is no longer running", { + details: [ + "Most likely, your machine ran out of memory and killed the esbuild process", + "that `remix dev` relies on for builds and rebuilds.", + ], + }); + process.exit(1); + } + logThrown(thrown); + return undefined; + }); + + // initial build + onBuildStart?.(ctx); + let manifest = await compile(); + onBuildFinish?.(ctx, Date.now() - start, manifest !== undefined); + + let restart = debounce(async () => { + let start = Date.now(); + void compiler.dispose(); + + try { + ctx.config = await reloadConfig(ctx.config.rootDirectory); + } catch (thrown: unknown) { + logThrown(thrown); + return; + } + onBuildStart?.(ctx); + + compiler = await Compiler.create(ctx); + let manifest = await compile(); + onBuildFinish?.(ctx, Date.now() - start, manifest !== undefined); + }, 500); + + let rebuild = debounce(async () => { + await compiler.cancel(); + onBuildStart?.(ctx); + let start = Date.now(); + let manifest = await compile(); + onBuildFinish?.(ctx, Date.now() - start, manifest !== undefined); + }, 100); + + let remixConfigPath = path.join(ctx.config.rootDirectory, "remix.config.js"); + let toWatch = [remixConfigPath, ctx.config.appDirectory]; + + // WARNING: Chokidar returns different paths in change events depending on + // whether the path provided to the watcher is absolute or relative. If the + // path is absolute, change events will contain absolute paths, and the + // opposite for relative paths. We need to ensure that the paths we provide + // are always absolute to ensure consistency in change events. + if (ctx.config.serverEntryPoint) { + toWatch.push( + path.resolve(ctx.config.rootDirectory, ctx.config.serverEntryPoint) + ); + } + ctx.config.watchPaths?.forEach((watchPath) => { + toWatch.push(path.resolve(ctx.config.rootDirectory, watchPath)); + }); + + let watcher = chokidar + .watch(toWatch, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 100, + }, + }) + .on("error", (error) => ctx.logger.error(String(error))) + .on("change", async (file) => { + if (shouldIgnore(file)) return; + onFileChanged?.(file); + await (file === remixConfigPath ? restart : rebuild)(); + }) + .on("add", async (file) => { + if (shouldIgnore(file)) return; + onFileCreated?.(file); + + try { + ctx.config = await reloadConfig(ctx.config.rootDirectory); + } catch (thrown: unknown) { + logThrown(thrown); + return; + } + + await (isEntryPoint(ctx.config, file) ? restart : rebuild)(); + }) + .on("unlink", async (file) => { + if (shouldIgnore(file)) return; + onFileDeleted?.(file); + await (isEntryPoint(ctx.config, file) ? restart : rebuild)(); + }); + + return async () => { + await watcher.close().catch(() => undefined); + void compiler.dispose(); + }; +} diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts new file mode 100644 index 0000000000..4ddf67780d --- /dev/null +++ b/packages/remix-dev/config.ts @@ -0,0 +1,740 @@ +import { execSync } from "node:child_process"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import fse from "fs-extra"; +import PackageJson from "@npmcli/package-json"; +import type { NodePolyfillsOptions as EsbuildPluginsNodeModulesPolyfillOptions } from "esbuild-plugins-node-modules-polyfill"; + +import type { RouteManifest, DefineRoutesFunction } from "./config/routes"; +import { defineRoutes } from "./config/routes"; +import { ServerMode, isValidServerMode } from "./config/serverModes"; +import { serverBuildVirtualModule } from "./compiler/server/virtualModules"; +import { flatRoutes } from "./config/flat-routes"; +import { detectPackageManager } from "./cli/detectPackageManager"; +import { logger } from "./tux"; + +export interface RemixMdxConfig { + rehypePlugins?: any[]; + remarkPlugins?: any[]; +} + +export type RemixMdxConfigFunction = ( + filename: string +) => Promise | RemixMdxConfig | undefined; + +export type ServerModuleFormat = "esm" | "cjs"; +export type ServerPlatform = "node" | "neutral"; + +type Dev = { + command?: string; + manual?: boolean; + port?: number; + tlsKey?: string; + tlsCert?: string; +}; + +interface FutureConfig { + v3_fetcherPersist: boolean; + v3_relativeSplatPath: boolean; + v3_throwAbortReason: boolean; + unstable_singleFetch: boolean; +} + +type NodeBuiltinsPolyfillOptions = Pick< + EsbuildPluginsNodeModulesPolyfillOptions, + "modules" | "globals" +>; + +/** + * The user-provided config in `remix.config.js`. + */ +export interface AppConfig { + /** + * The path to the `app` directory, relative to `remix.config.js`. Defaults + * to `"app"`. + */ + appDirectory?: string; + + /** + * The path to a directory Remix can use for caching things in development, + * relative to `remix.config.js`. Defaults to `".cache"`. + */ + cacheDirectory?: string; + + /** + * A function for defining custom routes, in addition to those already defined + * using the filesystem convention in `app/routes`. Both sets of routes will + * be merged. + */ + routes?: ( + defineRoutes: DefineRoutesFunction + ) => + | ReturnType + | Promise>; + + /** + * The path to the browser build, relative to `remix.config.js`. Defaults to + * "public/build". + */ + assetsBuildDirectory?: string; + + /** + * The URL prefix of the browser build with a trailing slash. Defaults to + * `"/build/"`. This is the path the browser will use to find assets. + */ + publicPath?: string; + + /** + * Options for `remix dev`. See https://remix.run/other-api/dev#options-1 + */ + dev?: Dev; + + /** + * Additional MDX remark / rehype plugins. + */ + mdx?: RemixMdxConfig | RemixMdxConfigFunction; + + /** + * Whether to process CSS using PostCSS if a PostCSS config file is present. + * Defaults to `true`. + */ + postcss?: boolean; + + /** + * A server entrypoint, relative to the root directory that becomes your + * server's main module. If specified, Remix will compile this file along with + * your application into a single file to be deployed to your server. This + * file can use either a `.ts` or `.js` file extension. + */ + server?: string; + + /** + * The path to the server build file, relative to `remix.config.js`. This file + * should end in a `.js` extension and should be deployed to your server. + */ + serverBuildPath?: string; + + /** + * The order of conditions to use when resolving server dependencies' + * `exports` field in `package.json`. + * + * For more information, see: https://esbuild.github.io/api/#conditions + */ + serverConditions?: string[]; + + /** + * A list of patterns that determined if a module is transpiled and included + * in the server bundle. This can be useful when consuming ESM only packages + * in a CJS build. + */ + serverDependenciesToBundle?: "all" | Array; + + /** + * The order of main fields to use when resolving server dependencies. + * Defaults to `["main", "module"]`. + * + * For more information, see: https://esbuild.github.io/api/#main-fields + */ + serverMainFields?: string[]; + + /** + * Whether to minify the server build in production or not. + * Defaults to `false`. + */ + serverMinify?: boolean; + + /** + * The output format of the server build. Defaults to "esm". + */ + serverModuleFormat?: ServerModuleFormat; + + /** + * The Node.js polyfills to include in the server build when targeting + * non-Node.js server platforms. + */ + serverNodeBuiltinsPolyfill?: NodeBuiltinsPolyfillOptions; + + /** + * The Node.js polyfills to include in the browser build. + */ + browserNodeBuiltinsPolyfill?: NodeBuiltinsPolyfillOptions; + + /** + * The platform the server build is targeting. Defaults to "node". + */ + serverPlatform?: ServerPlatform; + + /** + * Whether to support Tailwind functions and directives in CSS files if + * `tailwindcss` is installed. Defaults to `true`. + */ + tailwind?: boolean; + + /** + * A list of filenames or a glob patterns to match files in the `app/routes` + * directory that Remix will ignore. Matching files will not be recognized as + * routes. + */ + ignoredRouteFiles?: string[]; + + /** + * A function for defining custom directories to watch while running `remix dev`, + * in addition to `appDirectory`. + */ + watchPaths?: + | string + | string[] + | (() => Promise | string | string[]); + + /** + * Enabled future flags + */ + future?: [keyof FutureConfig] extends [never] + ? // Partial doesn't work when it's empty so just prevent any keys + { [key: string]: never } + : Partial; +} + +/** + * Fully resolved configuration object we use throughout Remix. + */ +export interface RemixConfig { + /** + * The absolute path to the root of the Remix project. + */ + rootDirectory: string; + + /** + * The absolute path to the application source directory. + */ + appDirectory: string; + + /** + * The absolute path to the cache directory. + */ + cacheDirectory: string; + + /** + * The path to the entry.client file, relative to `config.appDirectory`. + */ + entryClientFile: string; + + /** + * The absolute path to the entry.client file. + */ + entryClientFilePath: string; + + /** + * The path to the entry.server file, relative to `config.appDirectory`. + */ + entryServerFile: string; + + /** + * The absolute path to the entry.server file. + */ + entryServerFilePath: string; + + /** + * An object of all available routes, keyed by route id. + */ + routes: RouteManifest; + + /** + * The absolute path to the assets build directory. + */ + assetsBuildDirectory: string; + + /** + * the original relative path to the assets build directory + */ + relativeAssetsBuildDirectory: string; + + /** + * The URL prefix of the public build with a trailing slash. + */ + publicPath: string; + + /** + * Options for `remix dev`. See https://remix.run/other-api/dev#options-1 + */ + dev: Dev; + + /** + * Additional MDX remark / rehype plugins. + */ + mdx?: RemixMdxConfig | RemixMdxConfigFunction; + + /** + * Whether to process CSS using PostCSS if a PostCSS config file is present. + * Defaults to `true`. + */ + postcss: boolean; + + /** + * The path to the server build file. This file should end in a `.js`. + */ + serverBuildPath: string; + + /** + * The default entry module for the server build if a {@see AppConfig.server} + * is not provided. + */ + serverBuildTargetEntryModule: string; + + /** + * The order of conditions to use when resolving server dependencies' + * `exports` field in `package.json`. + * + * For more information, see: https://esbuild.github.io/api/#conditions + */ + serverConditions?: string[]; + + /** + * A list of patterns that determined if a module is transpiled and included + * in the server bundle. This can be useful when consuming ESM only packages + * in a CJS build. + */ + serverDependenciesToBundle: "all" | Array; + + /** + * A server entrypoint relative to the root directory that becomes your + * server's main module. + */ + serverEntryPoint?: string; + + /** + * The order of main fields to use when resolving server dependencies. + * Defaults to `["main", "module"]`. + * + * For more information, see: https://esbuild.github.io/api/#main-fields + */ + serverMainFields: string[]; + + /** + * Whether to minify the server build in production or not. + * Defaults to `false`. + */ + serverMinify: boolean; + + /** + * The mode to use to run the server. + */ + serverMode: ServerMode; + + /** + * The output format of the server build. Defaults to "esm". + */ + serverModuleFormat: ServerModuleFormat; + + /** + * The Node.js polyfills to include in the server build when targeting + * non-Node.js server platforms. + */ + serverNodeBuiltinsPolyfill?: NodeBuiltinsPolyfillOptions; + + /** + * The Node.js polyfills to include in the browser build. + */ + browserNodeBuiltinsPolyfill?: NodeBuiltinsPolyfillOptions; + + /** + * The platform the server build is targeting. Defaults to "node". + */ + serverPlatform: ServerPlatform; + + /** + * Whether to support Tailwind functions and directives in CSS files if `tailwindcss` is installed. + * Defaults to `true`. + */ + tailwind: boolean; + + /** + * A list of directories to watch. + */ + watchPaths: string[]; + + /** + * The path for the tsconfig file, if present on the root directory. + */ + tsconfigPath: string | undefined; + + future: FutureConfig; +} + +/** + * Returns a fully resolved config object from the remix.config.js in the given + * root directory. + */ +export async function readConfig( + remixRoot?: string, + serverMode?: ServerMode +): Promise { + if (!remixRoot) { + remixRoot = process.env.REMIX_ROOT || process.cwd(); + } + + let rootDirectory = path.resolve(remixRoot); + let configFile = findConfig(rootDirectory, "remix.config", configExts); + + let appConfig: AppConfig = {}; + if (configFile) { + let appConfigModule: any; + try { + // shout out to next + // https://github.com/vercel/next.js/blob/b15a976e11bf1dc867c241a4c1734757427d609c/packages/next/server/config.ts#L748-L765 + if (process.env.JEST_WORKER_ID) { + // dynamic import does not currently work inside vm which + // jest relies on, so we fall back to require for this case + // https://github.com/nodejs/node/issues/35889 + appConfigModule = require(configFile); + } else { + let stat = fse.statSync(configFile); + appConfigModule = await import( + pathToFileURL(configFile).href + "?t=" + stat.mtimeMs + ); + } + appConfig = appConfigModule?.default || appConfigModule; + } catch (error: unknown) { + throw new Error( + `Error loading Remix config at ${configFile}\n${String(error)}` + ); + } + } + + return await resolveConfig(appConfig, { + rootDirectory, + serverMode, + }); +} + +export async function resolveConfig( + appConfig: AppConfig, + { + rootDirectory, + serverMode = ServerMode.Production, + isSpaMode = false, + }: { + rootDirectory: string; + serverMode?: ServerMode; + isSpaMode?: boolean; + } +): Promise { + if (!isValidServerMode(serverMode)) { + throw new Error(`Invalid server mode "${serverMode}"`); + } + + let serverBuildPath = path.resolve( + rootDirectory, + appConfig.serverBuildPath ?? "build/index.js" + ); + let serverBuildTargetEntryModule = `export * from ${JSON.stringify( + serverBuildVirtualModule.id + )};`; + let serverConditions = appConfig.serverConditions; + let serverDependenciesToBundle = appConfig.serverDependenciesToBundle || []; + let serverEntryPoint = appConfig.server; + let serverMainFields = appConfig.serverMainFields; + let serverMinify = appConfig.serverMinify; + + let serverModuleFormat = appConfig.serverModuleFormat || "esm"; + let serverPlatform = appConfig.serverPlatform || "node"; + serverMainFields ??= + serverModuleFormat === "esm" ? ["module", "main"] : ["main", "module"]; + serverMinify ??= false; + + let serverNodeBuiltinsPolyfill = appConfig.serverNodeBuiltinsPolyfill; + let browserNodeBuiltinsPolyfill = appConfig.browserNodeBuiltinsPolyfill; + let mdx = appConfig.mdx; + let postcss = appConfig.postcss ?? true; + let tailwind = appConfig.tailwind ?? true; + + let appDirectory = path.resolve( + rootDirectory, + appConfig.appDirectory || "app" + ); + + let cacheDirectory = path.resolve( + rootDirectory, + appConfig.cacheDirectory || ".cache" + ); + + let defaultsDirectory = path.resolve(__dirname, "config", "defaults"); + + let userEntryClientFile = findEntry(appDirectory, "entry.client"); + let userEntryServerFile = findEntry(appDirectory, "entry.server"); + + let entryServerFile: string; + let entryClientFile = userEntryClientFile || "entry.client.tsx"; + + let pkgJson = await PackageJson.load(rootDirectory); + let deps = pkgJson.content.dependencies ?? {}; + + if (isSpaMode && appConfig.future?.unstable_singleFetch != true) { + // This is a super-simple default since we don't need streaming in SPA Mode. + // We can include this in a remix-spa template, but right now `npx remix reveal` + // will still expose the streaming template since that command doesn't have + // access to the `ssr:false` flag in the vite config (the streaming template + // works just fine so maybe instea dof having this we _only have this version + // in the template...). We let users manage an entry.server file in SPA Mode + // so they can de ide if they want to hydrate the full document or just an + // embedded `
    ` or whatever. + entryServerFile = "entry.server.spa.tsx"; + } else if (userEntryServerFile) { + entryServerFile = userEntryServerFile; + } else { + let serverRuntime = deps["@remix-run/deno"] + ? "deno" + : deps["@remix-run/cloudflare"] + ? "cloudflare" + : deps["@remix-run/node"] + ? "node" + : undefined; + + if (!serverRuntime) { + let serverRuntimes = [ + "@remix-run/deno", + "@remix-run/cloudflare", + "@remix-run/node", + ]; + let formattedList = disjunctionListFormat.format(serverRuntimes); + throw new Error( + `Could not determine server runtime. Please install one of the following: ${formattedList}` + ); + } + + if (!deps["isbot"]) { + console.log( + "adding `isbot` to your package.json, you should commit this change" + ); + + pkgJson.update({ + dependencies: { + ...pkgJson.content.dependencies, + isbot: "^4", + }, + }); + + await pkgJson.save(); + + let packageManager = detectPackageManager() ?? "npm"; + + execSync(`${packageManager} install`, { + cwd: rootDirectory, + stdio: "inherit", + }); + } + + entryServerFile = `entry.server.${serverRuntime}.tsx`; + } + + let entryClientFilePath = userEntryClientFile + ? path.resolve(appDirectory, userEntryClientFile) + : path.resolve(defaultsDirectory, entryClientFile); + + let entryServerFilePath = userEntryServerFile + ? path.resolve(appDirectory, userEntryServerFile) + : path.resolve(defaultsDirectory, entryServerFile); + + let assetsBuildDirectory = + appConfig.assetsBuildDirectory || path.join("public", "build"); + + let absoluteAssetsBuildDirectory = path.resolve( + rootDirectory, + assetsBuildDirectory + ); + + let publicPath = addTrailingSlash(appConfig.publicPath || "/build/"); + + let rootRouteFile = findEntry(appDirectory, "root"); + if (!rootRouteFile) { + throw new Error(`Missing "root" route file in ${appDirectory}`); + } + + let routes: RouteManifest = { + root: { path: "", id: "root", file: rootRouteFile }, + }; + + if (fse.existsSync(path.resolve(appDirectory, "routes"))) { + let fileRoutes = flatRoutes(appDirectory, appConfig.ignoredRouteFiles); + for (let route of Object.values(fileRoutes)) { + routes[route.id] = { ...route, parentId: route.parentId || "root" }; + } + } + if (appConfig.routes) { + let manualRoutes = await appConfig.routes(defineRoutes); + for (let route of Object.values(manualRoutes)) { + routes[route.id] = { ...route, parentId: route.parentId || "root" }; + } + } + + let watchPaths: string[] = []; + if (typeof appConfig.watchPaths === "function") { + let directories = await appConfig.watchPaths(); + watchPaths = watchPaths.concat( + Array.isArray(directories) ? directories : [directories] + ); + } else if (appConfig.watchPaths) { + watchPaths = watchPaths.concat( + Array.isArray(appConfig.watchPaths) + ? appConfig.watchPaths + : [appConfig.watchPaths] + ); + } + + // When tsconfigPath is undefined, the default "tsconfig.json" is not + // found in the root directory. + let tsconfigPath: string | undefined; + let rootTsconfig = path.resolve(rootDirectory, "tsconfig.json"); + let rootJsConfig = path.resolve(rootDirectory, "jsconfig.json"); + + if (fse.existsSync(rootTsconfig)) { + tsconfigPath = rootTsconfig; + } else if (fse.existsSync(rootJsConfig)) { + tsconfigPath = rootJsConfig; + } + + // Note: When a future flag is removed from here, it should be added to the + // list below, so we can let folks know if they have obsolete flags in their + // config. If we ever convert remix.config.js to a TS file, so we get proper + // typings this won't be necessary anymore. + let future: FutureConfig = { + v3_fetcherPersist: appConfig.future?.v3_fetcherPersist === true, + v3_relativeSplatPath: appConfig.future?.v3_relativeSplatPath === true, + v3_throwAbortReason: appConfig.future?.v3_throwAbortReason === true, + unstable_singleFetch: appConfig.future?.unstable_singleFetch === true, + }; + + if (appConfig.future) { + let userFlags = appConfig.future; + let deprecatedFlags = [ + "unstable_cssModules", + "unstable_cssSideEffectImports", + "unstable_dev", + "unstable_postcss", + "unstable_tailwind", + "unstable_vanillaExtract", + "v2_errorBoundary", + "v2_headers", + "v2_meta", + "v2_normalizeFormMethod", + "v2_routeConvention", + ]; + + if ("v2_dev" in userFlags) { + if (userFlags.v2_dev === true) { + deprecatedFlags.push("v2_dev"); + } else { + logger.warn("The `v2_dev` future flag is obsolete.", { + details: [ + "Move your dev options from `future.v2_dev` to `dev` within your `remix.config.js` file", + ], + }); + } + } + + let obsoleteFlags = deprecatedFlags.filter((f) => f in userFlags); + if (obsoleteFlags.length > 0) { + logger.warn( + `The following Remix future flags are now obsolete ` + + `and can be removed from your remix.config.js file:\n` + + obsoleteFlags.map((f) => `- ${f}\n`).join("") + ); + } + } + + return { + appDirectory, + cacheDirectory, + entryClientFile, + entryClientFilePath, + entryServerFile, + entryServerFilePath, + dev: appConfig.dev ?? {}, + assetsBuildDirectory: absoluteAssetsBuildDirectory, + relativeAssetsBuildDirectory: assetsBuildDirectory, + publicPath, + rootDirectory, + routes, + serverBuildPath, + serverBuildTargetEntryModule, + serverConditions, + serverDependenciesToBundle, + serverEntryPoint, + serverMainFields, + serverMinify, + serverMode, + serverModuleFormat, + serverNodeBuiltinsPolyfill, + browserNodeBuiltinsPolyfill, + serverPlatform, + mdx, + postcss, + tailwind, + watchPaths, + tsconfigPath, + future, + }; +} + +function addTrailingSlash(path: string): string { + return path.endsWith("/") ? path : path + "/"; +} + +const entryExts = [".js", ".jsx", ".ts", ".tsx"]; + +function findEntry(dir: string, basename: string): string | undefined { + for (let ext of entryExts) { + let file = path.resolve(dir, basename + ext); + if (fse.existsSync(file)) return path.relative(dir, file); + } + + return undefined; +} + +const configExts = [".js", ".cjs", ".mjs"]; + +export function findConfig( + dir: string, + basename: string, + extensions: string[] +): string | undefined { + for (let ext of extensions) { + let name = basename + ext; + let file = path.join(dir, name); + if (fse.existsSync(file)) return file; + } + + return undefined; +} + +// adds types for `Intl.ListFormat` to the global namespace +// we could also update our `tsconfig.json` to include `lib: ["es2021"]` +declare namespace Intl { + type ListType = "conjunction" | "disjunction"; + + interface ListFormatOptions { + localeMatcher?: "lookup" | "best fit"; + type?: ListType; + style?: "long" | "short" | "narrow"; + } + + interface ListFormatPart { + type: "element" | "literal"; + value: string; + } + + class ListFormat { + constructor(locales?: string | string[], options?: ListFormatOptions); + format(values: any[]): string; + formatToParts(values: any[]): ListFormatPart[]; + supportedLocalesOf( + locales: string | string[], + options?: ListFormatOptions + ): string[]; + } +} + +let disjunctionListFormat = new Intl.ListFormat("en", { + style: "long", + type: "disjunction", +}); diff --git a/packages/remix-dev/config/defaults/entry.client.tsx b/packages/remix-dev/config/defaults/entry.client.tsx new file mode 100644 index 0000000000..999c0a128c --- /dev/null +++ b/packages/remix-dev/config/defaults/entry.client.tsx @@ -0,0 +1,12 @@ +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/packages/remix-dev/config/defaults/entry.dev.ts b/packages/remix-dev/config/defaults/entry.dev.ts new file mode 100644 index 0000000000..036c6c04a9 --- /dev/null +++ b/packages/remix-dev/config/defaults/entry.dev.ts @@ -0,0 +1,13 @@ +// @ts-nocheck +/* eslint-disable */ + +export default () => { + import("react"); + import("react/jsx-dev-runtime"); + import("react/jsx-runtime"); + import("react-dom"); + import("react-dom/client"); + import("react-refresh/runtime"); + import("@remix-run/react"); + import("remix:hmr"); +}; diff --git a/packages/remix-dev/config/defaults/entry.server.cloudflare.tsx b/packages/remix-dev/config/defaults/entry.server.cloudflare.tsx new file mode 100644 index 0000000000..e7bb7a4f4f --- /dev/null +++ b/packages/remix-dev/config/defaults/entry.server.cloudflare.tsx @@ -0,0 +1,55 @@ +import type { AppLoadContext, EntryContext } from "@remix-run/cloudflare"; +import { RemixServer } from "@remix-run/react"; +import * as isbotModule from "isbot"; +import { renderToReadableStream } from "react-dom/server"; + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext +) { + const body = await renderToReadableStream( + , + { + signal: request.signal, + onError(error: unknown) { + // Log streaming rendering errors from inside the shell + console.error(error); + responseStatusCode = 500; + }, + } + ); + + if (isBotRequest(request.headers.get("user-agent"))) { + await body.allReady; + } + + responseHeaders.set("Content-Type", "text/html"); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} + +// We have some Remix apps in the wild already running with isbot@3 so we need +// to maintain backwards compatibility even though we want new apps to use +// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev. +function isBotRequest(userAgent: string | null) { + if (!userAgent) { + return false; + } + + // isbot >= 3.8.0, >4 + if ("isbot" in isbotModule && typeof isbotModule.isbot === "function") { + return isbotModule.isbot(userAgent); + } + + // isbot < 3.8.0 + if ("default" in isbotModule && typeof isbotModule.default === "function") { + return isbotModule.default(userAgent); + } + + return false; +} diff --git a/packages/remix-dev/config/defaults/entry.server.deno.tsx b/packages/remix-dev/config/defaults/entry.server.deno.tsx new file mode 100644 index 0000000000..cef1e72d97 --- /dev/null +++ b/packages/remix-dev/config/defaults/entry.server.deno.tsx @@ -0,0 +1,55 @@ +import type { AppLoadContext, EntryContext } from "@remix-run/deno"; +import { RemixServer } from "@remix-run/react"; +import * as isbotModule from "isbot"; +import { renderToReadableStream } from "react-dom/server"; + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext +) { + const body = await renderToReadableStream( + , + { + signal: request.signal, + onError(error: unknown) { + // Log streaming rendering errors from inside the shell + console.error(error); + responseStatusCode = 500; + }, + } + ); + + if (isBotRequest(request.headers.get("user-agent"))) { + await body.allReady; + } + + responseHeaders.set("Content-Type", "text/html"); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} + +// We have some Remix apps in the wild already running with isbot@3 so we need +// to maintain backwards compatibility even though we want new apps to use +// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev. +function isBotRequest(userAgent: string | null) { + if (!userAgent) { + return false; + } + + // isbot >= 3.8.0, >4 + if ("isbot" in isbotModule && typeof isbotModule.isbot === "function") { + return isbotModule.isbot(userAgent); + } + + // isbot < 3.8.0 + if ("default" in isbotModule && typeof isbotModule.default === "function") { + return isbotModule.default(userAgent); + } + + return false; +} diff --git a/packages/remix-dev/config/defaults/entry.server.node.tsx b/packages/remix-dev/config/defaults/entry.server.node.tsx new file mode 100644 index 0000000000..65ad16eb8b --- /dev/null +++ b/packages/remix-dev/config/defaults/entry.server.node.tsx @@ -0,0 +1,155 @@ +import { PassThrough } from "node:stream"; + +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import * as isbotModule from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; + +const ABORT_DELAY = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext +) { + let prohibitOutOfOrderStreaming = + isBotRequest(request.headers.get("user-agent")) || remixContext.isSpaMode; + + return prohibitOutOfOrderStreaming + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); +} + +// We have some Remix apps in the wild already running with isbot@3 so we need +// to maintain backwards compatibility even though we want new apps to use +// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev. +function isBotRequest(userAgent: string | null) { + if (!userAgent) { + return false; + } + + // isbot >= 3.8.0, >4 + if ("isbot" in isbotModule && typeof isbotModule.isbot === "function") { + return isbotModule.isbot(userAgent); + } + + // isbot < 3.8.0 + if ("default" in isbotModule && typeof isbotModule.default === "function") { + return isbotModule.default(userAgent); + } + + return false; +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/packages/remix-dev/config/defaults/entry.server.spa.tsx b/packages/remix-dev/config/defaults/entry.server.spa.tsx new file mode 100644 index 0000000000..a8db60573a --- /dev/null +++ b/packages/remix-dev/config/defaults/entry.server.spa.tsx @@ -0,0 +1,20 @@ +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import * as React from "react"; +import { renderToString } from "react-dom/server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + let html = renderToString( + + ); + html = "\n" + html; + return new Response(html, { + headers: { "Content-Type": "text/html" }, + status: responseStatusCode, + }); +} diff --git a/packages/remix-dev/config/flat-routes.ts b/packages/remix-dev/config/flat-routes.ts new file mode 100644 index 0000000000..27518daada --- /dev/null +++ b/packages/remix-dev/config/flat-routes.ts @@ -0,0 +1,550 @@ +import fs from "node:fs"; +import path from "node:path"; +import { makeRe } from "minimatch"; + +import type { ConfigRoute, RouteManifest } from "./routes"; +import { normalizeSlashes } from "./routes"; +import { findConfig } from "../config"; + +export const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]; + +export let paramPrefixChar = "$" as const; +export let escapeStart = "[" as const; +export let escapeEnd = "]" as const; + +export let optionalStart = "(" as const; +export let optionalEnd = ")" as const; + +const PrefixLookupTrieEndSymbol = Symbol("PrefixLookupTrieEndSymbol"); +type PrefixLookupNode = { + [key: string]: PrefixLookupNode; +} & Record; + +class PrefixLookupTrie { + root: PrefixLookupNode = { + [PrefixLookupTrieEndSymbol]: false, + }; + + add(value: string) { + if (!value) throw new Error("Cannot add empty string to PrefixLookupTrie"); + + let node = this.root; + for (let char of value) { + if (!node[char]) { + node[char] = { + [PrefixLookupTrieEndSymbol]: false, + }; + } + node = node[char]; + } + node[PrefixLookupTrieEndSymbol] = true; + } + + findAndRemove( + prefix: string, + filter: (nodeValue: string) => boolean + ): string[] { + let node = this.root; + for (let char of prefix) { + if (!node[char]) return []; + node = node[char]; + } + + return this.#findAndRemoveRecursive([], node, prefix, filter); + } + + #findAndRemoveRecursive( + values: string[], + node: PrefixLookupNode, + prefix: string, + filter: (nodeValue: string) => boolean + ): string[] { + for (let char of Object.keys(node)) { + this.#findAndRemoveRecursive(values, node[char], prefix + char, filter); + } + + if (node[PrefixLookupTrieEndSymbol] && filter(prefix)) { + node[PrefixLookupTrieEndSymbol] = false; + values.push(prefix); + } + + return values; + } +} + +export function flatRoutes( + appDirectory: string, + ignoredFilePatterns: string[] = [], + prefix = "routes" +) { + let ignoredFileRegex = Array.from(new Set(["**/.*", ...ignoredFilePatterns])) + .map((re) => makeRe(re)) + .filter((re: any): re is RegExp => !!re); + let routesDir = path.join(appDirectory, prefix); + + let rootRoute = findConfig(appDirectory, "root", routeModuleExts); + + if (!rootRoute) { + throw new Error( + `Could not find a root route module in the app directory: ${appDirectory}` + ); + } + + if (!fs.existsSync(rootRoute)) { + throw new Error( + `Could not find the routes directory: ${routesDir}. Did you forget to create it?` + ); + } + + // Only read the routes directory + let entries = fs.readdirSync(routesDir, { + withFileTypes: true, + encoding: "utf-8", + }); + + let routes: string[] = []; + for (let entry of entries) { + let filepath = normalizeSlashes(path.join(routesDir, entry.name)); + + let route: string | null = null; + // If it's a directory, don't recurse into it, instead just look for a route module + if (entry.isDirectory()) { + route = findRouteModuleForFolder( + appDirectory, + filepath, + ignoredFileRegex + ); + } else if (entry.isFile()) { + route = findRouteModuleForFile(appDirectory, filepath, ignoredFileRegex); + } + + if (route) routes.push(route); + } + + let routeManifest = flatRoutesUniversal(appDirectory, routes, prefix); + return routeManifest; +} + +export function flatRoutesUniversal( + appDirectory: string, + routes: string[], + prefix: string = "routes" +): RouteManifest { + let urlConflicts = new Map(); + let routeManifest: RouteManifest = {}; + let prefixLookup = new PrefixLookupTrie(); + let uniqueRoutes = new Map(); + let routeIdConflicts = new Map(); + + // id -> file + let routeIds = new Map(); + + for (let file of routes) { + let normalizedFile = normalizeSlashes(file); + let routeExt = path.extname(normalizedFile); + let routeDir = path.dirname(normalizedFile); + let normalizedApp = normalizeSlashes(appDirectory); + let routeId = + routeDir === path.posix.join(normalizedApp, prefix) + ? path.posix + .relative(normalizedApp, normalizedFile) + .slice(0, -routeExt.length) + : path.posix.relative(normalizedApp, routeDir); + + let conflict = routeIds.get(routeId); + if (conflict) { + let currentConflicts = routeIdConflicts.get(routeId); + if (!currentConflicts) { + currentConflicts = [path.posix.relative(normalizedApp, conflict)]; + } + currentConflicts.push(path.posix.relative(normalizedApp, normalizedFile)); + routeIdConflicts.set(routeId, currentConflicts); + continue; + } + + routeIds.set(routeId, normalizedFile); + } + + let sortedRouteIds = Array.from(routeIds).sort( + ([a], [b]) => b.length - a.length + ); + + for (let [routeId, file] of sortedRouteIds) { + let index = routeId.endsWith("_index"); + let [segments, raw] = getRouteSegments(routeId.slice(prefix.length + 1)); + let pathname = createRoutePath(segments, raw, index); + + routeManifest[routeId] = { + file: file.slice(appDirectory.length + 1), + id: routeId, + path: pathname, + }; + if (index) routeManifest[routeId].index = true; + let childRouteIds = prefixLookup.findAndRemove(routeId, (value) => { + return [".", "/"].includes(value.slice(routeId.length).charAt(0)); + }); + prefixLookup.add(routeId); + + if (childRouteIds.length > 0) { + for (let childRouteId of childRouteIds) { + routeManifest[childRouteId].parentId = routeId; + } + } + } + + // path creation + let parentChildrenMap = new Map(); + for (let [routeId] of sortedRouteIds) { + let config = routeManifest[routeId]; + if (!config.parentId) continue; + let existingChildren = parentChildrenMap.get(config.parentId) || []; + existingChildren.push(config); + parentChildrenMap.set(config.parentId, existingChildren); + } + + for (let [routeId] of sortedRouteIds) { + let config = routeManifest[routeId]; + let originalPathname = config.path || ""; + let pathname = config.path; + let parentConfig = config.parentId ? routeManifest[config.parentId] : null; + if (parentConfig?.path && pathname) { + pathname = pathname + .slice(parentConfig.path.length) + .replace(/^\//, "") + .replace(/\/$/, ""); + } + + if (!config.parentId) config.parentId = "root"; + config.path = pathname || undefined; + + /** + * We do not try to detect path collisions for pathless layout route + * files because, by definition, they create the potential for route + * collisions _at that level in the tree_. + * + * Consider example where a user may want multiple pathless layout routes + * for different subfolders + * + * routes/ + * account.tsx + * account._private.tsx + * account._private.orders.tsx + * account._private.profile.tsx + * account._public.tsx + * account._public.login.tsx + * account._public.perks.tsx + * + * In order to support both a public and private layout for `/account/*` + * URLs, we are creating a mutually exclusive set of URLs beneath 2 + * separate pathless layout routes. In this case, the route paths for + * both account._public.tsx and account._private.tsx is the same + * (/account), but we're again not expecting to match at that level. + * + * By only ignoring this check when the final portion of the filename is + * pathless, we will still detect path collisions such as: + * + * routes/parent._pathless.foo.tsx + * routes/parent._pathless2.foo.tsx + * + * and + * + * routes/parent._pathless/index.tsx + * routes/parent._pathless2/index.tsx + */ + let lastRouteSegment = config.id + .replace(new RegExp(`^${prefix}/`), "") + .split(".") + .pop(); + let isPathlessLayoutRoute = + lastRouteSegment && + lastRouteSegment.startsWith("_") && + lastRouteSegment !== "_index"; + if (isPathlessLayoutRoute) { + continue; + } + + let conflictRouteId = originalPathname + (config.index ? "?index" : ""); + let conflict = uniqueRoutes.get(conflictRouteId); + uniqueRoutes.set(conflictRouteId, config); + + if (conflict && (originalPathname || config.index)) { + let currentConflicts = urlConflicts.get(originalPathname); + if (!currentConflicts) currentConflicts = [conflict]; + currentConflicts.push(config); + urlConflicts.set(originalPathname, currentConflicts); + continue; + } + } + + if (routeIdConflicts.size > 0) { + for (let [routeId, files] of routeIdConflicts.entries()) { + console.error(getRouteIdConflictErrorMessage(routeId, files)); + } + } + + // report conflicts + if (urlConflicts.size > 0) { + for (let [path, routes] of urlConflicts.entries()) { + // delete all but the first route from the manifest + for (let i = 1; i < routes.length; i++) { + delete routeManifest[routes[i].id]; + } + let files = routes.map((r) => r.file); + console.error(getRoutePathConflictErrorMessage(path, files)); + } + } + + return routeManifest; +} + +function findRouteModuleForFile( + appDirectory: string, + filepath: string, + ignoredFileRegex: RegExp[] +): string | null { + let relativePath = normalizeSlashes(path.relative(appDirectory, filepath)); + let isIgnored = ignoredFileRegex.some((regex) => regex.test(relativePath)); + if (isIgnored) return null; + return filepath; +} + +function findRouteModuleForFolder( + appDirectory: string, + filepath: string, + ignoredFileRegex: RegExp[] +): string | null { + let relativePath = path.relative(appDirectory, filepath); + let isIgnored = ignoredFileRegex.some((regex) => regex.test(relativePath)); + if (isIgnored) return null; + + let routeRouteModule = findConfig(filepath, "route", routeModuleExts); + let routeIndexModule = findConfig(filepath, "index", routeModuleExts); + + // if both a route and index module exist, throw a conflict error + // preferring the route module over the index module + if (routeRouteModule && routeIndexModule) { + let [segments, raw] = getRouteSegments( + path.relative(appDirectory, filepath) + ); + let routePath = createRoutePath(segments, raw, false); + console.error( + getRoutePathConflictErrorMessage(routePath || "/", [ + routeRouteModule, + routeIndexModule, + ]) + ); + } + + return routeRouteModule || routeIndexModule || null; +} + +type State = + | // normal path segment normal character concatenation until we hit a special character or the end of the segment (i.e. `/`, `.`, '\') + "NORMAL" + // we hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks + | "ESCAPE" + // we hit a `(` and are now in an optional segment until we hit a `)` or an escape sequence + | "OPTIONAL" + // we previously were in a opt fional segment and hit a `[` and are now in an escape sequence until we hit a `]` - take characters literally and skip isSegmentSeparator checks - afterwards go back to OPTIONAL state + | "OPTIONAL_ESCAPE"; + +export function getRouteSegments(routeId: string): [string[], string[]] { + let routeSegments: string[] = []; + let rawRouteSegments: string[] = []; + let index = 0; + let routeSegment = ""; + let rawRouteSegment = ""; + let state: State = "NORMAL"; + + let pushRouteSegment = (segment: string, rawSegment: string) => { + if (!segment) return; + + let notSupportedInRR = (segment: string, char: string) => { + throw new Error( + `Route segment "${segment}" for "${routeId}" cannot contain "${char}".\n` + + `If this is something you need, upvote this proposal for React Router https://github.com/remix-run/react-router/discussions/9822.` + ); + }; + + if (rawSegment.includes("*")) { + return notSupportedInRR(rawSegment, "*"); + } + + if (rawSegment.includes(":")) { + return notSupportedInRR(rawSegment, ":"); + } + + if (rawSegment.includes("/")) { + return notSupportedInRR(segment, "/"); + } + + routeSegments.push(segment); + rawRouteSegments.push(rawSegment); + }; + + while (index < routeId.length) { + let char = routeId[index]; + index++; //advance to next char + + switch (state) { + case "NORMAL": { + if (isSegmentSeparator(char)) { + pushRouteSegment(routeSegment, rawRouteSegment); + routeSegment = ""; + rawRouteSegment = ""; + state = "NORMAL"; + break; + } + if (char === escapeStart) { + state = "ESCAPE"; + rawRouteSegment += char; + break; + } + if (char === optionalStart) { + state = "OPTIONAL"; + rawRouteSegment += char; + break; + } + if (!routeSegment && char == paramPrefixChar) { + if (index === routeId.length) { + routeSegment += "*"; + rawRouteSegment += char; + } else { + routeSegment += ":"; + rawRouteSegment += char; + } + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "ESCAPE": { + if (char === escapeEnd) { + state = "NORMAL"; + rawRouteSegment += char; + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "OPTIONAL": { + if (char === optionalEnd) { + routeSegment += "?"; + rawRouteSegment += char; + state = "NORMAL"; + break; + } + + if (char === escapeStart) { + state = "OPTIONAL_ESCAPE"; + rawRouteSegment += char; + break; + } + + if (!routeSegment && char === paramPrefixChar) { + if (index === routeId.length) { + routeSegment += "*"; + rawRouteSegment += char; + } else { + routeSegment += ":"; + rawRouteSegment += char; + } + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + case "OPTIONAL_ESCAPE": { + if (char === escapeEnd) { + state = "OPTIONAL"; + rawRouteSegment += char; + break; + } + + routeSegment += char; + rawRouteSegment += char; + break; + } + } + } + + // process remaining segment + pushRouteSegment(routeSegment, rawRouteSegment); + return [routeSegments, rawRouteSegments]; +} + +export function createRoutePath( + routeSegments: string[], + rawRouteSegments: string[], + isIndex?: boolean +) { + let result: string[] = []; + + if (isIndex) { + routeSegments = routeSegments.slice(0, -1); + } + + for (let index = 0; index < routeSegments.length; index++) { + let segment = routeSegments[index]; + let rawSegment = rawRouteSegments[index]; + + // skip pathless layout segments + if (segment.startsWith("_") && rawSegment.startsWith("_")) { + continue; + } + + // remove trailing slash + if (segment.endsWith("_") && rawSegment.endsWith("_")) { + segment = segment.slice(0, -1); + } + + result.push(segment); + } + + return result.length ? result.join("/") : undefined; +} + +export function getRoutePathConflictErrorMessage( + pathname: string, + routes: string[] +) { + let [taken, ...others] = routes; + + if (!pathname.startsWith("/")) { + pathname = "/" + pathname; + } + + return ( + `⚠️ Route Path Collision: "${pathname}"\n\n` + + `The following routes all define the same URL, only the first one will be used\n\n` + + `🟢 ${taken}\n` + + others.map((route) => `⭕️️ ${route}`).join("\n") + + "\n" + ); +} + +export function getRouteIdConflictErrorMessage( + routeId: string, + files: string[] +) { + let [taken, ...others] = files; + + return ( + `⚠️ Route ID Collision: "${routeId}"\n\n` + + `The following routes all define the same Route ID, only the first one will be used\n\n` + + `🟢 ${taken}\n` + + others.map((route) => `⭕️️ ${route}`).join("\n") + + "\n" + ); +} + +export function isSegmentSeparator(checkChar: string | undefined) { + if (!checkChar) return false; + return ["/", ".", path.win32.sep].includes(checkChar); +} diff --git a/packages/remix-dev/config/format.ts b/packages/remix-dev/config/format.ts new file mode 100644 index 0000000000..3a58fa22ef --- /dev/null +++ b/packages/remix-dev/config/format.ts @@ -0,0 +1,91 @@ +import type { RouteManifest } from "./routes"; + +export type RoutesFormat = "json" | "jsx"; + +export function formatRoutes( + routeManifest: RouteManifest, + format: RoutesFormat +) { + switch (format) { + case "json": + return formatRoutesAsJson(routeManifest); + case "jsx": + return formatRoutesAsJsx(routeManifest); + } +} + +type JsonFormattedRoute = { + id: string; + index?: boolean; + path?: string; + caseSensitive?: boolean; + file: string; + children?: JsonFormattedRoute[]; +}; + +export function formatRoutesAsJson(routeManifest: RouteManifest): string { + function handleRoutesRecursive( + parentId?: string + ): JsonFormattedRoute[] | undefined { + let routes = Object.values(routeManifest).filter( + (route) => route.parentId === parentId + ); + + let children = []; + + for (let route of routes) { + children.push({ + id: route.id, + index: route.index, + path: route.path, + caseSensitive: route.caseSensitive, + file: route.file, + children: handleRoutesRecursive(route.id), + }); + } + + if (children.length > 0) { + return children; + } + return undefined; + } + + return JSON.stringify(handleRoutesRecursive() || null, null, 2); +} + +export function formatRoutesAsJsx(routeManifest: RouteManifest) { + let output = ""; + + function handleRoutesRecursive(parentId?: string, level = 1): boolean { + let routes = Object.values(routeManifest).filter( + (route) => route.parentId === parentId + ); + + let indent = Array(level * 2) + .fill(" ") + .join(""); + + for (let route of routes) { + output += "\n" + indent; + output += ``; + if (handleRoutesRecursive(route.id, level + 1)) { + output += "\n" + indent; + output += ""; + } else { + output = output.slice(0, -1) + " />"; + } + } + + return routes.length > 0; + } + + handleRoutesRecursive(); + + output += "\n"; + + return output; +} diff --git a/packages/remix-dev/config/routes.ts b/packages/remix-dev/config/routes.ts new file mode 100644 index 0000000000..c793e3bade --- /dev/null +++ b/packages/remix-dev/config/routes.ts @@ -0,0 +1,190 @@ +import * as path from "node:path"; + +/** + * A route that was created using `defineRoutes` or created conventionally from + * looking at the files on the filesystem. + */ +export interface ConfigRoute { + /** + * The path this route uses to match on the URL pathname. + */ + path?: string; + + /** + * Should be `true` if it is an index route. This disallows child routes. + */ + index?: boolean; + + /** + * Should be `true` if the `path` is case-sensitive. Defaults to `false`. + */ + caseSensitive?: boolean; + + /** + * The unique id for this route, named like its `file` but without the + * extension. So `app/routes/gists/$username.tsx` will have an `id` of + * `routes/gists/$username`. + */ + id: string; + + /** + * The unique `id` for this route's parent route, if there is one. + */ + parentId?: string; + + /** + * The path to the entry point for this route, relative to + * `config.appDirectory`. + */ + file: string; +} + +export interface RouteManifest { + [routeId: string]: ConfigRoute; +} + +export interface DefineRouteOptions { + /** + * Should be `true` if the route `path` is case-sensitive. Defaults to + * `false`. + */ + caseSensitive?: boolean; + + /** + * Should be `true` if this is an index route that does not allow child routes. + */ + index?: boolean; + + /** + * An optional unique id string for this route. Use this if you need to aggregate + * two or more routes with the same route file. + */ + id?: string; +} + +interface DefineRouteChildren { + (): void; +} + +/** + * A function for defining a route that is passed as the argument to the + * `defineRoutes` callback. + * + * Calls to this function are designed to be nested, using the `children` + * callback argument. + * + * defineRoutes(route => { + * route('/', 'pages/layout', () => { + * route('react-router', 'pages/react-router'); + * route('reach-ui', 'pages/reach-ui'); + * }); + * }); + */ +export interface DefineRouteFunction { + ( + /** + * The path this route uses to match the URL pathname. + */ + path: string | undefined, + + /** + * The path to the file that exports the React component rendered by this + * route as its default export, relative to the `app` directory. + */ + file: string, + + /** + * Options for defining routes, or a function for defining child routes. + */ + optionsOrChildren?: DefineRouteOptions | DefineRouteChildren, + + /** + * A function for defining child routes. + */ + children?: DefineRouteChildren + ): void; +} + +export type DefineRoutesFunction = typeof defineRoutes; + +/** + * A function for defining routes programmatically, instead of using the + * filesystem convention. + */ +export function defineRoutes( + callback: (defineRoute: DefineRouteFunction) => void +): RouteManifest { + let routes: RouteManifest = Object.create(null); + let parentRoutes: ConfigRoute[] = []; + let alreadyReturned = false; + + let defineRoute: DefineRouteFunction = ( + path, + file, + optionsOrChildren, + children + ) => { + if (alreadyReturned) { + throw new Error( + "You tried to define routes asynchronously but started defining " + + "routes before the async work was done. Please await all async " + + "data before calling `defineRoutes()`" + ); + } + + let options: DefineRouteOptions; + if (typeof optionsOrChildren === "function") { + // route(path, file, children) + options = {}; + children = optionsOrChildren; + } else { + // route(path, file, options, children) + // route(path, file, options) + options = optionsOrChildren || {}; + } + + let route: ConfigRoute = { + path: path ? path : undefined, + index: options.index ? true : undefined, + caseSensitive: options.caseSensitive ? true : undefined, + id: options.id || createRouteId(file), + parentId: + parentRoutes.length > 0 + ? parentRoutes[parentRoutes.length - 1].id + : "root", + file, + }; + + if (route.id in routes) { + throw new Error( + `Unable to define routes with duplicate route id: "${route.id}"` + ); + } + + routes[route.id] = route; + + if (children) { + parentRoutes.push(route); + children(); + parentRoutes.pop(); + } + }; + + callback(defineRoute); + + alreadyReturned = true; + + return routes; +} + +export function createRouteId(file: string) { + return normalizeSlashes(stripFileExtension(file)); +} + +export function normalizeSlashes(file: string) { + return file.split(path.win32.sep).join("/"); +} + +function stripFileExtension(file: string) { + return file.replace(/\.[a-z0-9]+$/i, ""); +} diff --git a/packages/remix-dev/config/serverModes.ts b/packages/remix-dev/config/serverModes.ts new file mode 100644 index 0000000000..a04829da43 --- /dev/null +++ b/packages/remix-dev/config/serverModes.ts @@ -0,0 +1,16 @@ +/** + * The mode to use when running the server. + */ +export enum ServerMode { + Development = "development", + Production = "production", + Test = "test", +} + +export function isValidServerMode(mode: string): mode is ServerMode { + return ( + mode === ServerMode.Development || + mode === ServerMode.Production || + mode === ServerMode.Test + ); +} diff --git a/packages/remix-dev/dependencies.ts b/packages/remix-dev/dependencies.ts new file mode 100644 index 0000000000..dca8e2d988 --- /dev/null +++ b/packages/remix-dev/dependencies.ts @@ -0,0 +1,93 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import type { RemixConfig } from "./config"; + +type PackageDependencies = { [packageName: string]: string }; + +function getPackageDependencies( + packageJsonFile: string, + includeDev?: boolean +): PackageDependencies { + let pkg = JSON.parse(fs.readFileSync(packageJsonFile, "utf8")); + let deps = pkg?.dependencies || {}; + + if (includeDev) { + Object.assign(deps, pkg?.devDependencies || {}); + } + + return deps; +} + +export function getAppDependencies( + config: RemixConfig, + includeDev?: boolean +): PackageDependencies { + return getPackageDependencies( + path.resolve(config.rootDirectory, "package.json"), + includeDev + ); +} + +export function getDependenciesToBundle(...pkg: string[]): string[] { + let aggregatedDeps = new Set(pkg); + let visitedPackages = new Set(); + + pkg.forEach((p) => { + getPackageDependenciesRecursive(p, aggregatedDeps, visitedPackages); + }); + + return Array.from(aggregatedDeps); +} + +interface ErrorWithCode extends Error { + code: string; +} + +function isErrorWithCode(error: unknown): error is ErrorWithCode { + return ( + error instanceof Error && + typeof (error as NodeJS.ErrnoException).code === "string" + ); +} + +function getPackageDependenciesRecursive( + pkg: string, + aggregatedDeps: Set, + visitedPackages: Set +): void { + visitedPackages.add(pkg); + + let pkgPath: string; + try { + pkgPath = require.resolve(pkg, { paths: [__dirname, process.cwd()] }); + } catch (err) { + if (isErrorWithCode(err) && err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") { + // Handle packages without main exports. + // They at least need to have package.json exported. + pkgPath = require.resolve(`${pkg}/package.json`, { + paths: [__dirname, process.cwd()], + }); + } else { + throw err; + } + } + let lastIndexOfPackageName = pkgPath.lastIndexOf(pkg); + if (lastIndexOfPackageName !== -1) { + pkgPath = pkgPath.substring(0, lastIndexOfPackageName); + } + let pkgJson = path.join(pkgPath, "package.json"); + if (!fs.existsSync(pkgJson)) { + console.log(pkgJson, `does not exist`); + return; + } + + let dependencies = getPackageDependencies(pkgJson); + + Object.keys(dependencies).forEach((dep) => { + aggregatedDeps.add(dep); + if (!visitedPackages.has(dep)) { + getPackageDependenciesRecursive(dep, aggregatedDeps, visitedPackages); + } + }); +} diff --git a/packages/remix-dev/devServer/index.ts b/packages/remix-dev/devServer/index.ts new file mode 100644 index 0000000000..cf891617f4 --- /dev/null +++ b/packages/remix-dev/devServer/index.ts @@ -0,0 +1 @@ +export { liveReload } from "./liveReload"; diff --git a/packages/remix-dev/devServer/liveReload.ts b/packages/remix-dev/devServer/liveReload.ts new file mode 100644 index 0000000000..fb892aafd2 --- /dev/null +++ b/packages/remix-dev/devServer/liveReload.ts @@ -0,0 +1,90 @@ +import exitHook from "exit-hook"; +import fse from "fs-extra"; +import path from "node:path"; +import prettyMs from "pretty-ms"; +import WebSocket from "ws"; + +import { watch } from "../compiler"; +import type { RemixConfig } from "../config"; +import { createFileWatchCache } from "../compiler/fileWatchCache"; +import { logger } from "../tux"; + +const relativePath = (file: string) => path.relative(process.cwd(), file); + +let clean = (config: RemixConfig) => { + try { + fse.emptyDirSync(config.assetsBuildDirectory); + } catch { + // ignore failed clean up attempts + } +}; + +export async function liveReload( + config: RemixConfig, + options: { port: number; mode: string } +) { + clean(config); + let wss = new WebSocket.Server({ port: options.port }); + function broadcast(event: { type: string } & Record) { + setTimeout(() => { + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(event)); + } + }); + }, 500); + } + + function log(message: string) { + let _message = `💿 ${message}`; + console.log(_message); + broadcast({ type: "LOG", message: _message }); + } + + let fileWatchCache = createFileWatchCache(); + + let hasBuilt = false; + let dispose = await watch( + { + config, + options: { + mode: options.mode, + sourcemap: true, + }, + fileWatchCache, + logger, + }, + { + onBuildStart() { + clean(config); + log((hasBuilt ? "Rebuilding" : "Building") + "..."); + }, + onBuildFinish(_, durationMs: number, manifest) { + if (manifest === undefined) return; + hasBuilt = true; + log((hasBuilt ? "Rebuilt" : "Built") + ` in ${prettyMs(durationMs)}`); + broadcast({ type: "RELOAD" }); + }, + onFileCreated(file) { + log(`File created: ${relativePath(file)}`); + }, + onFileChanged(file) { + log(`File changed: ${relativePath(file)}`); + fileWatchCache.invalidateFile(file); + }, + onFileDeleted(file) { + log(`File deleted: ${relativePath(file)}`); + fileWatchCache.invalidateFile(file); + }, + } + ); + + let heartbeat = setInterval(broadcast, 60000, { type: "PING" }); + + exitHook(() => clean(config)); + return async () => { + wss.close(); + clearInterval(heartbeat); + await dispose(); + }; +} diff --git a/packages/remix-dev/devServer_unstable/env.ts b/packages/remix-dev/devServer_unstable/env.ts new file mode 100644 index 0000000000..d41c644989 --- /dev/null +++ b/packages/remix-dev/devServer_unstable/env.ts @@ -0,0 +1,11 @@ +import fse from "fs-extra"; +import * as path from "node:path"; + +// Import environment variables from: .env, failing gracefully if it doesn't exist +export async function loadEnv(rootDirectory: string): Promise { + let envPath = path.join(rootDirectory, ".env"); + if (!fse.existsSync(envPath)) return; + + let result = require("dotenv").config({ path: envPath }); + if (result.error) throw result.error; +} diff --git a/packages/remix-dev/devServer_unstable/hdr.ts b/packages/remix-dev/devServer_unstable/hdr.ts new file mode 100644 index 0000000000..64fd2a14a4 --- /dev/null +++ b/packages/remix-dev/devServer_unstable/hdr.ts @@ -0,0 +1,142 @@ +import * as path from "node:path"; +import esbuild from "esbuild"; + +import type { Context } from "../compiler/context"; +import { emptyModulesPlugin } from "../compiler/plugins/emptyModules"; +import { externalPlugin } from "../compiler/plugins/external"; +import { getRouteModuleExports } from "../compiler/utils/routeExports"; +import { createMatchPath } from "../compiler/utils/tsconfig"; +import invariant from "../invariant"; +import { mdxPlugin } from "../compiler/plugins/mdx"; +import { loaders } from "../compiler/utils/loaders"; + +function isBareModuleId(id: string): boolean { + return !id.startsWith("node:") && !id.startsWith(".") && !path.isAbsolute(id); +} + +type Route = Context["config"]["routes"][string]; + +export let detectLoaderChanges = async ( + ctx: Context +): Promise> => { + let entryPoints: Record = {}; + for (let id of Object.keys(ctx.config.routes)) { + entryPoints[id] = ctx.config.routes[id].file + "?loader"; + } + let options: esbuild.BuildOptions = { + bundle: true, + entryPoints: entryPoints, + treeShaking: true, + metafile: true, + outdir: ".", + write: false, + entryNames: "[hash]", + loader: loaders, + logLevel: "silent", + plugins: [ + { + name: "hmr-loader", + setup(build) { + let routesByFile: Map = Object.keys( + ctx.config.routes + ).reduce((map, key) => { + let route = ctx.config.routes[key]; + map.set(route.file, route); + return map; + }, new Map()); + let filter = /\?loader$/; + build.onResolve({ filter }, (args) => { + return { path: args.path, namespace: "hmr-loader" }; + }); + build.onLoad({ filter, namespace: "hmr-loader" }, async (args) => { + let file = args.path.replace(filter, ""); + let route = routesByFile.get(file); + invariant(route, `Cannot get route by path: ${args.path}`); + let cacheKey = `module-exports:${route.id}`; + let { cacheValue: theExports } = await ctx.fileWatchCache.getOrSet( + cacheKey, + async () => { + let file = path.resolve( + ctx.config.appDirectory, + ctx.config.routes[route!.id].file + ); + return { + cacheValue: await getRouteModuleExports( + ctx.config, + route!.id + ), + fileDependencies: new Set([file]), + }; + } + ); + + let contents = "module.exports = {};"; + if ( + theExports.includes("loader") && + theExports.includes("clientLoader") + ) { + contents = `export { loader, clientLoader } from ${JSON.stringify( + `./${file}` + )};`; + } else if (theExports.includes("loader")) { + contents = `export { loader } from ${JSON.stringify( + `./${file}` + )};`; + } else if (theExports.includes("clientLoader")) { + contents = `export { clientLoader } from ${JSON.stringify( + `./${file}` + )};`; + } + + return { + contents, + resolveDir: ctx.config.appDirectory, + loader: "js", + }; + }); + }, + }, + externalPlugin(/^node:.*/, { sideEffects: false }), + externalPlugin(/\.css$/, { sideEffects: false }), + externalPlugin(/^https?:\/\//, { sideEffects: false }), + mdxPlugin(ctx), + emptyModulesPlugin(ctx, /\.client(\.[jt]sx?)?$/), + { + name: "hmr-bare-modules", + setup(build) { + let matchPath = ctx.config.tsconfigPath + ? createMatchPath(ctx.config.tsconfigPath) + : undefined; + function resolvePath(id: string) { + if (!matchPath) return id; + return ( + matchPath(id, undefined, undefined, [ + ".ts", + ".tsx", + ".js", + ".jsx", + ]) || id + ); + } + build.onResolve({ filter: /.*/ }, (args) => { + if (!isBareModuleId(resolvePath(args.path))) { + return undefined; + } + return { path: args.path, external: true }; + }); + }, + }, + ], + }; + + let { metafile } = await esbuild.build(options); + + let entries: Record = {}; + for (let [hashjs, { entryPoint }] of Object.entries(metafile!.outputs)) { + if (entryPoint === undefined) continue; + let file = entryPoint.replace(/^hmr-loader:/, "").replace(/\?loader$/, ""); + entries[file] = hashjs.replace(/\.js$/, ""); + } + + return entries; +}; diff --git a/packages/remix-dev/devServer_unstable/hmr.ts b/packages/remix-dev/devServer_unstable/hmr.ts new file mode 100644 index 0000000000..6f88ab803b --- /dev/null +++ b/packages/remix-dev/devServer_unstable/hmr.ts @@ -0,0 +1,75 @@ +import path from "node:path"; + +import type { RemixConfig } from "../config"; +import { type Manifest } from "../manifest"; + +export type Update = { + id: string; + routeId: string; + url: string; + revalidate: boolean; + reason: string; +}; + +// route id: filepaths relative to app/ dir without extension +// filename: absolute or relative to root for things we don't handle +// for things we handle: relative to app dir +export let updates = ( + config: RemixConfig, + manifest: Manifest, + prevManifest: Manifest, + hdr: Record, + prevHdr?: Record +): Update[] => { + let updates: Update[] = []; + for (let [routeId, route] of Object.entries(manifest.routes)) { + let prevRoute = prevManifest.routes[routeId] as typeof route | undefined; + let file = config.routes[routeId].file; + let moduleId = path.relative( + config.rootDirectory, + path.join(config.appDirectory, file) + ); + + // new route + if (!prevRoute) { + updates.push({ + id: moduleId, + routeId: route.id, + url: route.module, + revalidate: true, + reason: "Route added", + }); + continue; + } + + // when loaders are diff + let loaderHash = hdr[file]; + let prevLoaderHash = prevHdr?.[file]; + if (loaderHash !== prevLoaderHash) { + updates.push({ + id: moduleId, + routeId: route.id, + url: route.module, + revalidate: true, + reason: "Loader changed", + }); + continue; + } + + // when fingerprinted assets are diff (self or imports) + let diffModule = route.module !== prevRoute.module; + let xorImports = new Set(route.imports ?? []); + prevRoute.imports?.forEach(xorImports.delete.bind(xorImports)); + if (diffModule || xorImports.size > 0) { + updates.push({ + id: moduleId, + routeId: route.id, + url: route.module, + revalidate: false, + reason: "Component changed", + }); + continue; + } + } + return updates; +}; diff --git a/packages/remix-dev/devServer_unstable/index.ts b/packages/remix-dev/devServer_unstable/index.ts new file mode 100644 index 0000000000..ef3a3fb370 --- /dev/null +++ b/packages/remix-dev/devServer_unstable/index.ts @@ -0,0 +1,287 @@ +import * as path from "node:path"; +import * as stream from "node:stream"; +import * as http from "node:http"; +import * as https from "node:https"; +import fs from "fs-extra"; +import prettyMs from "pretty-ms"; +import execa from "execa"; +import express from "express"; +import pc from "picocolors"; +import exitHook from "exit-hook"; + +import * as Channel from "../channel"; +import { type Manifest } from "../manifest"; +import * as Compiler from "../compiler"; +import { createFileWatchCache } from "../compiler/fileWatchCache"; +import { type RemixConfig } from "../config"; +import { loadEnv } from "./env"; +import * as Socket from "./socket"; +import * as HMR from "./hmr"; +import { detectPackageManager } from "../cli/detectPackageManager"; +import * as HDR from "./hdr"; +import type { Result } from "../result"; +import { err, ok } from "../result"; +import invariant from "../invariant"; +import { logger } from "../tux"; +import { killtree } from "./proc"; + +let detectBin = async (): Promise => { + let pkgManager = detectPackageManager() ?? "npm"; + if (pkgManager === "npm") { + // npm v9 removed the `bin` command, so have to use `prefix` + let { stdout } = await execa(pkgManager, ["prefix"]); + return path.join(stdout.trim(), "node_modules", ".bin"); + } + if (pkgManager === "bun") { + let { stdout } = await execa(pkgManager, ["pm", "bin"]); + return stdout.trim(); + } + let { stdout } = await execa(pkgManager, ["bin"]); + return stdout.trim(); +}; + +export let serve = async ( + initialConfig: RemixConfig, + options: { + command?: string; + manual: boolean; + port: number; + tlsKey?: string; + tlsCert?: string; + REMIX_DEV_ORIGIN: URL; + } +) => { + await loadEnv(initialConfig.rootDirectory); + let state: { + appServer?: execa.ExecaChildProcess; + manifest?: Manifest; + prevManifest?: Manifest; + appReady?: Channel.Type; + loaderChanges?: Promise>>; + prevLoaderHashes?: Record; + } = {}; + + let app = express() + // handle `broadcastDevReady` messages + .use(express.json()) + .post("/ping", (req, res) => { + let { buildHash } = req.body; + if (typeof buildHash !== "string") { + logger.warn(`unrecognized payload: ${req.body}`); + res.sendStatus(400); + } + if (buildHash === state.manifest?.version) { + state.appReady?.ok(); + } + res.sendStatus(200); + }); + + let server = + options.tlsKey && options.tlsCert + ? https.createServer( + { + key: fs.readFileSync(options.tlsKey), + cert: fs.readFileSync(options.tlsCert), + }, + app + ) + : http.createServer(app); + let websocket = Socket.serve(server); + + let bin = await detectBin(); + let startAppServer = (command?: string) => { + let cmd = + command ?? + `remix-serve ${path.relative( + process.cwd(), + initialConfig.serverBuildPath + )}`; + let newAppServer = execa + .command(cmd, { + stdio: "pipe", + env: { + NODE_ENV: "development", + PATH: + bin + (process.platform === "win32" ? ";" : ":") + process.env.PATH, + REMIX_DEV_ORIGIN: options.REMIX_DEV_ORIGIN.href, + FORCE_COLOR: process.env.NO_COLOR === undefined ? "1" : "0", + }, + // https://github.com/sindresorhus/execa/issues/433 + windowsHide: false, + }) + .on("error", (e) => { + // patch execa error types + invariant("errno" in e && typeof e.errno === "number", "errno missing"); + invariant("code" in e && typeof e.code === "string", "code missing"); + invariant("path" in e && typeof e.path === "string", "path missing"); + + if (command === undefined) { + logger.error(`command not found: ${e.path}`, { + details: [ + `\`remix dev\` did not receive \`--command\` nor \`-c\`, defaulting to \`${cmd}\`.`, + "You probably meant to use `-c` for your app server command.", + "For example: `remix dev -c 'node ./server.js'`", + ], + }); + process.exit(1); + } + logger.error("app failed to start" + pc.gray(` (${command})`)); + throw e; + }); + + if (newAppServer.stdin) + process.stdin.pipe(newAppServer.stdin, { end: true }); + if (newAppServer.stderr) + newAppServer.stderr.pipe(process.stderr, { end: false }); + if (newAppServer.stdout) { + newAppServer.stdout + .pipe( + new stream.PassThrough({ + transform(chunk, _, callback) { + let str: string = chunk.toString(); + let matches = + str && str.matchAll(/\[REMIX DEV\] ([A-Fa-f0-9]+) ready/g); + if (matches) { + for (let match of matches) { + let buildHash = match[1]; + if (buildHash === state.manifest?.version) { + state.appReady?.ok(); + } + } + } + + callback(null, chunk); + }, + }) + ) + .pipe(process.stdout, { end: false }); + } + + return newAppServer; + }; + + let fileWatchCache = createFileWatchCache(); + + let dispose = await Compiler.watch( + { + config: initialConfig, + options: { + mode: "development", + sourcemap: true, + REMIX_DEV_ORIGIN: options.REMIX_DEV_ORIGIN, + }, + fileWatchCache, + logger, + }, + { + onBuildStart: async (ctx) => { + // stop listening for previous manifest + state.appReady?.err(); + + clean(ctx.config); + if (!state.prevManifest) { + let msg = "building..."; + websocket.log(msg); + logger.info(msg); + } + + state.loaderChanges = HDR.detectLoaderChanges(ctx).then(ok, err); + }, + onBuildManifest: (manifest: Manifest) => { + state.manifest = manifest; + state.appReady = Channel.create(); + }, + onBuildFinish: async (ctx, durationMs, succeeded) => { + if (!succeeded) return; + + let msg = + (state.prevManifest ? "rebuilt" : "built") + + pc.gray(` (${prettyMs(durationMs)})`); + websocket.log(msg); + logger.info(msg); + + // accumulate new state, but only update state after updates are processed + let newState: typeof state = { prevManifest: state.manifest }; + try { + let start = Date.now(); + if (state.appServer === undefined || !options.manual) { + if (state.appServer?.pid) { + await killtree(state.appServer.pid); + } + state.appServer = startAppServer(options.command); + } + let appReady = await state.appReady!.result; + if (!appReady.ok) return; + if (state.prevManifest) { + logger.info( + `app server ready` + pc.gray(` (${prettyMs(Date.now() - start)})`) + ); + } + + // HMR + HDR + let loaderChanges = await state.loaderChanges!; + if (loaderChanges.ok) { + newState.prevLoaderHashes = loaderChanges.value; + } + if (loaderChanges?.ok && state.manifest && state.prevManifest) { + let updates = HMR.updates( + ctx.config, + state.manifest, + state.prevManifest, + loaderChanges.value, + state.prevLoaderHashes + ); + websocket.hmr(state.manifest, updates); + + let hdr = updates.some((u) => u.revalidate); + logger.info("hmr" + (hdr ? " + hdr" : "")); + return; + } + + // Live Reload + if (state.prevManifest !== undefined) { + websocket.reload(); + logger.info("live reload"); + } + } finally { + // commit accumulated state + Object.assign(state, newState); + process.stdout.write("\n"); + } + }, + onFileCreated: (file) => { + logger.info(`rebuilding...` + pc.gray(` (+ ${relativePath(file)})`)); + websocket.log(`file created: ${relativePath(file)}`); + }, + onFileChanged: (file) => { + logger.info(`rebuilding...` + pc.gray(` (~ ${relativePath(file)})`)); + websocket.log(`file changed: ${relativePath(file)}`); + fileWatchCache.invalidateFile(file); + }, + onFileDeleted: (file) => { + logger.info(`rebuilding` + pc.gray(` (- ${relativePath(file)})`)); + websocket.log(`file deleted: ${relativePath(file)}`); + fileWatchCache.invalidateFile(file); + }, + } + ); + + server.listen(options.port); + + let cleanup = async () => { + state.appServer?.kill(); + websocket.close(); + server.close(); + await dispose(); + }; + exitHook(cleanup); + return cleanup; +}; + +let clean = (config: RemixConfig) => { + try { + fs.emptyDirSync(config.relativeAssetsBuildDirectory); + } catch {} +}; + +let relativePath = (file: string) => path.relative(process.cwd(), file); diff --git a/packages/remix-dev/devServer_unstable/proc.ts b/packages/remix-dev/devServer_unstable/proc.ts new file mode 100644 index 0000000000..3c842bc6c6 --- /dev/null +++ b/packages/remix-dev/devServer_unstable/proc.ts @@ -0,0 +1,57 @@ +import execa from "execa"; +import pidtree from "pidtree"; + +let isWindows = process.platform === "win32"; + +export let kill = async (pid: number) => { + if (!isAlive(pid)) return; + if (isWindows) { + await execa("taskkill", ["/F", "/PID", pid.toString()]).catch((error) => { + // taskkill 128 -> the process is already dead + if (error.exitCode === 128) return; + if (/There is no running instance of the task./.test(error.message)) + return; + console.warn(error.message); + }); + return; + } + await execa("kill", ["-9", pid.toString()]).catch((error) => { + // process is already dead + if (/No such process/.test(error.message)) return; + console.warn(error.message); + }); +}; + +let isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return false; + } +}; + +export let killtree = async (pid: number) => { + let descendants = await pidtree(pid).catch(() => undefined); + if (descendants === undefined) return; + let pids = [pid, ...descendants]; + + await Promise.all(pids.map(kill)); + + return new Promise((resolve, reject) => { + let check = setInterval(() => { + pids = pids.filter(isAlive); + if (pids.length === 0) { + clearInterval(check); + resolve(); + } + }, 50); + + setTimeout(() => { + clearInterval(check); + reject( + new Error("Timeout: Processes did not exit within the specified time.") + ); + }, 2000); + }); +}; diff --git a/packages/remix-dev/devServer_unstable/socket.ts b/packages/remix-dev/devServer_unstable/socket.ts new file mode 100644 index 0000000000..fa6afcb13c --- /dev/null +++ b/packages/remix-dev/devServer_unstable/socket.ts @@ -0,0 +1,48 @@ +import WebSocket from "ws"; +import type { Server as HTTPServer } from "node:http"; + +import { type Manifest } from "../manifest"; +import type * as HMR from "./hmr"; + +type Message = + | { type: "RELOAD" } + | { type: "LOG"; message: string } + | { + type: "HMR"; + assetsManifest: Manifest; + updates: HMR.Update[]; + }; + +type Broadcast = (message: Message) => void; + +export let serve = (server: HTTPServer) => { + let wss = new WebSocket.Server({ server }); + + let broadcast: Broadcast = (message) => { + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(message)); + } + }); + }; + + let log = (messageText: string) => { + let _message = `[remix] ${messageText}`; + broadcast({ type: "LOG", message: _message }); + }; + + let reload = () => broadcast({ type: "RELOAD" }); + + let hmr = (assetsManifest: Manifest, updates: HMR.Update[]) => { + broadcast({ type: "HMR", assetsManifest, updates }); + }; + + let heartbeat = setInterval(broadcast, 60000, { type: "PING" }); + + let close = () => { + clearInterval(heartbeat); + return wss.close(); + }; + + return { log, reload, hmr, close }; +}; diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts new file mode 100644 index 0000000000..1c28706e7c --- /dev/null +++ b/packages/remix-dev/index.ts @@ -0,0 +1,15 @@ +import "./modules"; + +export type { AppConfig, RemixConfig as ResolvedRemixConfig } from "./config"; + +export * as cli from "./cli/index"; + +export type { Manifest as AssetsManifest } from "./manifest"; +export { getDependenciesToBundle } from "./dependencies"; +export type { + BuildManifest, + Preset, + ServerBundlesFunction, + VitePluginConfig, +} from "./vite"; +export { vitePlugin, cloudflareDevProxyVitePlugin } from "./vite"; diff --git a/packages/remix-dev/invariant.ts b/packages/remix-dev/invariant.ts new file mode 100644 index 0000000000..690ae3ffe4 --- /dev/null +++ b/packages/remix-dev/invariant.ts @@ -0,0 +1,18 @@ +export default function invariant( + value: boolean, + message?: string +): asserts value; + +export default function invariant( + value: T | null | undefined, + message?: string +): asserts value is T; + +export default function invariant(value: any, message?: string) { + if (value === false || value === null || typeof value === "undefined") { + console.error( + "The following error is a bug in Remix; please open an issue! https://github.com/remix-run/remix/issues/new" + ); + throw new Error(message); + } +} diff --git a/packages/remix-dev/jest.config.js b/packages/remix-dev/jest.config.js new file mode 100644 index 0000000000..3f4869bc95 --- /dev/null +++ b/packages/remix-dev/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "dev", + setupFilesAfterEnv: ["/__tests__/setupAfterEnv.ts"], + setupFiles: [], +}; diff --git a/packages/remix-dev/manifest.ts b/packages/remix-dev/manifest.ts new file mode 100644 index 0000000000..e9b628aa63 --- /dev/null +++ b/packages/remix-dev/manifest.ts @@ -0,0 +1,28 @@ +export type Manifest = { + version: string; + url?: string; + entry: { + module: string; + imports: string[]; + }; + routes: { + [routeId: string]: { + id: string; + parentId?: string; + path?: string; + index?: boolean; + caseSensitive?: boolean; + module: string; + imports?: string[]; + hasAction: boolean; + hasLoader: boolean; + hasClientAction: boolean; + hasClientLoader: boolean; + hasErrorBoundary: boolean; + }; + }; + hmr?: { + timestamp?: number; + runtime: string; + }; +}; diff --git a/packages/remix-dev/modules.ts b/packages/remix-dev/modules.ts new file mode 100644 index 0000000000..c5fe4043e5 --- /dev/null +++ b/packages/remix-dev/modules.ts @@ -0,0 +1,152 @@ +// @ts-nocheck + +declare module "*.aac" { + let asset: string; + export default asset; +} +declare module "*.avif" { + let asset: string; + export default asset; +} +declare module "*.module.css" { + let styles: { readonly [key: string]: string }; + export default styles; +} +declare module "*.css" { + let asset: string; + export default asset; +} +declare module "*.csv" { + let asset: string; + export default asset; +} +declare module "*.eot" { + let asset: string; + export default asset; +} +declare module "*.fbx" { + let asset: string; + export default asset; +} +declare module "*.flac" { + let asset: string; + export default asset; +} +declare module "*.gif" { + let asset: string; + export default asset; +} +declare module "*.glb" { + let asset: string; + export default asset; +} +declare module "*.gltf" { + let asset: string; + export default asset; +} +declare module "*.gql" { + let asset: string; + export default asset; +} +declare module "*.graphql" { + let asset: string; + export default asset; +} +declare module "*.hdr" { + let asset: string; + export default asset; +} +declare module "*.ico" { + let asset: string; + export default asset; +} +declare module "*.jpeg" { + let asset: string; + export default asset; +} +declare module "*.jpg" { + let asset: string; + export default asset; +} +declare module "*.md" { + import "mdx"; + export let attributes: any; + export let filename: string; +} +declare module "*.mdx" { + import "mdx"; + export let attributes: any; + export let filename: string; +} +declare module "*.mp3" { + let asset: string; + export default asset; +} +declare module "*.mov" { + let asset: string; + export default asset; +} +declare module "*.mp4" { + let asset: string; + export default asset; +} +declare module "*.ogg" { + let asset: string; + export default asset; +} +declare module "*.otf" { + let asset: string; + export default asset; +} +declare module "*.png" { + let asset: string; + export default asset; +} +declare module "*.psd" { + let asset: string; + export default asset; +} +declare module "*.sql" { + let asset: string; + export default asset; +} +declare module "*.svg" { + let asset: string; + export default asset; +} +declare module "*.ttf" { + let asset: string; + export default asset; +} +declare module "*.wasm" { + let asset: string; + export default asset; +} +declare module "*.wav" { + let asset: string; + export default asset; +} +declare module "*.webm" { + let asset: string; + export default asset; +} +declare module "*.webp" { + let asset: string; + export default asset; +} +declare module "*.woff" { + let asset: string; + export default asset; +} +declare module "*.woff2" { + let asset: string; + export default asset; +} +declare module "*.webmanifest" { + let asset: string; + export default asset; +} +declare module "*.zip" { + let asset: string; + export default asset; +} diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json new file mode 100644 index 0000000000..3b6108b7cb --- /dev/null +++ b/packages/remix-dev/package.json @@ -0,0 +1,141 @@ +{ + "name": "@remix-run/dev", + "version": "2.9.0-pre.0", + "description": "Dev tools and CLI for Remix", + "homepage": "https://remix.run", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-dev" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "bin": { + "remix": "dist/cli.js" + }, + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "@babel/core": "^7.21.8", + "@babel/generator": "^7.21.5", + "@babel/parser": "^7.21.8", + "@babel/plugin-syntax-decorators": "^7.22.10", + "@babel/plugin-syntax-jsx": "^7.21.4", + "@babel/preset-typescript": "^7.21.5", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.22.5", + "@mdx-js/mdx": "^2.3.0", + "@npmcli/package-json": "^4.0.1", + "@remix-run/node": "workspace:*", + "@remix-run/router": "1.16.0-pre.0", + "@remix-run/server-runtime": "workspace:*", + "@types/mdx": "^2.0.5", + "@vanilla-extract/integration": "^6.2.0", + "arg": "^5.0.1", + "cacache": "^17.1.3", + "chalk": "^4.1.2", + "chokidar": "^3.5.1", + "cross-spawn": "^7.0.3", + "dotenv": "^16.0.0", + "es-module-lexer": "^1.3.1", + "esbuild": "0.17.6", + "esbuild-plugins-node-modules-polyfill": "^1.6.0", + "execa": "5.1.1", + "exit-hook": "2.2.1", + "express": "^4.17.1", + "fs-extra": "^10.0.0", + "get-port": "^5.1.1", + "gunzip-maybe": "^1.4.2", + "jsesc": "3.0.2", + "json5": "^2.2.2", + "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", + "minimatch": "^9.0.0", + "ora": "^5.4.1", + "picocolors": "^1.0.0", + "picomatch": "^2.3.1", + "pidtree": "^0.6.0", + "postcss": "^8.4.19", + "postcss-discard-duplicates": "^5.1.0", + "postcss-load-config": "^4.0.1", + "postcss-modules": "^6.0.0", + "prettier": "^2.7.1", + "pretty-ms": "^7.0.1", + "react-refresh": "^0.14.0", + "remark-frontmatter": "4.0.1", + "remark-mdx-frontmatter": "^1.0.1", + "semver": "^7.3.7", + "set-cookie-parser": "^2.6.0", + "tar-fs": "^2.1.1", + "tsconfig-paths": "^4.0.0", + "ws": "^7.4.5" + }, + "devDependencies": { + "@remix-run/cloudflare": "workspace:*", + "@remix-run/deno": "workspace:*", + "@remix-run/serve": "workspace:*", + "@remix-run/testing": "workspace:*", + "@types/babel__core": "^7.20.5", + "@types/babel__generator": "^7.6.8", + "@types/babel__traverse": "^7.20.5", + "@types/cacache": "^17.0.0", + "@types/cross-spawn": "^6.0.2", + "@types/express": "^4.17.9", + "@types/gunzip-maybe": "^1.4.0", + "@types/jsesc": "^3.0.1", + "@types/lodash.debounce": "^4.0.6", + "@types/node": "^18.17.1", + "@types/npmcli__package-json": "^4.0.0", + "@types/picomatch": "^2.3.0", + "@types/prettier": "^2.7.3", + "@types/set-cookie-parser": "^2.4.1", + "@types/shelljs": "^0.8.11", + "@types/tar-fs": "^2.0.1", + "@types/ws": "^7.4.1", + "esbuild-register": "^3.3.2", + "fast-glob": "3.2.11", + "msw": "^1.2.3", + "strip-ansi": "^6.0.1", + "tiny-invariant": "^1.2.0", + "vite": "5.1.3", + "wrangler": "^3.28.2" + }, + "peerDependencies": { + "@remix-run/react": "^2.9.0-pre.0", + "@remix-run/serve": "^2.9.0-pre.0", + "typescript": "^5.1.0", + "vite": "^5.1.0", + "wrangler": "^3.28.2" + }, + "peerDependenciesMeta": { + "@remix-run/serve": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vite": { + "optional": true + }, + "wrangler": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/", + "compiler/shims/", + "server-build.js", + "server-build.d.ts", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/remix-dev/result.ts b/packages/remix-dev/result.ts new file mode 100644 index 0000000000..eaa82557b6 --- /dev/null +++ b/packages/remix-dev/result.ts @@ -0,0 +1,7 @@ +type Ok = { ok: true; value: V }; +type Err = { ok: false; error: E }; + +export type Result = Ok | Err; + +export let ok = (value: V): Ok => ({ ok: true, value }); +export let err = (error: E): Err => ({ ok: false, error }); diff --git a/packages/remix-dev/rollup.config.js b/packages/remix-dev/rollup.config.js new file mode 100644 index 0000000000..b1d5fd1095 --- /dev/null +++ b/packages/remix-dev/rollup.config.js @@ -0,0 +1,103 @@ +const path = require("node:path"); +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + copyToPlaygrounds, + createBanner, + getCliConfig, + getOutputDir, + isBareModuleId, +} = require("../../rollup.utils"); +const { name: packageName, version } = require("./package.json"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + let sourceDir = "packages/remix-dev"; + let outputDir = getOutputDir(packageName); + let outputDist = path.join(outputDir, "dist"); + + return [ + { + external(id) { + return isBareModuleId(id); + }, + input: [ + `${sourceDir}/index.ts`, + // Since we're using a dynamic require for the Vite plugin, we + // need to tell Rollup it's an entry point + `${sourceDir}/vite/plugin.ts`, + ], + output: { + banner: createBanner("@remix-run/dev", version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "named", + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + nodeResolve({ extensions: [".ts"] }), + copy({ + targets: [ + { src: `LICENSE.md`, dest: [outputDir, sourceDir] }, + { src: `${sourceDir}/package.json`, dest: [outputDir, outputDist] }, + { src: `${sourceDir}/README.md`, dest: outputDir }, + { src: `${sourceDir}/vite/static`, dest: `${outputDist}/vite` }, + { + src: `${sourceDir}/config/defaults`, + dest: [`${outputDir}/config`, `${outputDist}/config`], + }, + ], + }), + // Allow dynamic imports in CJS code to allow us to utilize + // ESM modules as part of the compiler. + { + name: "dynamic-import-polyfill", + renderDynamicImport() { + return { + left: "import(", + right: ")", + }; + }, + }, + copyToPlaygrounds(), + ], + }, + getCliConfig({ packageName, version }), + { + external() { + return true; + }, + input: `${sourceDir}/server-build.ts`, + output: [ + { + // TODO: Remove deep import support or move to package.json + // "exports" field in a future major release + banner: createBanner("@remix-run/dev", version, true), + dir: outputDir, + format: "cjs", + }, + { + banner: createBanner("@remix-run/dev", version, true), + dir: outputDist, + format: "cjs", + }, + ], + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + nodeResolve({ extensions: [".ts"] }), + copyToPlaygrounds(), + ], + }, + ]; +}; diff --git a/packages/remix-dev/server-build.ts b/packages/remix-dev/server-build.ts new file mode 100644 index 0000000000..226a349204 --- /dev/null +++ b/packages/remix-dev/server-build.ts @@ -0,0 +1,18 @@ +import type { ServerBuild } from "@remix-run/server-runtime"; + +throw new Error( + "@remix-run/dev/server-build is not meant to be used directly from node_modules." + + " It exists to provide type definitions for a virtual module provided" + + " by the Remix compiler at build time." +); + +export const mode: ServerBuild["mode"] = undefined!; +export const assets: ServerBuild["assets"] = undefined!; +export const basename: ServerBuild["basename"] = undefined!; +export const entry: ServerBuild["entry"] = undefined!; +export const routes: ServerBuild["routes"] = undefined!; +export const future: ServerBuild["future"] = undefined!; +export const publicPath: ServerBuild["publicPath"] = undefined!; +// prettier-ignore +export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"] = undefined!; +export const isSpaMode: ServerBuild["isSpaMode"] = undefined!; diff --git a/packages/remix-dev/tsconfig.json b/packages/remix-dev/tsconfig.json new file mode 100644 index 0000000000..7db657d43c --- /dev/null +++ b/packages/remix-dev/tsconfig.json @@ -0,0 +1,18 @@ +{ + "include": ["**/*.ts", "package.json"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "../../build/node_modules/@remix-run/dev/dist" + } +} diff --git a/packages/remix-dev/tux/format.ts b/packages/remix-dev/tux/format.ts new file mode 100644 index 0000000000..89269dc59c --- /dev/null +++ b/packages/remix-dev/tux/format.ts @@ -0,0 +1,25 @@ +import pc from "picocolors"; +import type { Formatter } from "picocolors/types"; + +type FormatArgs = { + label: string; + color: Formatter; +}; + +export let format = + ({ label, color }: FormatArgs) => + (message: string, details: string[] = []) => { + let lines = []; + lines.push( + (pc.isColorSupported ? pc.inverse(color(` ${label} `)) : `[${label}]`) + + " " + + message + ); + if (details.length > 0) { + for (let detail of details) { + lines.push(color("┃") + " " + pc.gray(detail)); + } + lines.push(color("┗")); + } + return lines.join("\n"); + }; diff --git a/packages/remix-dev/tux/index.ts b/packages/remix-dev/tux/index.ts new file mode 100644 index 0000000000..6edc896e21 --- /dev/null +++ b/packages/remix-dev/tux/index.ts @@ -0,0 +1 @@ +export { logger, type Logger } from "./logger"; diff --git a/packages/remix-dev/tux/logger.ts b/packages/remix-dev/tux/logger.ts new file mode 100644 index 0000000000..5ef1580bf8 --- /dev/null +++ b/packages/remix-dev/tux/logger.ts @@ -0,0 +1,60 @@ +import pc from "picocolors"; +import type { Formatter } from "picocolors/types"; + +import { format } from "./format"; + +type Log = ( + message: string, + options?: { details?: string[]; key?: string } +) => void; + +export type Logger = { + debug: Log; + info: Log; + warn: Log; + error: Log; +}; + +type LogArgs = { + label: string; + color: Formatter; + dest: NodeJS.WriteStream; +}; + +let log = ({ label, color, dest }: LogArgs): Log => { + let _format = format({ label, color }); + let already = new Set(); + + return (message, { details, key } = {}) => { + let formatted = _format(message, details) + "\n"; + + if (key === undefined) return dest.write(formatted); + if (already.has(key)) return; + already.add(key); + + dest.write(formatted); + }; +}; + +export let logger: Logger = { + debug: log({ + label: "debug", + color: pc.green, + dest: process.stdout, + }), + info: log({ + label: "info", + color: pc.blue, + dest: process.stdout, + }), + warn: log({ + label: "warn", + color: pc.yellow, + dest: process.stderr, + }), + error: log({ + label: "error", + color: pc.red, + dest: process.stderr, + }), +}; diff --git a/packages/remix-dev/vite/babel.ts b/packages/remix-dev/vite/babel.ts new file mode 100644 index 0000000000..a075e02836 --- /dev/null +++ b/packages/remix-dev/vite/babel.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports */ +import type { NodePath } from "@babel/traverse"; +import type { types as BabelTypes } from "@babel/core"; +import { parse } from "@babel/parser"; +import * as t from "@babel/types"; + +// These `require`s were needed to support building within vite-ecosystem-ci, +// otherwise we get errors that `traverse` and `generate` are not functions. +const traverse = require("@babel/traverse") + .default as typeof import("@babel/traverse").default; +const generate = require("@babel/generator") + .default as typeof import("@babel/generator").default; + +export { traverse, generate, parse, t }; +export type { BabelTypes, NodePath }; diff --git a/packages/remix-dev/vite/build.ts b/packages/remix-dev/vite/build.ts new file mode 100644 index 0000000000..682f3cfefa --- /dev/null +++ b/packages/remix-dev/vite/build.ts @@ -0,0 +1,332 @@ +import type * as Vite from "vite"; +import path from "node:path"; +import fse from "fs-extra"; +import colors from "picocolors"; + +import { + type RemixPluginContext, + type BuildManifest, + type ServerBundleBuildConfig, + type ServerBundlesBuildManifest, + resolveViteConfig, + extractRemixPluginContext, + configRouteToBranchRoute, + getServerBuildDirectory, +} from "./plugin"; +import type { ConfigRoute, RouteManifest } from "../config/routes"; +import invariant from "../invariant"; +import { preloadViteEsm } from "./import-vite-esm-sync"; + +function getAddressableRoutes(routes: RouteManifest): ConfigRoute[] { + let nonAddressableIds = new Set(); + + for (let id in routes) { + let route = routes[id]; + + // We omit the parent route of index routes since the index route takes ownership of its parent's path + if (route.index) { + invariant( + route.parentId, + `Expected index route "${route.id}" to have "parentId" set` + ); + nonAddressableIds.add(route.parentId); + } + + // We omit pathless routes since they can only be addressed via descendant routes + if (typeof route.path !== "string" && !route.index) { + nonAddressableIds.add(id); + } + } + + return Object.values(routes).filter( + (route) => !nonAddressableIds.has(route.id) + ); +} + +function getRouteBranch(routes: RouteManifest, routeId: string) { + let branch: ConfigRoute[] = []; + let currentRouteId: string | undefined = routeId; + + while (currentRouteId) { + let route: ConfigRoute = routes[currentRouteId]; + invariant(route, `Missing route for ${currentRouteId}`); + branch.push(route); + currentRouteId = route.parentId; + } + + return branch.reverse(); +} + +type RemixViteClientBuildArgs = { + ssr: false; + serverBundleBuildConfig?: never; +}; + +type RemixViteServerBuildArgs = { + ssr: true; + serverBundleBuildConfig?: ServerBundleBuildConfig; +}; + +type RemixViteBuildArgs = RemixViteClientBuildArgs | RemixViteServerBuildArgs; + +async function getServerBuilds(ctx: RemixPluginContext): Promise<{ + serverBuilds: RemixViteServerBuildArgs[]; + buildManifest: BuildManifest; +}> { + let { rootDirectory } = ctx; + // eslint-disable-next-line prefer-let/prefer-let -- Improve type narrowing + const { routes, serverBuildFile, serverBundles, appDirectory } = + ctx.remixConfig; + let serverBuildDirectory = getServerBuildDirectory(ctx); + if (!serverBundles) { + return { + serverBuilds: [{ ssr: true }], + buildManifest: { routes }, + }; + } + + let { normalizePath } = await import("vite"); + + let resolvedAppDirectory = path.resolve(rootDirectory, appDirectory); + let rootRelativeRoutes = Object.fromEntries( + Object.entries(routes).map(([id, route]) => { + let filePath = path.join(resolvedAppDirectory, route.file); + let rootRelativeFilePath = normalizePath( + path.relative(rootDirectory, filePath) + ); + return [id, { ...route, file: rootRelativeFilePath }]; + }) + ); + + let buildManifest: ServerBundlesBuildManifest = { + serverBundles: {}, + routeIdToServerBundleId: {}, + routes: rootRelativeRoutes, + }; + + let serverBundleBuildConfigById = new Map(); + + await Promise.all( + getAddressableRoutes(routes).map(async (route) => { + let branch = getRouteBranch(routes, route.id); + let serverBundleId = await serverBundles({ + branch: branch.map((route) => + configRouteToBranchRoute({ + ...route, + // Ensure absolute paths are passed to the serverBundles function + file: path.join(resolvedAppDirectory, route.file), + }) + ), + }); + if (typeof serverBundleId !== "string") { + throw new Error(`The "serverBundles" function must return a string`); + } + if (!/^[a-zA-Z0-9-_]+$/.test(serverBundleId)) { + throw new Error( + `The "serverBundles" function must only return strings containing alphanumeric characters, hyphens and underscores.` + ); + } + buildManifest.routeIdToServerBundleId[route.id] = serverBundleId; + + let relativeServerBundleDirectory = path.relative( + rootDirectory, + path.join(serverBuildDirectory, serverBundleId) + ); + let serverBuildConfig = serverBundleBuildConfigById.get(serverBundleId); + if (!serverBuildConfig) { + buildManifest.serverBundles[serverBundleId] = { + id: serverBundleId, + file: normalizePath( + path.join(relativeServerBundleDirectory, serverBuildFile) + ), + }; + serverBuildConfig = { + routes: {}, + serverBundleId, + }; + serverBundleBuildConfigById.set(serverBundleId, serverBuildConfig); + } + for (let route of branch) { + serverBuildConfig.routes[route.id] = route; + } + }) + ); + + let serverBuilds = Array.from(serverBundleBuildConfigById.values()).map( + (serverBundleBuildConfig): RemixViteServerBuildArgs => { + let serverBuild: RemixViteServerBuildArgs = { + ssr: true, + serverBundleBuildConfig, + }; + return serverBuild; + } + ); + + return { + serverBuilds, + buildManifest, + }; +} + +async function cleanBuildDirectory( + viteConfig: Vite.ResolvedConfig, + ctx: RemixPluginContext +) { + let buildDirectory = ctx.remixConfig.buildDirectory; + let isWithinRoot = () => { + let relativePath = path.relative(ctx.rootDirectory, buildDirectory); + return !relativePath.startsWith("..") && !path.isAbsolute(relativePath); + }; + + if (viteConfig.build.emptyOutDir ?? isWithinRoot()) { + await fse.remove(buildDirectory); + } +} + +function getViteManifestPaths( + ctx: RemixPluginContext, + serverBuilds: Array +) { + let buildRelative = (pathname: string) => + path.resolve(ctx.remixConfig.buildDirectory, pathname); + + let viteManifestPaths: Array<{ srcPath: string; destPath: string }> = [ + { + srcPath: "client/.vite/manifest.json", + destPath: ".vite/client-manifest.json", + }, + ...serverBuilds.map(({ serverBundleBuildConfig }) => { + let serverBundleId = serverBundleBuildConfig?.serverBundleId; + let serverBundlePath = serverBundleId ? serverBundleId + "/" : ""; + let serverBundleSuffix = serverBundleId ? serverBundleId + "-" : ""; + return { + srcPath: `server/${serverBundlePath}.vite/manifest.json`, + destPath: `.vite/server-${serverBundleSuffix}manifest.json`, + }; + }), + ].map(({ srcPath, destPath }) => ({ + srcPath: buildRelative(srcPath), + destPath: buildRelative(destPath), + })); + + return viteManifestPaths; +} + +export interface ViteBuildOptions { + assetsInlineLimit?: number; + clearScreen?: boolean; + config?: string; + emptyOutDir?: boolean; + force?: boolean; + logLevel?: Vite.LogLevel; + minify?: Vite.BuildOptions["minify"]; + mode?: string; + profile?: boolean; + sourcemapClient?: boolean | "inline" | "hidden"; + sourcemapServer?: boolean | "inline" | "hidden"; +} + +export async function build( + root: string, + { + assetsInlineLimit, + clearScreen, + config: configFile, + emptyOutDir, + force, + logLevel, + minify, + mode, + sourcemapClient, + sourcemapServer, + }: ViteBuildOptions +) { + // Ensure Vite's ESM build is preloaded at the start of the process + // so it can be accessed synchronously via `importViteEsmSync` + await preloadViteEsm(); + + let viteConfig = await resolveViteConfig({ configFile, mode, root }); + + // eslint-disable-next-line prefer-let/prefer-let -- Improve type narrowing + const ctx = await extractRemixPluginContext(viteConfig); + + if (!ctx) { + console.error(colors.red("Remix Vite plugin not found in Vite config")); + process.exit(1); + } + + let { remixConfig } = ctx; + + let vite = await import("vite"); + + async function viteBuild({ + ssr, + serverBundleBuildConfig, + }: RemixViteBuildArgs) { + await vite.build({ + root, + mode, + configFile, + build: { + assetsInlineLimit, + emptyOutDir, + minify, + ssr, + sourcemap: ssr ? sourcemapServer : sourcemapClient, + }, + optimizeDeps: { force }, + clearScreen, + logLevel, + ...(serverBundleBuildConfig + ? { __remixServerBundleBuildConfig: serverBundleBuildConfig } + : {}), + }); + } + + await cleanBuildDirectory(viteConfig, ctx); + + // Run the Vite client build first + await viteBuild({ ssr: false }); + + // Then run Vite SSR builds in parallel + let { serverBuilds, buildManifest } = await getServerBuilds(ctx); + await Promise.all(serverBuilds.map(viteBuild)); + + let viteManifestPaths = getViteManifestPaths(ctx, serverBuilds); + await Promise.all( + viteManifestPaths.map(async ({ srcPath, destPath }) => { + let manifestExists = await fse.pathExists(srcPath); + if (!manifestExists) return; + + // Move/delete original Vite manifest file + if (ctx.viteManifestEnabled) { + await fse.ensureDir(path.dirname(destPath)); + await fse.move(srcPath, destPath); + } else { + await fse.remove(srcPath); + } + + // Remove .vite dir if it's now empty + let viteDir = path.dirname(srcPath); + let viteDirFiles = await fse.readdir(viteDir); + if (viteDirFiles.length === 0) { + await fse.remove(viteDir); + } + }) + ); + + if (ctx.remixConfig.manifest) { + await fse.ensureDir(path.join(ctx.remixConfig.buildDirectory, ".remix")); + await fse.writeFile( + path.join(ctx.remixConfig.buildDirectory, ".remix", "manifest.json"), + JSON.stringify(buildManifest, null, 2), + "utf-8" + ); + } + + await remixConfig.buildEnd?.({ + buildManifest, + remixConfig, + viteConfig, + }); +} diff --git a/packages/remix-dev/vite/cloudflare-proxy-plugin.ts b/packages/remix-dev/vite/cloudflare-proxy-plugin.ts new file mode 100644 index 0000000000..314c94cf33 --- /dev/null +++ b/packages/remix-dev/vite/cloudflare-proxy-plugin.ts @@ -0,0 +1,87 @@ +import { createRequestHandler } from "@remix-run/server-runtime"; +import { + type AppLoadContext, + type ServerBuild, +} from "@remix-run/server-runtime"; +import { type Plugin } from "vite"; +import { type GetPlatformProxyOptions, type PlatformProxy } from "wrangler"; + +import { fromNodeRequest, toNodeRequest } from "./node-adapter"; + +let serverBuildId = "virtual:remix/server-build"; + +type CfProperties = Record; + +type LoadContext = { + cloudflare: Omit, "dispose">; +}; + +type GetLoadContext = (args: { + request: Request; + context: LoadContext; +}) => AppLoadContext | Promise; + +function importWrangler() { + try { + return import("wrangler"); + } catch (_) { + throw Error("Could not import `wrangler`. Do you have it installed?"); + } +} + +const NAME = "vite-plugin-remix-cloudflare-proxy"; + +export const cloudflareDevProxyVitePlugin = ({ + getLoadContext, + ...options +}: { + getLoadContext?: GetLoadContext; +} & GetPlatformProxyOptions = {}): Plugin => { + return { + name: NAME, + config: () => ({ + ssr: { + resolve: { + externalConditions: ["workerd", "worker"], + }, + }, + }), + configResolved: (viteConfig) => { + let pluginIndex = (name: string) => + viteConfig.plugins.findIndex((plugin) => plugin.name === name); + let remixIndex = pluginIndex("remix"); + if (remixIndex >= 0 && remixIndex < pluginIndex(NAME)) { + throw new Error( + `The "${NAME}" plugin should be placed before the Remix plugin in your Vite config file` + ); + } + }, + configureServer: async (viteDevServer) => { + let { getPlatformProxy } = await importWrangler(); + // Do not include `dispose` in Cloudflare context + let { dispose, ...cloudflare } = await getPlatformProxy(options); + let context = { cloudflare }; + return () => { + if (!viteDevServer.config.server.middlewareMode) { + viteDevServer.middlewares.use(async (nodeReq, nodeRes, next) => { + try { + let build = (await viteDevServer.ssrLoadModule( + serverBuildId + )) as ServerBuild; + + let handler = createRequestHandler(build, "development"); + let req = fromNodeRequest(nodeReq); + let loadContext = getLoadContext + ? await getLoadContext({ request: req, context }) + : context; + let res = await handler(req, loadContext); + await toNodeRequest(res, nodeRes); + } catch (error) { + next(error); + } + }); + } + }; + }, + }; +}; diff --git a/packages/remix-dev/vite/dev.ts b/packages/remix-dev/vite/dev.ts new file mode 100644 index 0000000000..aeff831a7b --- /dev/null +++ b/packages/remix-dev/vite/dev.ts @@ -0,0 +1,76 @@ +import type * as Vite from "vite"; +import colors from "picocolors"; + +import { preloadViteEsm } from "./import-vite-esm-sync"; +import * as profiler from "./profiler"; + +export interface ViteDevOptions { + clearScreen?: boolean; + config?: string; + cors?: boolean; + force?: boolean; + host?: boolean | string; + logLevel?: Vite.LogLevel; + mode?: string; + open?: boolean | string; + port?: number; + strictPort?: boolean; + profile?: boolean; +} + +export async function dev( + root: string, + { + clearScreen, + config: configFile, + cors, + force, + host, + logLevel, + mode, + open, + port, + strictPort, + }: ViteDevOptions +) { + // Ensure Vite's ESM build is preloaded at the start of the process + // so it can be accessed synchronously via `importViteEsmSync` + await preloadViteEsm(); + + let vite = await import("vite"); + let server = await vite.createServer({ + root, + mode, + configFile, + server: { open, cors, host, port, strictPort }, + optimizeDeps: { force }, + clearScreen, + logLevel, + }); + + if (!server.config.plugins.find((plugin) => plugin.name === "remix")) { + console.error(colors.red("Remix Vite plugin not found in Vite config")); + process.exit(1); + } + + await server.listen(); + server.printUrls(); + + let customShortcuts: Vite.CLIShortcut[] = [ + { + key: "p", + description: "start/stop the profiler", + async action(server) { + if (profiler.getSession()) { + await profiler.stop(server.config.logger.info); + } else { + await profiler.start(() => { + server.config.logger.info("Profiler started"); + }); + } + }, + }, + ]; + + server.bindCLIShortcuts({ print: true, customShortcuts }); +} diff --git a/packages/remix-dev/vite/import-vite-esm-sync.ts b/packages/remix-dev/vite/import-vite-esm-sync.ts new file mode 100644 index 0000000000..f48894cdba --- /dev/null +++ b/packages/remix-dev/vite/import-vite-esm-sync.ts @@ -0,0 +1,21 @@ +// This file is used to avoid CJS deprecation warnings in Vite 5 since +// @remix-run/dev currently compiles to CJS. By using this interface, we only +// ever access the Vite package via a dynamic import which forces the ESM build. +// "importViteAsync" is expected be called up-front in the first async plugin +// hook, which then unlocks "importViteEsmSync" for use anywhere in the plugin +// and its utils. This file won't be needed when this package is ESM only. + +import invariant from "../invariant"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +type Vite = typeof import("vite"); +let vite: Vite | undefined; + +export async function preloadViteEsm(): Promise { + vite = await import("vite"); +} + +export function importViteEsmSync(): Vite { + invariant(vite, "importViteEsmSync() called before preloadViteEsm()"); + return vite; +} diff --git a/packages/remix-dev/vite/index.ts b/packages/remix-dev/vite/index.ts new file mode 100644 index 0000000000..f563856b38 --- /dev/null +++ b/packages/remix-dev/vite/index.ts @@ -0,0 +1,18 @@ +// This file allows us to dynamically require the plugin so non-Vite consumers +// don't need to have Vite installed as a peer dependency. Only types should +// be imported at the top level, or code that doesn't import Vite. +import type { RemixVitePlugin } from "./plugin"; +export type { + BuildManifest, + Preset, + VitePluginConfig, + ServerBundlesFunction, +} from "./plugin"; + +export const vitePlugin: RemixVitePlugin = (...args) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + let { remixVitePlugin } = require("./plugin") as typeof import("./plugin"); + return remixVitePlugin(...args); +}; + +export { cloudflareDevProxyVitePlugin } from "./cloudflare-proxy-plugin"; diff --git a/packages/remix-dev/vite/node-adapter.ts b/packages/remix-dev/vite/node-adapter.ts new file mode 100644 index 0000000000..ad270a8552 --- /dev/null +++ b/packages/remix-dev/vite/node-adapter.ts @@ -0,0 +1,87 @@ +import type { IncomingHttpHeaders, ServerResponse } from "node:http"; +import { once } from "node:events"; +import { Readable } from "node:stream"; +import { splitCookiesString } from "set-cookie-parser"; +import { createReadableStreamFromReadable } from "@remix-run/node"; +import type * as Vite from "vite"; + +import invariant from "../invariant"; + +export type NodeRequestHandler = ( + req: Vite.Connect.IncomingMessage, + res: ServerResponse +) => Promise; + +function fromNodeHeaders(nodeHeaders: IncomingHttpHeaders): Headers { + let headers = new Headers(); + + for (let [key, values] of Object.entries(nodeHeaders)) { + if (values) { + if (Array.isArray(values)) { + for (let value of values) { + headers.append(key, value); + } + } else { + headers.set(key, values); + } + } + } + + return headers; +} + +// Based on `createRemixRequest` in packages/remix-express/server.ts +export function fromNodeRequest( + nodeReq: Vite.Connect.IncomingMessage +): Request { + let origin = + nodeReq.headers.origin && "null" !== nodeReq.headers.origin + ? nodeReq.headers.origin + : `http://${nodeReq.headers.host}`; + // Use `req.originalUrl` so Remix is aware of the full path + invariant( + nodeReq.originalUrl, + "Expected `nodeReq.originalUrl` to be defined" + ); + let url = new URL(nodeReq.originalUrl, origin); + let init: RequestInit = { + method: nodeReq.method, + headers: fromNodeHeaders(nodeReq.headers), + }; + + if (nodeReq.method !== "GET" && nodeReq.method !== "HEAD") { + init.body = createReadableStreamFromReadable(nodeReq); + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url.href, init); +} + +// Adapted from solid-start's `handleNodeResponse`: +// https://github.com/solidjs/solid-start/blob/7398163869b489cce503c167e284891cf51a6613/packages/start/node/fetch.js#L162-L185 +export async function toNodeRequest(res: Response, nodeRes: ServerResponse) { + nodeRes.statusCode = res.status; + nodeRes.statusMessage = res.statusText; + + let cookiesStrings = []; + + for (let [name, value] of res.headers) { + if (name === "set-cookie") { + cookiesStrings.push(...splitCookiesString(value)); + } else nodeRes.setHeader(name, value); + } + + if (cookiesStrings.length) { + nodeRes.setHeader("set-cookie", cookiesStrings); + } + + if (res.body) { + // https://github.com/microsoft/TypeScript/issues/29867 + let responseBody = res.body as unknown as AsyncIterable; + let readable = Readable.from(responseBody); + readable.pipe(nodeRes); + await once(readable, "end"); + } else { + nodeRes.end(); + } +} diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts new file mode 100644 index 0000000000..2160bcf7ed --- /dev/null +++ b/packages/remix-dev/vite/plugin.ts @@ -0,0 +1,1944 @@ +// We can only import types from Vite at the top level since we're in a CJS +// context but want to use Vite's ESM build to avoid deprecation warnings +import type * as Vite from "vite"; +import { type BinaryLike, createHash } from "node:crypto"; +import * as path from "node:path"; +import * as url from "node:url"; +import * as fse from "fs-extra"; +import babel from "@babel/core"; +import { + type ServerBuild, + unstable_setDevServerHooks as setDevServerHooks, + createRequestHandler, +} from "@remix-run/server-runtime"; +import { + init as initEsModuleLexer, + parse as esModuleLexer, +} from "es-module-lexer"; +import jsesc from "jsesc"; +import pick from "lodash/pick"; +import omit from "lodash/omit"; +import colors from "picocolors"; + +import { type ConfigRoute, type RouteManifest } from "../config/routes"; +import { + type AppConfig as RemixEsbuildUserConfig, + type RemixConfig as ResolvedRemixEsbuildConfig, + resolveConfig as resolveRemixEsbuildConfig, + findConfig, +} from "../config"; +import { type Manifest as RemixManifest } from "../manifest"; +import invariant from "../invariant"; +import { + type NodeRequestHandler, + fromNodeRequest, + toNodeRequest, +} from "./node-adapter"; +import { getStylesForUrl, isCssModulesFile } from "./styles"; +import * as VirtualModule from "./vmod"; +import { resolveFileUrl } from "./resolve-file-url"; +import { removeExports } from "./remove-exports"; +import { importViteEsmSync, preloadViteEsm } from "./import-vite-esm-sync"; + +export async function resolveViteConfig({ + configFile, + mode, + root, +}: { + configFile?: string; + mode?: string; + root: string; +}) { + let vite = await import("vite"); + + let viteConfig = await vite.resolveConfig( + { mode, configFile, root }, + "build", // command + "production", // default mode + "production" // default NODE_ENV + ); + + if (typeof viteConfig.build.manifest === "string") { + throw new Error("Custom Vite manifest paths are not supported"); + } + + return viteConfig; +} + +export async function extractRemixPluginContext( + viteConfig: Vite.ResolvedConfig +) { + return viteConfig["__remixPluginContext" as keyof typeof viteConfig] as + | RemixPluginContext + | undefined; +} + +export async function loadVitePluginContext({ + configFile, + root, +}: { + configFile?: string; + root?: string; +}) { + if (!root) { + root = process.env.REMIX_ROOT || process.cwd(); + } + + configFile = + configFile ?? + findConfig(root, "vite.config", [ + ".ts", + ".cts", + ".mts", + ".js", + ".cjs", + ".mjs", + ]); + + // V3 TODO: Vite config should not be optional + if (!configFile) { + return; + } + + let viteConfig = await resolveViteConfig({ configFile, root }); + return await extractRemixPluginContext(viteConfig); +} + +const supportedRemixEsbuildConfigKeys = [ + "appDirectory", + "future", + "ignoredRouteFiles", + "routes", + "serverModuleFormat", +] as const satisfies ReadonlyArray; +type SupportedRemixEsbuildUserConfig = Pick< + RemixEsbuildUserConfig, + typeof supportedRemixEsbuildConfigKeys[number] +>; + +const SERVER_ONLY_ROUTE_EXPORTS = ["loader", "action", "headers"]; +const CLIENT_ROUTE_EXPORTS = [ + "clientAction", + "clientLoader", + "default", + "ErrorBoundary", + "handle", + "HydrateFallback", + "Layout", + "links", + "meta", + "shouldRevalidate", +]; + +// The "=1" suffix ensures client route requests can be processed before hitting +// the Vite plugin since "?client-route" can be serialized as "?client-route=" +const CLIENT_ROUTE_QUERY_STRING = "?client-route=1"; + +// Only expose a subset of route properties to the "serverBundles" function +const branchRouteProperties = [ + "id", + "path", + "file", + "index", +] as const satisfies ReadonlyArray; +type BranchRoute = Pick; + +export const configRouteToBranchRoute = ( + configRoute: ConfigRoute +): BranchRoute => pick(configRoute, branchRouteProperties); + +export type ServerBundlesFunction = (args: { + branch: BranchRoute[]; +}) => string | Promise; + +type BaseBuildManifest = { + routes: RouteManifest; +}; + +type DefaultBuildManifest = BaseBuildManifest & { + serverBundles?: never; + routeIdToServerBundleId?: never; +}; + +export type ServerBundlesBuildManifest = BaseBuildManifest & { + serverBundles: { + [serverBundleId: string]: { + id: string; + file: string; + }; + }; + routeIdToServerBundleId: Record; +}; + +export type BuildManifest = DefaultBuildManifest | ServerBundlesBuildManifest; + +const excludedRemixConfigPresetKeys = [ + "presets", +] as const satisfies ReadonlyArray; + +type ExcludedRemixConfigPresetKey = + typeof excludedRemixConfigPresetKeys[number]; + +type RemixConfigPreset = Omit; + +export type Preset = { + name: string; + remixConfig?: (args: { + remixUserConfig: VitePluginConfig; + }) => RemixConfigPreset | Promise; + remixConfigResolved?: (args: { + remixConfig: ResolvedVitePluginConfig; + }) => void | Promise; +}; + +export type VitePluginConfig = SupportedRemixEsbuildUserConfig & { + /** + * The react router app basename. Defaults to `"/"`. + */ + basename?: string; + /** + * The path to the build directory, relative to the project. Defaults to + * `"build"`. + */ + buildDirectory?: string; + /** + * A function that is called after the full Remix build is complete. + */ + buildEnd?: BuildEndHook; + /** + * Whether to write a `"manifest.json"` file to the build directory.` + * Defaults to `false`. + */ + manifest?: boolean; + /** + * An array of Remix config presets to ease integration with other platforms + * and tools. + */ + presets?: Array; + /** + * The file name of the server build output. This file + * should end in a `.js` extension and should be deployed to your server. + * Defaults to `"index.js"`. + */ + serverBuildFile?: string; + /** + * A function for assigning routes to different server bundles. This + * function should return a server bundle ID which will be used as the + * bundle's directory name within the server build directory. + */ + serverBundles?: ServerBundlesFunction; + /** + * Enable server-side rendering for your application. Disable to use Remix in + * "SPA Mode", which will request the `/` path at build-time and save it as + * an `index.html` file with your assets so your application can be deployed + * as a SPA without server-rendering. Default's to `true`. + */ + ssr?: boolean; +}; + +type BuildEndHook = (args: { + buildManifest: BuildManifest | undefined; + remixConfig: ResolvedVitePluginConfig; + viteConfig: Vite.ResolvedConfig; +}) => void | Promise; + +export type ResolvedVitePluginConfig = Readonly< + Pick< + ResolvedRemixEsbuildConfig, + "appDirectory" | "future" | "publicPath" | "routes" | "serverModuleFormat" + > & { + basename: string; + buildDirectory: string; + buildEnd?: BuildEndHook; + manifest: boolean; + publicPath: string; // derived from Vite's `base` config + serverBuildFile: string; + serverBundles?: ServerBundlesFunction; + ssr: boolean; + } +>; + +export type ServerBundleBuildConfig = { + routes: RouteManifest; + serverBundleId: string; +}; + +type RemixPluginSsrBuildContext = + | { + isSsrBuild: false; + getRemixServerManifest?: never; + serverBundleBuildConfig?: never; + } + | { + isSsrBuild: true; + getRemixServerManifest: () => Promise; + serverBundleBuildConfig: ServerBundleBuildConfig | null; + }; + +export type RemixPluginContext = RemixPluginSsrBuildContext & { + rootDirectory: string; + entryClientFilePath: string; + entryServerFilePath: string; + remixConfig: ResolvedVitePluginConfig; + viteManifestEnabled: boolean; +}; + +let serverBuildId = VirtualModule.id("server-build"); +let serverManifestId = VirtualModule.id("server-manifest"); +let browserManifestId = VirtualModule.id("browser-manifest"); +let hmrRuntimeId = VirtualModule.id("hmr-runtime"); +let injectHmrRuntimeId = VirtualModule.id("inject-hmr-runtime"); + +const resolveRelativeRouteFilePath = ( + route: ConfigRoute, + remixConfig: ResolvedVitePluginConfig +) => { + let vite = importViteEsmSync(); + let file = route.file; + let fullPath = path.resolve(remixConfig.appDirectory, file); + + return vite.normalizePath(fullPath); +}; + +let vmods = [serverBuildId, serverManifestId, browserManifestId]; + +const invalidateVirtualModules = (viteDevServer: Vite.ViteDevServer) => { + vmods.forEach((vmod) => { + let mod = viteDevServer.moduleGraph.getModuleById( + VirtualModule.resolve(vmod) + ); + if (mod) { + viteDevServer.moduleGraph.invalidateModule(mod); + } + }); +}; + +const getHash = (source: BinaryLike, maxLength?: number): string => { + let hash = createHash("sha256").update(source).digest("hex"); + return typeof maxLength === "number" ? hash.slice(0, maxLength) : hash; +}; + +const isClientRoute = (id: string): boolean => { + return id.endsWith(CLIENT_ROUTE_QUERY_STRING); +}; + +const resolveChunk = ( + ctx: RemixPluginContext, + viteManifest: Vite.Manifest, + absoluteFilePath: string +) => { + let vite = importViteEsmSync(); + let rootRelativeFilePath = vite.normalizePath( + path.relative(ctx.rootDirectory, absoluteFilePath) + ); + let entryChunk = + viteManifest[rootRelativeFilePath + CLIENT_ROUTE_QUERY_STRING] ?? + viteManifest[rootRelativeFilePath]; + + if (!entryChunk) { + let knownManifestKeys = Object.keys(viteManifest) + .map((key) => '"' + key + '"') + .join(", "); + throw new Error( + `No manifest entry found for "${rootRelativeFilePath}". Known manifest keys: ${knownManifestKeys}` + ); + } + + return entryChunk; +}; + +const getRemixManifestBuildAssets = ( + ctx: RemixPluginContext, + viteManifest: Vite.Manifest, + entryFilePath: string, + prependedAssetFilePaths: string[] = [] +): RemixManifest["entry"] & { css: string[] } => { + let entryChunk = resolveChunk(ctx, viteManifest, entryFilePath); + + // This is here to support prepending client entry assets to the root route + let prependedAssetChunks = prependedAssetFilePaths.map((filePath) => + resolveChunk(ctx, viteManifest, filePath) + ); + + let chunks = resolveDependantChunks(viteManifest, [ + ...prependedAssetChunks, + entryChunk, + ]); + + return { + module: `${ctx.remixConfig.publicPath}${entryChunk.file}`, + imports: + dedupe(chunks.flatMap((e) => e.imports ?? [])).map((imported) => { + return `${ctx.remixConfig.publicPath}${viteManifest[imported].file}`; + }) ?? [], + css: + dedupe(chunks.flatMap((e) => e.css ?? [])).map((href) => { + return `${ctx.remixConfig.publicPath}${href}`; + }) ?? [], + }; +}; + +function resolveDependantChunks( + viteManifest: Vite.Manifest, + entryChunks: Vite.ManifestChunk[] +): Vite.ManifestChunk[] { + let chunks = new Set(); + + function walk(chunk: Vite.ManifestChunk) { + if (chunks.has(chunk)) { + return; + } + + if (chunk.imports) { + for (let importKey of chunk.imports) { + walk(viteManifest[importKey]); + } + } + + chunks.add(chunk); + } + + for (let entryChunk of entryChunks) { + walk(entryChunk); + } + + return Array.from(chunks); +} + +function dedupe(array: T[]): T[] { + return [...new Set(array)]; +} + +const writeFileSafe = async (file: string, contents: string): Promise => { + await fse.ensureDir(path.dirname(file)); + await fse.writeFile(file, contents); +}; + +const getRouteManifestModuleExports = async ( + viteChildCompiler: Vite.ViteDevServer | null, + ctx: RemixPluginContext +): Promise> => { + let entries = await Promise.all( + Object.entries(ctx.remixConfig.routes).map(async ([key, route]) => { + let sourceExports = await getRouteModuleExports( + viteChildCompiler, + ctx, + route.file + ); + return [key, sourceExports] as const; + }) + ); + return Object.fromEntries(entries); +}; + +const getRouteModuleExports = async ( + viteChildCompiler: Vite.ViteDevServer | null, + ctx: RemixPluginContext, + routeFile: string, + readRouteFile?: () => string | Promise +): Promise => { + if (!viteChildCompiler) { + throw new Error("Vite child compiler not found"); + } + + // We transform the route module code with the Vite child compiler so that we + // can parse the exports from non-JS files like MDX. This ensures that we can + // understand the exports from anything that Vite can compile to JS, not just + // the route file formats that the Remix compiler historically supported. + + let ssr = true; + let { pluginContainer, moduleGraph } = viteChildCompiler; + + let routePath = path.resolve(ctx.remixConfig.appDirectory, routeFile); + let url = resolveFileUrl(ctx, routePath); + + let resolveId = async () => { + let result = await pluginContainer.resolveId(url, undefined, { ssr }); + if (!result) throw new Error(`Could not resolve module ID for ${url}`); + return result.id; + }; + + let [id, code] = await Promise.all([ + resolveId(), + readRouteFile?.() ?? fse.readFile(routePath, "utf-8"), + // pluginContainer.transform(...) fails if we don't do this first: + moduleGraph.ensureEntryFromUrl(url, ssr), + ]); + + let transformed = await pluginContainer.transform(code, id, { ssr }); + let [, exports] = esModuleLexer(transformed.code); + let exportNames = exports.map((e) => e.n); + + return exportNames; +}; + +const getServerBundleBuildConfig = ( + viteUserConfig: Vite.UserConfig +): ServerBundleBuildConfig | null => { + if ( + !("__remixServerBundleBuildConfig" in viteUserConfig) || + !viteUserConfig.__remixServerBundleBuildConfig + ) { + return null; + } + + return viteUserConfig.__remixServerBundleBuildConfig as ServerBundleBuildConfig; +}; + +export let getServerBuildDirectory = (ctx: RemixPluginContext) => + path.join( + ctx.remixConfig.buildDirectory, + "server", + ...(ctx.serverBundleBuildConfig + ? [ctx.serverBundleBuildConfig.serverBundleId] + : []) + ); + +let getClientBuildDirectory = (remixConfig: ResolvedVitePluginConfig) => + path.join(remixConfig.buildDirectory, "client"); + +let defaultEntriesDir = path.resolve(__dirname, "..", "config", "defaults"); +let defaultEntries = fse + .readdirSync(defaultEntriesDir) + .map((filename) => path.join(defaultEntriesDir, filename)); +invariant(defaultEntries.length > 0, "No default entries found"); + +let mergeRemixConfig = (...configs: VitePluginConfig[]): VitePluginConfig => { + let reducer = ( + configA: VitePluginConfig, + configB: VitePluginConfig + ): VitePluginConfig => { + let mergeRequired = (key: keyof VitePluginConfig) => + configA[key] !== undefined && configB[key] !== undefined; + + return { + ...configA, + ...configB, + ...(mergeRequired("buildEnd") + ? { + buildEnd: async (...args) => { + await Promise.all([ + configA.buildEnd?.(...args), + configB.buildEnd?.(...args), + ]); + }, + } + : {}), + ...(mergeRequired("future") + ? { + future: { + ...configA.future, + ...configB.future, + }, + } + : {}), + ...(mergeRequired("ignoredRouteFiles") + ? { + ignoredRouteFiles: Array.from( + new Set([ + ...(configA.ignoredRouteFiles ?? []), + ...(configB.ignoredRouteFiles ?? []), + ]) + ), + } + : {}), + ...(mergeRequired("presets") + ? { + presets: [...(configA.presets ?? []), ...(configB.presets ?? [])], + } + : {}), + ...(mergeRequired("routes") + ? { + routes: async (...args) => { + let [routesA, routesB] = await Promise.all([ + configA.routes?.(...args), + configB.routes?.(...args), + ]); + + return { + ...routesA, + ...routesB, + }; + }, + } + : {}), + }; + }; + + return configs.reduce(reducer, {}); +}; + +type MaybePromise = T | Promise; + +let remixDevLoadContext: ( + request: Request +) => MaybePromise> = () => ({}); + +export let setRemixDevLoadContext = ( + loadContext: (request: Request) => MaybePromise> +) => { + remixDevLoadContext = loadContext; +}; + +// Inlined from https://github.com/jsdf/deep-freeze +let deepFreeze = (o: any) => { + Object.freeze(o); + let oIsFunction = typeof o === "function"; + let hasOwnProp = Object.prototype.hasOwnProperty; + Object.getOwnPropertyNames(o).forEach(function (prop) { + if ( + hasOwnProp.call(o, prop) && + (oIsFunction + ? prop !== "caller" && prop !== "callee" && prop !== "arguments" + : true) && + o[prop] !== null && + (typeof o[prop] === "object" || typeof o[prop] === "function") && + !Object.isFrozen(o[prop]) + ) { + deepFreeze(o[prop]); + } + }); + return o; +}; + +export type RemixVitePlugin = (config?: VitePluginConfig) => Vite.Plugin[]; +export const remixVitePlugin: RemixVitePlugin = (remixUserConfig = {}) => { + // Prevent mutations to the user config + remixUserConfig = deepFreeze(remixUserConfig); + + let viteCommand: Vite.ResolvedConfig["command"]; + let viteUserConfig: Vite.UserConfig; + let viteConfigEnv: Vite.ConfigEnv; + let viteConfig: Vite.ResolvedConfig | undefined; + let cssModulesManifest: Record = {}; + let viteChildCompiler: Vite.ViteDevServer | null = null; + + // This is initialized by `updateRemixPluginContext` during Vite's `config` + // hook, so most of the code can assume this defined without null check. + // During dev, `updateRemixPluginContext` is called again on every config file + // change or route file addition/removal. + let ctx: RemixPluginContext; + + /** Mutates `ctx` as a side-effect */ + let updateRemixPluginContext = async (): Promise => { + let remixConfigPresets: VitePluginConfig[] = ( + await Promise.all( + (remixUserConfig.presets ?? []).map(async (preset) => { + if (!preset.name) { + throw new Error( + "Remix presets must have a `name` property defined." + ); + } + + if (!preset.remixConfig) { + return null; + } + + let remixConfigPreset: VitePluginConfig = omit( + await preset.remixConfig({ remixUserConfig }), + excludedRemixConfigPresetKeys + ); + + return remixConfigPreset; + }) + ) + ).filter(function isNotNull(value: T | null): value is T { + return value !== null; + }); + + let defaults = { + basename: "/", + buildDirectory: "build", + manifest: false, + serverBuildFile: "index.js", + ssr: true, + } as const satisfies Partial; + + let resolvedRemixUserConfig = { + ...defaults, // Default values should be completely overridden by user/preset config, not merged + ...mergeRemixConfig(...remixConfigPresets, remixUserConfig), + }; + + let rootDirectory = + viteUserConfig.root ?? process.env.REMIX_ROOT ?? process.cwd(); + + let { basename, buildEnd, manifest, ssr } = resolvedRemixUserConfig; + let isSpaMode = !ssr; + + // Only select the Remix esbuild config options that the Vite plugin uses + let { + appDirectory, + entryClientFilePath, + entryServerFilePath, + future, + routes, + serverModuleFormat, + } = await resolveRemixEsbuildConfig( + pick(resolvedRemixUserConfig, supportedRemixEsbuildConfigKeys), + { rootDirectory, isSpaMode } + ); + + let buildDirectory = path.resolve( + rootDirectory, + resolvedRemixUserConfig.buildDirectory + ); + + let { serverBuildFile, serverBundles } = resolvedRemixUserConfig; + + let publicPath = viteUserConfig.base ?? "/"; + + if ( + basename !== "/" && + viteCommand === "serve" && + !viteUserConfig.server?.middlewareMode && + !basename.startsWith(publicPath) + ) { + throw new Error( + "When using the Remix `basename` and the Vite `base` config, " + + "the `basename` config must begin with `base` for the default " + + "Vite dev server." + ); + } + + // Log warning for incompatible vite config flags + if (isSpaMode && serverBundles) { + console.warn( + colors.yellow( + colors.bold("⚠️ SPA Mode: ") + + "the `serverBundles` config is invalid with " + + "`ssr:false` and will be ignored`" + ) + ); + serverBundles = undefined; + } + + let remixConfig: ResolvedVitePluginConfig = deepFreeze({ + appDirectory, + basename, + buildDirectory, + buildEnd, + future, + manifest, + publicPath, + routes, + serverBuildFile, + serverBundles, + serverModuleFormat, + ssr, + }); + + for (let preset of remixUserConfig.presets ?? []) { + await preset.remixConfigResolved?.({ remixConfig }); + } + + let viteManifestEnabled = viteUserConfig.build?.manifest === true; + + let ssrBuildCtx: RemixPluginSsrBuildContext = + viteConfigEnv.isSsrBuild && viteCommand === "build" + ? { + isSsrBuild: true, + getRemixServerManifest: async () => + (await generateRemixManifestsForBuild()).remixServerManifest, + serverBundleBuildConfig: getServerBundleBuildConfig(viteUserConfig), + } + : { isSsrBuild: false }; + + ctx = { + remixConfig, + rootDirectory, + entryClientFilePath, + entryServerFilePath, + viteManifestEnabled, + ...ssrBuildCtx, + }; + }; + + let pluginIndex = (pluginName: string) => { + invariant(viteConfig); + return viteConfig.plugins.findIndex((plugin) => plugin.name === pluginName); + }; + + let getServerEntry = async () => { + invariant(viteConfig, "viteconfig required to generate the server entry"); + + // v3 TODO: + // - Deprecate `ServerBuild.mode` once we officially stabilize vite and + // mark the old compiler as deprecated + // - Remove `ServerBuild.mode` in v3 + + let routes = ctx.serverBundleBuildConfig + ? // For server bundle builds, the server build should only import the + // routes for this bundle rather than importing all routes + ctx.serverBundleBuildConfig.routes + : // Otherwise, all routes are imported as usual + ctx.remixConfig.routes; + + return ` + import * as entryServer from ${JSON.stringify( + resolveFileUrl(ctx, ctx.entryServerFilePath) + )}; + ${Object.keys(routes) + .map((key, index) => { + let route = routes[key]!; + return `import * as route${index} from ${JSON.stringify( + resolveFileUrl( + ctx, + resolveRelativeRouteFilePath(route, ctx.remixConfig) + ) + )};`; + }) + .join("\n")} + /** + * \`mode\` is only relevant for the old Remix compiler but + * is included here to satisfy the \`ServerBuild\` typings. + */ + export const mode = ${JSON.stringify(viteConfig.mode)}; + export { default as assets } from ${JSON.stringify(serverManifestId)}; + export const assetsBuildDirectory = ${JSON.stringify( + path.relative( + ctx.rootDirectory, + getClientBuildDirectory(ctx.remixConfig) + ) + )}; + export const basename = ${JSON.stringify(ctx.remixConfig.basename)}; + export const future = ${JSON.stringify(ctx.remixConfig.future)}; + export const isSpaMode = ${!ctx.remixConfig.ssr}; + export const publicPath = ${JSON.stringify(ctx.remixConfig.publicPath)}; + export const entry = { module: entryServer }; + export const routes = { + ${Object.keys(routes) + .map((key, index) => { + let route = routes[key]!; + return `${JSON.stringify(key)}: { + id: ${JSON.stringify(route.id)}, + parentId: ${JSON.stringify(route.parentId)}, + path: ${JSON.stringify(route.path)}, + index: ${JSON.stringify(route.index)}, + caseSensitive: ${JSON.stringify(route.caseSensitive)}, + module: route${index} + }`; + }) + .join(",\n ")} + };`; + }; + + let loadViteManifest = async (directory: string) => { + let manifestContents = await fse.readFile( + path.resolve(directory, ".vite", "manifest.json"), + "utf-8" + ); + return JSON.parse(manifestContents) as Vite.Manifest; + }; + + let getViteManifestAssetPaths = ( + viteManifest: Vite.Manifest + ): Set => { + // Get .css?url imports and CSS entry points + let cssUrlPaths = Object.values(viteManifest) + .filter((chunk) => chunk.file.endsWith(".css")) + .map((chunk) => chunk.file); + + // Get bundled CSS files and generic asset types + let chunkAssetPaths = Object.values(viteManifest).flatMap( + (chunk) => chunk.assets ?? [] + ); + + return new Set([...cssUrlPaths, ...chunkAssetPaths]); + }; + + let generateRemixManifestsForBuild = async (): Promise<{ + remixBrowserManifest: RemixManifest; + remixServerManifest: RemixManifest; + }> => { + invariant(viteConfig); + + let viteManifest = await loadViteManifest( + getClientBuildDirectory(ctx.remixConfig) + ); + + let entry = getRemixManifestBuildAssets( + ctx, + viteManifest, + ctx.entryClientFilePath + ); + + let browserRoutes: RemixManifest["routes"] = {}; + let serverRoutes: RemixManifest["routes"] = {}; + + let routeManifestExports = await getRouteManifestModuleExports( + viteChildCompiler, + ctx + ); + + for (let [key, route] of Object.entries(ctx.remixConfig.routes)) { + let routeFilePath = path.join(ctx.remixConfig.appDirectory, route.file); + let sourceExports = routeManifestExports[key]; + let isRootRoute = route.parentId === undefined; + + let routeManifestEntry = { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + hasAction: sourceExports.includes("action"), + hasLoader: sourceExports.includes("loader"), + hasClientAction: sourceExports.includes("clientAction"), + hasClientLoader: sourceExports.includes("clientLoader"), + hasErrorBoundary: sourceExports.includes("ErrorBoundary"), + ...getRemixManifestBuildAssets( + ctx, + viteManifest, + routeFilePath, + // If this is the root route, we also need to include assets from the + // client entry file as this is a common way for consumers to import + // global reset styles, etc. + isRootRoute ? [ctx.entryClientFilePath] : [] + ), + }; + + browserRoutes[key] = routeManifestEntry; + + let serverBundleRoutes = ctx.serverBundleBuildConfig?.routes; + if (!serverBundleRoutes || serverBundleRoutes[key]) { + serverRoutes[key] = routeManifestEntry; + } + } + + let fingerprintedValues = { entry, routes: browserRoutes }; + let version = getHash(JSON.stringify(fingerprintedValues), 8); + let manifestPath = path.posix.join( + viteConfig.build.assetsDir, + `manifest-${version}.js` + ); + let url = `${ctx.remixConfig.publicPath}${manifestPath}`; + let nonFingerprintedValues = { url, version }; + + let remixBrowserManifest: RemixManifest = { + ...fingerprintedValues, + ...nonFingerprintedValues, + }; + + // Write the browser manifest to disk as part of the build process + await writeFileSafe( + path.join(getClientBuildDirectory(ctx.remixConfig), manifestPath), + `window.__remixManifest=${JSON.stringify(remixBrowserManifest)};` + ); + + // The server manifest is the same as the browser manifest, except for + // server bundle builds which only includes routes for the current bundle, + // otherwise the server and client have the same routes + let remixServerManifest: RemixManifest = { + ...remixBrowserManifest, + routes: serverRoutes, + }; + + return { + remixBrowserManifest, + remixServerManifest, + }; + }; + + // In dev, the server and browser Remix manifests are the same + let getRemixManifestForDev = async (): Promise => { + let routes: RemixManifest["routes"] = {}; + + let routeManifestExports = await getRouteManifestModuleExports( + viteChildCompiler, + ctx + ); + + for (let [key, route] of Object.entries(ctx.remixConfig.routes)) { + let sourceExports = routeManifestExports[key]; + routes[key] = { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + module: path.posix.join( + ctx.remixConfig.publicPath, + `${resolveFileUrl( + ctx, + resolveRelativeRouteFilePath(route, ctx.remixConfig) + )}${CLIENT_ROUTE_QUERY_STRING}` + ), + hasAction: sourceExports.includes("action"), + hasLoader: sourceExports.includes("loader"), + hasClientAction: sourceExports.includes("clientAction"), + hasClientLoader: sourceExports.includes("clientLoader"), + hasErrorBoundary: sourceExports.includes("ErrorBoundary"), + imports: [], + }; + } + + return { + version: String(Math.random()), + url: path.posix.join( + ctx.remixConfig.publicPath, + VirtualModule.url(browserManifestId) + ), + hmr: { + runtime: path.posix.join( + ctx.remixConfig.publicPath, + VirtualModule.url(injectHmrRuntimeId) + ), + }, + entry: { + module: path.posix.join( + ctx.remixConfig.publicPath, + resolveFileUrl(ctx, ctx.entryClientFilePath) + ), + imports: [], + }, + routes, + }; + }; + + return [ + { + name: "remix", + config: async (_viteUserConfig, _viteConfigEnv) => { + // Preload Vite's ESM build up-front as soon as we're in an async context + await preloadViteEsm(); + + // Ensure sync import of Vite works after async preload + let vite = importViteEsmSync(); + + viteUserConfig = _viteUserConfig; + viteConfigEnv = _viteConfigEnv; + viteCommand = viteConfigEnv.command; + + await updateRemixPluginContext(); + + Object.assign( + process.env, + vite.loadEnv( + viteConfigEnv.mode, + ctx.rootDirectory, + // We override default prefix of "VITE_" with a blank string since + // we're targeting the server, so we want to load all environment + // variables, not just those explicitly marked for the client + "" + ) + ); + + let baseRollupOptions = { + // Silence Rollup "use client" warnings + // Adapted from https://github.com/vitejs/vite-plugin-react/pull/144 + onwarn(warning, defaultHandler) { + if ( + warning.code === "MODULE_LEVEL_DIRECTIVE" && + warning.message.includes("use client") + ) { + return; + } + if (viteUserConfig.build?.rollupOptions?.onwarn) { + viteUserConfig.build.rollupOptions.onwarn( + warning, + defaultHandler + ); + } else { + defaultHandler(warning); + } + }, + } satisfies Vite.BuildOptions["rollupOptions"]; + + return { + __remixPluginContext: ctx, + appType: + viteCommand === "serve" && + viteConfigEnv.mode === "production" && + ctx.remixConfig.ssr === false + ? "spa" + : "custom", + + ssr: { + external: [ + // This is only necessary for development within the Remix repo + // because these packages are symlinked and Vite treats them as + // internal source code. For consumers this is a no-op. + "@remix-run/architect", + "@remix-run/cloudflare-pages", + "@remix-run/cloudflare-workers", + "@remix-run/cloudflare", + "@remix-run/css-bundle", + "@remix-run/deno", + "@remix-run/dev", + "@remix-run/express", + "@remix-run/netlify", + "@remix-run/node", + "@remix-run/react", + "@remix-run/serve", + "@remix-run/server-runtime", + ], + }, + optimizeDeps: { + include: [ + // Pre-bundle React dependencies to avoid React duplicates, + // even if React dependencies are not direct dependencies. + // https://react.dev/warnings/invalid-hook-call-warning#duplicate-react + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "react-dom/client", + + // Pre-bundle Remix dependencies to avoid Remix router duplicates. + // Our remix-remix-react-proxy plugin does not process default client and + // server entry files since those come from within `node_modules`. + // That means that before Vite pre-bundles dependencies (e.g. first time dev server is run) + // mismatching Remix routers cause `Error: You must render this element inside a element`. + "@remix-run/react", + + // For some reason, the `vite-dotenv` integration test consistently fails on webkit + // with `504 (Outdated Optimize Dep)` from Vite unless `@remix-run/node` is included + // in `optimizeDeps.include`. 🤷 + // This could be caused by how we copy `node_modules/` into integration test fixtures, + // so maybe this will be unnecessary once we switch to pnpm + "@remix-run/node", + ], + }, + esbuild: { + jsx: "automatic", + jsxDev: viteCommand !== "build", + }, + resolve: { + dedupe: [ + // https://react.dev/warnings/invalid-hook-call-warning#duplicate-react + "react", + "react-dom", + + // see description for `@remix-run/react` in `optimizeDeps.include` + "@remix-run/react", + ], + }, + base: viteUserConfig.base, + + // When consumer provides an allow list for files that can be read by + // the server, ensure that Remix's default entry files are included. + // If we don't do this and a default entry file is used, the server + // will throw an error that the file is not allowed to be read. + // https://vitejs.dev/config/server-options#server-fs-allow + server: viteUserConfig.server?.fs?.allow + ? { fs: { allow: defaultEntries } } + : undefined, + + // Vite config options for building + ...(viteCommand === "build" + ? { + build: { + cssMinify: viteUserConfig.build?.cssMinify ?? true, + ...(!viteConfigEnv.isSsrBuild + ? { + manifest: true, + outDir: getClientBuildDirectory(ctx.remixConfig), + rollupOptions: { + ...baseRollupOptions, + preserveEntrySignatures: "exports-only", + input: [ + ctx.entryClientFilePath, + ...Object.values(ctx.remixConfig.routes).map( + (route) => + `${path.resolve( + ctx.remixConfig.appDirectory, + route.file + )}${CLIENT_ROUTE_QUERY_STRING}` + ), + ], + }, + } + : { + // We move SSR-only assets to client assets. Note that the + // SSR build can also emit code-split JS files (e.g. by + // dynamic import) under the same assets directory + // regardless of "ssrEmitAssets" option, so we also need to + // keep these JS files have to be kept as-is. + ssrEmitAssets: true, + copyPublicDir: false, // Assets in the public directory are only used by the client + manifest: true, // We need the manifest to detect SSR-only assets + outDir: getServerBuildDirectory(ctx), + rollupOptions: { + ...baseRollupOptions, + preserveEntrySignatures: "exports-only", + input: serverBuildId, + output: { + entryFileNames: ctx.remixConfig.serverBuildFile, + format: ctx.remixConfig.serverModuleFormat, + }, + }, + }), + }, + } + : undefined), + + // Vite config options for SPA preview mode + ...(viteCommand === "serve" && ctx.remixConfig.ssr === false + ? { + build: { + manifest: true, + outDir: getClientBuildDirectory(ctx.remixConfig), + }, + } + : undefined), + }; + }, + async configResolved(resolvedViteConfig) { + await initEsModuleLexer; + + viteConfig = resolvedViteConfig; + invariant(viteConfig); + + // We load the same Vite config file again for the child compiler so + // that both parent and child compiler's plugins have independent state. + // If we re-used the `viteUserConfig.plugins` array for the child + // compiler, it could lead to mutating shared state between plugin + // instances in unexpected ways, e.g. during `vite build` the + // `configResolved` plugin hook would be called with `command = "build"` + // by parent and then `command = "serve"` by child, which some plugins + // may respond to by updating state referenced by the parent. + if (!viteConfig.configFile) { + throw new Error( + "The Remix Vite plugin requires the use of a Vite config file" + ); + } + + let vite = importViteEsmSync(); + + let childCompilerConfigFile = await vite.loadConfigFromFile( + { + command: viteConfig.command, + mode: viteConfig.mode, + isSsrBuild: ctx.isSsrBuild, + }, + viteConfig.configFile + ); + + invariant( + childCompilerConfigFile, + "Vite config file was unable to be resolved for Remix child compiler" + ); + + // Validate that commonly used Rollup plugins that need to run before + // Remix are in the correct order. This is because Rollup plugins can't + // set `enforce: "pre"` like Vite plugins can. Explicitly validating + // this provides a much nicer developer experience. + let rollupPrePlugins = [ + { pluginName: "@mdx-js/rollup", displayName: "@mdx-js/rollup" }, + ]; + for (let prePlugin of rollupPrePlugins) { + let prePluginIndex = pluginIndex(prePlugin.pluginName); + if (prePluginIndex >= 0 && prePluginIndex > pluginIndex("remix")) { + throw new Error( + `The "${prePlugin.displayName}" plugin should be placed before the Remix plugin in your Vite config file` + ); + } + } + + viteChildCompiler = await vite.createServer({ + ...viteUserConfig, + mode: viteConfig.mode, + server: { + watch: viteConfig.command === "build" ? null : undefined, + preTransformRequests: false, + hmr: false, + }, + configFile: false, + envFile: false, + plugins: [ + ...(childCompilerConfigFile.config.plugins ?? []) + .flat() + // Exclude this plugin from the child compiler to prevent an + // infinite loop (plugin creates a child compiler with the same + // plugin that creates another child compiler, repeat ad + // infinitum), and to prevent the manifest from being written to + // disk from the child compiler. This is important in the + // production build because the child compiler is a Vite dev + // server and will generate incorrect manifests. + .filter( + (plugin) => + typeof plugin === "object" && + plugin !== null && + "name" in plugin && + plugin.name !== "remix" && + plugin.name !== "remix-hmr-updates" + ), + ], + }); + await viteChildCompiler.pluginContainer.buildStart({}); + }, + async transform(code, id) { + if (isCssModulesFile(id)) { + cssModulesManifest[id] = code; + } + + if (isClientRoute(id)) { + let routeModuleId = id.replace(CLIENT_ROUTE_QUERY_STRING, ""); + let sourceExports = await getRouteModuleExports( + viteChildCompiler, + ctx, + routeModuleId + ); + + let routeFileName = path.basename(routeModuleId); + let clientExports = sourceExports + .filter((exportName) => CLIENT_ROUTE_EXPORTS.includes(exportName)) + .join(", "); + + return `export { ${clientExports} } from "./${routeFileName}";`; + } + }, + buildStart() { + invariant(viteConfig); + + if ( + viteCommand === "build" && + viteConfig.mode === "production" && + !viteConfig.build.ssr && + viteConfig.build.sourcemap + ) { + viteConfig.logger.warn( + colors.yellow( + "\n" + + colors.bold(" ⚠️ Source maps are enabled in production\n") + + [ + "This makes your server code publicly", + "visible in the browser. This is highly", + "discouraged! If you insist, ensure that", + "you are using environment variables for", + "secrets and not hard-coding them in", + "your source code.", + ] + .map((line) => " " + line) + .join("\n") + + "\n" + ) + ); + } + }, + async configureServer(viteDevServer) { + setDevServerHooks({ + // Give the request handler access to the critical CSS in dev to avoid a + // flash of unstyled content since Vite injects CSS file contents via JS + getCriticalCss: async (build, url) => { + return getStylesForUrl({ + rootDirectory: ctx.rootDirectory, + entryClientFilePath: ctx.entryClientFilePath, + remixConfig: ctx.remixConfig, + viteDevServer, + cssModulesManifest, + build, + url, + }); + }, + // If an error is caught within the request handler, let Vite fix the + // stack trace so it maps back to the actual source code + processRequestError: (error) => { + if (error instanceof Error) { + viteDevServer.ssrFixStacktrace(error); + } + }, + }); + + // Invalidate virtual modules and update cached plugin config via file watcher + viteDevServer.watcher.on("all", async (eventName, filepath) => { + let { normalizePath } = importViteEsmSync(); + + let appFileAddedOrRemoved = + (eventName === "add" || eventName === "unlink") && + normalizePath(filepath).startsWith( + normalizePath(ctx.remixConfig.appDirectory) + ); + + invariant(viteConfig?.configFile); + let viteConfigChanged = + eventName === "change" && + normalizePath(filepath) === normalizePath(viteConfig.configFile); + + if (appFileAddedOrRemoved || viteConfigChanged) { + let lastRemixConfig = ctx.remixConfig; + + await updateRemixPluginContext(); + + if (!isEqualJson(lastRemixConfig, ctx.remixConfig)) { + invalidateVirtualModules(viteDevServer); + } + } + }); + + return () => { + // Let user servers handle SSR requests in middleware mode, + // otherwise the Vite plugin will handle the request + if (!viteDevServer.config.server.middlewareMode) { + viteDevServer.middlewares.use(async (req, res, next) => { + try { + let build = (await viteDevServer.ssrLoadModule( + serverBuildId + )) as ServerBuild; + + let handler = createRequestHandler(build, "development"); + let nodeHandler: NodeRequestHandler = async ( + nodeReq, + nodeRes + ) => { + let req = fromNodeRequest(nodeReq); + let res = await handler(req, await remixDevLoadContext(req)); + await toNodeRequest(res, nodeRes); + }; + await nodeHandler(req, res); + } catch (error) { + next(error); + } + }); + } + }; + }, + writeBundle: { + // After the SSR build is finished, we inspect the Vite manifest for + // the SSR build and move server-only assets to client assets directory + async handler() { + if (!ctx.isSsrBuild) { + return; + } + + invariant(viteConfig); + + let clientBuildDirectory = getClientBuildDirectory(ctx.remixConfig); + let serverBuildDirectory = getServerBuildDirectory(ctx); + + let ssrViteManifest = await loadViteManifest(serverBuildDirectory); + let clientViteManifest = await loadViteManifest(clientBuildDirectory); + + let clientAssetPaths = getViteManifestAssetPaths(clientViteManifest); + let ssrAssetPaths = getViteManifestAssetPaths(ssrViteManifest); + + // We only move assets that aren't in the client build, otherwise we + // remove them. These assets only exist because we explicitly set + // `ssrEmitAssets: true` in the SSR Vite config. These assets + // typically wouldn't exist by default, which is why we assume it's + // safe to remove them. We're aiming for a clean build output so that + // unnecessary assets don't get deployed alongside the server code. + let movedAssetPaths: string[] = []; + for (let ssrAssetPath of ssrAssetPaths) { + let src = path.join(serverBuildDirectory, ssrAssetPath); + if (!clientAssetPaths.has(ssrAssetPath)) { + let dest = path.join(clientBuildDirectory, ssrAssetPath); + await fse.move(src, dest); + movedAssetPaths.push(dest); + } else { + await fse.remove(src); + } + } + + // We assume CSS assets from the SSR build are unnecessary and remove + // them for the same reasons as above. + let ssrCssPaths = Object.values(ssrViteManifest).flatMap( + (chunk) => chunk.css ?? [] + ); + await Promise.all( + ssrCssPaths.map((cssPath) => + fse.remove(path.join(serverBuildDirectory, cssPath)) + ) + ); + + if (movedAssetPaths.length) { + viteConfig.logger.info( + [ + "", + `${colors.green("✓")} ${movedAssetPaths.length} asset${ + movedAssetPaths.length > 1 ? "s" : "" + } moved from Remix server build to client assets.`, + ...movedAssetPaths.map((movedAssetPath) => + colors.dim(path.relative(ctx.rootDirectory, movedAssetPath)) + ), + "", + ].join("\n") + ); + } + + if (!ctx.remixConfig.ssr) { + await handleSpaMode( + serverBuildDirectory, + ctx.remixConfig.serverBuildFile, + clientBuildDirectory, + viteConfig, + ctx.remixConfig.basename + ); + } + }, + }, + async buildEnd() { + await viteChildCompiler?.close(); + }, + }, + { + name: "remix-virtual-modules", + enforce: "pre", + resolveId(id) { + if (vmods.includes(id)) return VirtualModule.resolve(id); + }, + async load(id) { + switch (id) { + case VirtualModule.resolve(serverBuildId): { + return await getServerEntry(); + } + case VirtualModule.resolve(serverManifestId): { + let remixManifest = ctx.isSsrBuild + ? await ctx.getRemixServerManifest() + : await getRemixManifestForDev(); + + return `export default ${jsesc(remixManifest, { es6: true })};`; + } + case VirtualModule.resolve(browserManifestId): { + if (viteCommand === "build") { + throw new Error("This module only exists in development"); + } + + let remixManifest = await getRemixManifestForDev(); + let remixManifestString = jsesc(remixManifest, { es6: true }); + + return `window.__remixManifest=${remixManifestString};`; + } + } + }, + }, + { + name: "remix-dot-server", + enforce: "pre", + async resolveId(id, importer, options) { + if (options?.ssr) return; + + let isResolving = options?.custom?.["remix-dot-server"] ?? false; + if (isResolving) return; + options.custom = { ...options.custom, "remix-dot-server": true }; + let resolved = await this.resolve(id, importer, options); + if (!resolved) return; + + let serverFileRE = /\.server(\.[cm]?[jt]sx?)?$/; + let serverDirRE = /\/\.server\//; + let isDotServer = + serverFileRE.test(resolved!.id) || serverDirRE.test(resolved!.id); + if (!isDotServer) return; + + if (!importer) return; + if (viteCommand !== "build" && importer.endsWith(".html")) { + // Vite has a special `index.html` importer for `resolveId` within `transformRequest` + // https://github.com/vitejs/vite/blob/5684fcd8d27110d098b3e1c19d851f44251588f1/packages/vite/src/node/server/transformRequest.ts#L158 + // https://github.com/vitejs/vite/blob/5684fcd8d27110d098b3e1c19d851f44251588f1/packages/vite/src/node/server/pluginContainer.ts#L668 + return; + } + + let vite = importViteEsmSync(); + let importerShort = vite.normalizePath( + path.relative(ctx.rootDirectory, importer) + ); + let isRoute = getRoute(ctx.remixConfig, importer); + + if (isRoute) { + let serverOnlyExports = SERVER_ONLY_ROUTE_EXPORTS.map( + (xport) => `\`${xport}\`` + ).join(", "); + throw Error( + [ + colors.red(`Server-only module referenced by client`), + "", + ` '${id}' imported by route '${importerShort}'`, + "", + ` Remix automatically removes server-code from these exports:`, + ` ${serverOnlyExports}`, + "", + ` But other route exports in '${importerShort}' depend on '${id}'.`, + "", + " See https://remix.run/docs/en/main/future/vite#splitting-up-client-and-server-code", + "", + ].join("\n") + ); + } + + throw Error( + [ + colors.red(`Server-only module referenced by client`), + "", + ` '${id}' imported by '${importerShort}'`, + "", + " See https://remix.run/docs/en/main/future/vite#splitting-up-client-and-server-code", + "", + ].join("\n") + ); + }, + }, + { + name: "remix-dot-client", + async transform(code, id, options) { + if (!options?.ssr) return; + let clientFileRE = /\.client(\.[cm]?[jt]sx?)?$/; + let clientDirRE = /\/\.client\//; + if (clientFileRE.test(id) || clientDirRE.test(id)) { + let exports = esModuleLexer(code)[1]; + return { + code: exports + .map(({ n: name }) => + name === "default" + ? "export default undefined;" + : `export const ${name} = undefined;` + ) + .join("\n"), + map: null, + }; + } + }, + }, + { + name: "remix-route-exports", + async transform(code, id, options) { + if (options?.ssr) return; + + let route = getRoute(ctx.remixConfig, id); + if (!route) return; + + if (!ctx.remixConfig.ssr) { + let serverOnlyExports = esModuleLexer(code)[1] + .map((exp) => exp.n) + .filter((exp) => SERVER_ONLY_ROUTE_EXPORTS.includes(exp)); + if (serverOnlyExports.length > 0) { + let str = serverOnlyExports.map((e) => `\`${e}\``).join(", "); + let message = + `SPA Mode: ${serverOnlyExports.length} invalid route export(s) in ` + + `\`${route.file}\`: ${str}. See https://remix.run/future/spa-mode ` + + `for more information.`; + throw Error(message); + } + + if (route.id !== "root") { + let hasHydrateFallback = esModuleLexer(code)[1] + .map((exp) => exp.n) + .some((exp) => exp === "HydrateFallback"); + if (hasHydrateFallback) { + let message = + `SPA Mode: Invalid \`HydrateFallback\` export found in ` + + `\`${route.file}\`. \`HydrateFallback\` is only permitted on ` + + `the root route in SPA Mode. See https://remix.run/future/spa-mode ` + + `for more information.`; + throw Error(message); + } + } + } + + let [filepath] = id.split("?"); + + return removeExports(code, SERVER_ONLY_ROUTE_EXPORTS, { + sourceMaps: true, + filename: id, + sourceFileName: filepath, + }); + }, + }, + { + name: "remix-inject-hmr-runtime", + enforce: "pre", + resolveId(id) { + if (id === injectHmrRuntimeId) + return VirtualModule.resolve(injectHmrRuntimeId); + }, + async load(id) { + if (id !== VirtualModule.resolve(injectHmrRuntimeId)) return; + + return [ + `import RefreshRuntime from "${hmrRuntimeId}"`, + "RefreshRuntime.injectIntoGlobalHook(window)", + "window.$RefreshReg$ = () => {}", + "window.$RefreshSig$ = () => (type) => type", + "window.__vite_plugin_react_preamble_installed__ = true", + ].join("\n"); + }, + }, + { + name: "remix-hmr-runtime", + enforce: "pre", + resolveId(id) { + if (id === hmrRuntimeId) return VirtualModule.resolve(hmrRuntimeId); + }, + async load(id) { + if (id !== VirtualModule.resolve(hmrRuntimeId)) return; + + let reactRefreshDir = path.dirname( + require.resolve("react-refresh/package.json") + ); + let reactRefreshRuntimePath = path.join( + reactRefreshDir, + "cjs/react-refresh-runtime.development.js" + ); + + return [ + "const exports = {}", + await fse.readFile(reactRefreshRuntimePath, "utf8"), + await fse.readFile( + require.resolve("./static/refresh-utils.cjs"), + "utf8" + ), + "export default exports", + ].join("\n"); + }, + }, + { + name: "remix-react-refresh-babel", + async transform(code, id, options) { + if (viteCommand !== "serve") return; + if (id.includes("/node_modules/")) return; + + let [filepath] = id.split("?"); + let extensionsRE = /\.(jsx?|tsx?|mdx?)$/; + if (!extensionsRE.test(filepath)) return; + + let devRuntime = "react/jsx-dev-runtime"; + let ssr = options?.ssr === true; + let isJSX = filepath.endsWith("x"); + let useFastRefresh = !ssr && (isJSX || code.includes(devRuntime)); + if (!useFastRefresh) return; + + if (isClientRoute(id)) { + return { code: addRefreshWrapper(ctx.remixConfig, code, id) }; + } + + let result = await babel.transformAsync(code, { + filename: id, + sourceFileName: filepath, + parserOpts: { + sourceType: "module", + allowAwaitOutsideFunction: true, + }, + plugins: [[require("react-refresh/babel"), { skipEnvCheck: true }]], + sourceMaps: true, + }); + if (result === null) return; + + code = result.code!; + let refreshContentRE = /\$Refresh(?:Reg|Sig)\$\(/; + if (refreshContentRE.test(code)) { + code = addRefreshWrapper(ctx.remixConfig, code, id); + } + return { code, map: result.map }; + }, + }, + { + name: "remix-hmr-updates", + async handleHotUpdate({ server, file, modules, read }) { + let route = getRoute(ctx.remixConfig, file); + + type ManifestRoute = RemixManifest["routes"][string]; + type HmrEventData = { route: ManifestRoute | null }; + let hmrEventData: HmrEventData = { route: null }; + + if (route) { + // invalidate manifest on route exports change + let serverManifest = (await server.ssrLoadModule(serverManifestId)) + .default as RemixManifest; + + let oldRouteMetadata = serverManifest.routes[route.id]; + let newRouteMetadata = await getRouteMetadata( + ctx, + viteChildCompiler, + route, + read + ); + + hmrEventData.route = newRouteMetadata; + + if ( + !oldRouteMetadata || + ( + [ + "hasLoader", + "hasClientLoader", + "hasAction", + "hasClientAction", + "hasErrorBoundary", + ] as const + ).some((key) => oldRouteMetadata[key] !== newRouteMetadata[key]) + ) { + invalidateVirtualModules(server); + } + } + + server.ws.send({ + type: "custom", + event: "remix:hmr", + data: hmrEventData, + }); + + return modules; + }, + }, + ]; +}; + +function isEqualJson(v1: unknown, v2: unknown) { + return JSON.stringify(v1) === JSON.stringify(v2); +} + +function addRefreshWrapper( + remixConfig: ResolvedVitePluginConfig, + code: string, + id: string +): string { + let route = getRoute(remixConfig, id); + let acceptExports = + route || isClientRoute(id) + ? [ + "clientAction", + "clientLoader", + "handle", + "meta", + "links", + "shouldRevalidate", + ] + : []; + return ( + REACT_REFRESH_HEADER.replaceAll("__SOURCE__", JSON.stringify(id)) + + code + + REACT_REFRESH_FOOTER.replaceAll("__SOURCE__", JSON.stringify(id)) + .replaceAll("__ACCEPT_EXPORTS__", JSON.stringify(acceptExports)) + .replaceAll("__ROUTE_ID__", JSON.stringify(route?.id)) + ); +} + +const REACT_REFRESH_HEADER = ` +import RefreshRuntime from "${hmrRuntimeId}"; + +const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; +let prevRefreshReg; +let prevRefreshSig; + +if (import.meta.hot && !inWebWorker) { + if (!window.__vite_plugin_react_preamble_installed__) { + throw new Error( + "Remix Vite plugin can't detect preamble. Something is wrong." + ); + } + + prevRefreshReg = window.$RefreshReg$; + prevRefreshSig = window.$RefreshSig$; + window.$RefreshReg$ = (type, id) => { + RefreshRuntime.register(type, __SOURCE__ + " " + id) + }; + window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; +}`.replace(/\n+/g, ""); + +const REACT_REFRESH_FOOTER = ` +if (import.meta.hot && !inWebWorker) { + window.$RefreshReg$ = prevRefreshReg; + window.$RefreshSig$ = prevRefreshSig; + RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => { + RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports); + import.meta.hot.accept((nextExports) => { + if (!nextExports) return; + __ROUTE_ID__ && window.__remixRouteModuleUpdates.set(__ROUTE_ID__, nextExports); + const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports, __ACCEPT_EXPORTS__); + if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage); + }); + }); +}`; + +function getRoute( + pluginConfig: ResolvedVitePluginConfig, + file: string +): ConfigRoute | undefined { + let vite = importViteEsmSync(); + let routePath = vite.normalizePath( + path.relative(pluginConfig.appDirectory, file) + ); + let route = Object.values(pluginConfig.routes).find( + (r) => vite.normalizePath(r.file) === routePath + ); + return route; +} + +async function getRouteMetadata( + ctx: RemixPluginContext, + viteChildCompiler: Vite.ViteDevServer | null, + route: ConfigRoute, + readRouteFile?: () => string | Promise +) { + let sourceExports = await getRouteModuleExports( + viteChildCompiler, + ctx, + route.file, + readRouteFile + ); + + let info = { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + url: path.posix.join( + ctx.remixConfig.publicPath, + "/" + + path.relative( + ctx.rootDirectory, + resolveRelativeRouteFilePath(route, ctx.remixConfig) + ) + ), + module: path.posix.join( + ctx.remixConfig.publicPath, + `${resolveFileUrl( + ctx, + resolveRelativeRouteFilePath(route, ctx.remixConfig) + )}?import` + ), // Ensure the Vite dev server responds with a JS module + hasAction: sourceExports.includes("action"), + hasClientAction: sourceExports.includes("clientAction"), + hasLoader: sourceExports.includes("loader"), + hasClientLoader: sourceExports.includes("clientLoader"), + hasErrorBoundary: sourceExports.includes("ErrorBoundary"), + imports: [], + }; + return info; +} + +async function handleSpaMode( + serverBuildDirectoryPath: string, + serverBuildFile: string, + clientBuildDirectory: string, + viteConfig: Vite.ResolvedConfig, + basename: string +) { + // Create a handler and call it for the `/` path - rendering down to the + // proper HydrateFallback ... or not! Maybe they have a static landing page + // generated from routes/_index.tsx. + let serverBuildPath = path.join(serverBuildDirectoryPath, serverBuildFile); + let build = await import(url.pathToFileURL(serverBuildPath).toString()); + let { createRequestHandler: createHandler } = await import("@remix-run/node"); + let handler = createHandler(build, viteConfig.mode); + let response = await handler(new Request(`http://localhost${basename}`)); + let html = await response.text(); + if (response.status !== 200) { + throw new Error( + `SPA Mode: Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while generating the \`index.html\` file.\n${html}` + ); + } + + if ( + !html.includes("window.__remixContext =") || + !html.includes("window.__remixRouteModules =") + ) { + throw new Error( + "SPA Mode: Did you forget to include in your `root.tsx` " + + "`HydrateFallback` component? Your `index.html` file cannot hydrate " + + "into a SPA without ``." + ); + } + + // Write out the index.html file for the SPA + await fse.writeFile(path.join(clientBuildDirectory, "index.html"), html); + + viteConfig.logger.info( + "SPA Mode: index.html has been written to your " + + colors.bold(path.relative(process.cwd(), clientBuildDirectory)) + + " directory" + ); + + // Cleanup - we no longer need the server build assets + fse.removeSync(serverBuildDirectoryPath); +} diff --git a/packages/remix-dev/vite/profiler.ts b/packages/remix-dev/vite/profiler.ts new file mode 100644 index 0000000000..7033d576f4 --- /dev/null +++ b/packages/remix-dev/vite/profiler.ts @@ -0,0 +1,44 @@ +// Adapted from: +// - https://github.com/vitejs/vite/blob/9fc5d9cb3a1b9df067e00959faa9da43ae03f776/packages/vite/bin/vite.js +// - https://github.com/vitejs/vite/blob/9fc5d9cb3a1b9df067e00959faa9da43ae03f776/packages/vite/src/node/cli.ts + +import fs from "node:fs"; +import type { Session } from "node:inspector"; +import path from "node:path"; +import colors from "picocolors"; + +declare namespace global { + let __remix_profile_session: Session | undefined; +} + +export const getSession = () => global.__remix_profile_session; + +export const start = async (callback?: () => void | Promise) => { + let inspector = await import("node:inspector").then((r) => r.default); + let session = (global.__remix_profile_session = new inspector.Session()); + session.connect(); + session.post("Profiler.enable", () => { + session.post("Profiler.start", callback); + }); +}; + +let profileCount = 0; + +export const stop = (log: (message: string) => void): void | Promise => { + let session = getSession(); + if (!session) return; + return new Promise((res, rej) => { + session!.post("Profiler.stop", (err, { profile }) => { + if (err) return rej(err); + let outPath = path.resolve(`./remix-${profileCount++}.cpuprofile`); + fs.writeFileSync(outPath, JSON.stringify(profile)); + log( + colors.yellow( + `CPU profile written to ${colors.white(colors.dim(outPath))}` + ) + ); + global.__remix_profile_session = undefined; + res(); + }); + }); +}; diff --git a/packages/remix-dev/vite/remove-exports-test.ts b/packages/remix-dev/vite/remove-exports-test.ts new file mode 100644 index 0000000000..86af19d2c8 --- /dev/null +++ b/packages/remix-dev/vite/remove-exports-test.ts @@ -0,0 +1,399 @@ +import { removeExports } from "./remove-exports"; + +describe("removeExports", () => { + test("arrow function", () => { + let result = removeExports( + ` + export const serverExport_1 = () => {} + export const serverExport_2 = () => {} + + export const clientExport_1 = () => {} + export const clientExport_2 = () => {} + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "export const clientExport_1 = () => {}; + export const clientExport_2 = () => {};" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("arrow function with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + const sharedUtil = () => sharedLib() + const serverUtil = () => sharedUtil(serverLib(SERVER_STRING)) + const clientUtil = () => sharedUtil(clientLib()) + + export const serverExport_1 = () => serverUtil() + export const serverExport_2 = () => serverUtil() + + export const clientExport_1 = () => clientUtil() + export const clientExport_2 = () => clientUtil() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + const sharedUtil = () => sharedLib(); + const clientUtil = () => sharedUtil(clientLib()); + export const clientExport_1 = () => clientUtil(); + export const clientExport_2 = () => clientUtil();" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("function statement", () => { + let result = removeExports( + ` + export function serverExport_1(){} + export function serverExport_2(){} + + export function clientExport_1(){} + export function clientExport_2(){} + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "export function clientExport_1() {} + export function clientExport_2() {}" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("function statement with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + function sharedUtil() { return sharedLib() } + function serverUtil() { return sharedUtil(serverLib(SERVER_STRING)) } + function clientUtil() { return sharedUtil(clientLib()) } + + export function serverExport_1() { return serverUtil() } + export function serverExport_2() { return serverUtil() } + + export function clientExport_1() { return clientUtil() } + export function clientExport_2() { return clientUtil() } + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + function sharedUtil() { + return sharedLib(); + } + function clientUtil() { + return sharedUtil(clientLib()); + } + export function clientExport_1() { + return clientUtil(); + } + export function clientExport_2() { + return clientUtil(); + }" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("object", () => { + let result = removeExports( + ` + export const serverExport_1 = {} + export const serverExport_2 = {} + + export const clientExport_1 = {} + export const clientExport_2 = {} + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "export const clientExport_1 = {}; + export const clientExport_2 = {};" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("object with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + const sharedUtil = () => sharedLib() + const serverUtil = () => sharedUtil(serverLib(SERVER_STRING)) + const clientUtil = () => sharedUtil(clientLib()) + + export const serverExport_1 = { value: serverUtil() } + export const serverExport_2 = { value: serverUtil() } + + export const clientExport_1 = { value: clientUtil() } + export const clientExport_2 = { value: clientUtil() } + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + const sharedUtil = () => sharedLib(); + const clientUtil = () => sharedUtil(clientLib()); + export const clientExport_1 = { + value: clientUtil() + }; + export const clientExport_2 = { + value: clientUtil() + };" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("function call", () => { + let result = removeExports( + ` + export const serverExport_1 = globalFunction() + export const serverExport_2 = globalFunction() + + export const clientExport_1 = globalFunction() + export const clientExport_2 = globalFunction() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "export const clientExport_1 = globalFunction(); + export const clientExport_2 = globalFunction();" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("function call with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + const sharedUtil = () => sharedLib() + const serverUtil = () => sharedUtil(serverLib(SERVER_STRING)) + const clientUtil = () => sharedUtil(clientLib()) + + export const serverExport_1 = serverUtil() + export const serverExport_2 = serverUtil() + + export const clientExport_1 = clientUtil() + export const clientExport_2 = clientUtil() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + const sharedUtil = () => sharedLib(); + const clientUtil = () => sharedUtil(clientLib()); + export const clientExport_1 = clientUtil(); + export const clientExport_2 = clientUtil();" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("iife", () => { + let result = removeExports( + ` + export const serverExport_1 = (() => {})() + export const serverExport_2 = (() => {})() + + export const clientExport_1 = (() => {})() + export const clientExport_2 = (() => {})() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "export const clientExport_1 = (() => {})(); + export const clientExport_2 = (() => {})();" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("iife with dependencies", () => { + let result = removeExports( + ` + import { serverLib } from 'server-lib' + import { clientLib } from 'client-lib' + import { sharedLib } from 'shared-lib' + + const SERVER_STRING = 'SERVER_STRING' + + const sharedUtil = () => sharedLib() + const serverUtil = () => sharedUtil(serverLib(SERVER_STRING)) + const clientUtil = () => sharedUtil(clientLib()) + + export const serverExport_1 = (() => serverUtil())() + export const serverExport_2 = (() => serverUtil())() + + export const clientExport_1 = (() => clientUtil())() + export const clientExport_2 = (() => clientUtil())() + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "import { clientLib } from 'client-lib'; + import { sharedLib } from 'shared-lib'; + const sharedUtil = () => sharedLib(); + const clientUtil = () => sharedUtil(clientLib()); + export const clientExport_1 = (() => clientUtil())(); + export const clientExport_2 = (() => clientUtil())();" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("re-export", () => { + let result = removeExports( + ` + export { serverExport_1 } from './server/1' + export { serverExport_2 } from './server/2' + + export { clientExport_1 } from './client/1' + export { clientExport_2 } from './client/2' + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "export { clientExport_1 } from './client/1'; + export { clientExport_2 } from './client/2';" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("re-export multiple", () => { + let result = removeExports( + ` + export { serverExport_1, serverExport_2 } from './server' + + export { clientExport_1, clientExport_2 } from './client' + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot( + "\"export { clientExport_1, clientExport_2 } from './client';\"" + ); + expect(result.code).not.toMatch(/server/i); + }); + + test("re-export manual", () => { + let result = removeExports( + ` + import { serverExport_1 } from './server/1' + import { serverExport_2 } from './server/2' + import { clientExport_1 } from './client/1' + import { clientExport_2 } from './client/2' + + export { serverExport_1 } + export { serverExport_2 } + + export { clientExport_1 } + export { clientExport_2 } + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "import { clientExport_1 } from './client/1'; + import { clientExport_2 } from './client/2'; + export { clientExport_1 }; + export { clientExport_2 };" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("number", () => { + let result = removeExports( + ` + export const serverExport_1 = 123 + export const serverExport_2 = 123 + + export const clientExport_1 = 123 + export const clientExport_2 = 123 + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "export const clientExport_1 = 123; + export const clientExport_2 = 123;" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("string", () => { + let result = removeExports( + ` + export const serverExport_1 = 'string' + export const serverExport_2 = 'string' + + export const clientExport_1 = 'string' + export const clientExport_2 = 'string' + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "export const clientExport_1 = 'string'; + export const clientExport_2 = 'string';" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("string reference", () => { + let result = removeExports( + ` + const SERVER_STRING = 'SERVER_STRING'; + const CLIENT_STRING = 'CLIENT_STRING'; + + export const serverExport_1 = SERVER_STRING + export const serverExport_2 = SERVER_STRING + + export const clientExport_1 = CLIENT_STRING + export const clientExport_2 = CLIENT_STRING + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "const CLIENT_STRING = 'CLIENT_STRING'; + export const clientExport_1 = CLIENT_STRING; + export const clientExport_2 = CLIENT_STRING;" + `); + expect(result.code).not.toMatch(/server/i); + }); + + test("null", () => { + let result = removeExports( + ` + export const serverExport_1 = null + export const serverExport_2 = null + + export const clientExport_1 = null + export const clientExport_2 = null + `, + ["serverExport_1", "serverExport_2"] + ); + expect(result.code).toMatchInlineSnapshot(` + "export const clientExport_1 = null; + export const clientExport_2 = null;" + `); + expect(result.code).not.toMatch(/server/i); + }); +}); diff --git a/packages/remix-dev/vite/remove-exports.ts b/packages/remix-dev/vite/remove-exports.ts new file mode 100644 index 0000000000..298d2cac86 --- /dev/null +++ b/packages/remix-dev/vite/remove-exports.ts @@ -0,0 +1,368 @@ +// Adapted from https://github.com/egoist/babel-plugin-eliminator/blob/d29859396b7708b7f7abbacdd951cbbc80902f00/src/index.ts +// Which was originally adapted from https://github.com/vercel/next.js/blob/574fe0b582d5cc1b13663121fd47a3d82deaaa17/packages/next/build/babel/plugins/next-ssg-transform.ts +import type { GeneratorOptions } from "@babel/generator"; + +import { + type BabelTypes, + type NodePath, + parse, + traverse, + generate, + t, +} from "./babel"; + +function getIdentifier( + path: NodePath< + | BabelTypes.FunctionDeclaration + | BabelTypes.FunctionExpression + | BabelTypes.ArrowFunctionExpression + > +): NodePath | null { + let parentPath = path.parentPath; + if (parentPath.type === "VariableDeclarator") { + let variablePath = parentPath as NodePath; + let name = variablePath.get("id"); + return name.node.type === "Identifier" + ? (name as NodePath) + : null; + } + + if (parentPath.type === "AssignmentExpression") { + let variablePath = parentPath as NodePath; + let name = variablePath.get("left"); + return name.node.type === "Identifier" + ? (name as NodePath) + : null; + } + + if (path.node.type === "ArrowFunctionExpression") { + return null; + } + + return path.node.id && path.node.id.type === "Identifier" + ? (path.get("id") as NodePath) + : null; +} + +function isIdentifierReferenced( + ident: NodePath +): boolean { + let binding = ident.scope.getBinding(ident.node.name); + if (binding?.referenced) { + // Functions can reference themselves, so we need to check if there's a + // binding outside the function scope or not. + if (binding.path.type === "FunctionDeclaration") { + return !binding.constantViolations + .concat(binding.referencePaths) + // Check that every reference is contained within the function: + .every((ref) => ref.findParent((parent) => parent === binding?.path)); + } + + return true; + } + return false; +} + +export const removeExports = ( + source: string, + exportsToRemove: string[], + generateOptions: GeneratorOptions = {} +) => { + let document = parse(source, { sourceType: "module" }); + + let referencedIdentifiers = new Set>(); + let removedExports = new Set(); + + let markImport = ( + path: NodePath< + | BabelTypes.ImportSpecifier + | BabelTypes.ImportDefaultSpecifier + | BabelTypes.ImportNamespaceSpecifier + > + ) => { + let local = path.get("local"); + if (isIdentifierReferenced(local)) { + referencedIdentifiers.add(local); + } + }; + + let markFunction = ( + path: NodePath< + | BabelTypes.FunctionDeclaration + | BabelTypes.FunctionExpression + | BabelTypes.ArrowFunctionExpression + > + ) => { + let identifier = getIdentifier(path); + if (identifier?.node && isIdentifierReferenced(identifier)) { + referencedIdentifiers.add(identifier); + } + }; + + traverse(document, { + VariableDeclarator(variablePath) { + if (variablePath.node.id.type === "Identifier") { + let local = variablePath.get("id") as NodePath; + if (isIdentifierReferenced(local)) { + referencedIdentifiers.add(local); + } + } else if (variablePath.node.id.type === "ObjectPattern") { + let pattern = variablePath.get( + "id" + ) as NodePath; + + let properties = pattern.get("properties"); + properties.forEach((p) => { + let local = p.get( + p.node.type === "ObjectProperty" + ? "value" + : p.node.type === "RestElement" + ? "argument" + : (function () { + throw new Error("invariant"); + })() + ) as NodePath; + if (isIdentifierReferenced(local)) { + referencedIdentifiers.add(local); + } + }); + } else if (variablePath.node.id.type === "ArrayPattern") { + let pattern = variablePath.get( + "id" + ) as NodePath; + + let elements = pattern.get("elements"); + elements.forEach((element) => { + let local: NodePath; + if (element.node?.type === "Identifier") { + local = element as NodePath; + } else if (element.node?.type === "RestElement") { + local = element.get("argument") as NodePath; + } else { + return; + } + + if (isIdentifierReferenced(local)) { + referencedIdentifiers.add(local); + } + }); + } + }, + + FunctionDeclaration: markFunction, + FunctionExpression: markFunction, + ArrowFunctionExpression: markFunction, + ImportSpecifier: markImport, + ImportDefaultSpecifier: markImport, + ImportNamespaceSpecifier: markImport, + + ExportNamedDeclaration(path) { + let shouldRemove = false; + + // Handle re-exports: export { preload } from './foo' + path.node.specifiers = path.node.specifiers.filter((spec) => { + if (spec.exported.type !== "Identifier") { + return true; + } + + let { name } = spec.exported; + for (let namedExport of exportsToRemove) { + if (name === namedExport) { + removedExports.add(namedExport); + return false; + } + } + + return true; + }); + + let { declaration } = path.node; + + // When no re-exports are left, remove the path + if (!declaration && path.node.specifiers.length === 0) { + shouldRemove = true; + } + + if (declaration && declaration.type === "VariableDeclaration") { + declaration.declarations = declaration.declarations.filter( + (declarator: BabelTypes.VariableDeclarator) => { + for (let name of exportsToRemove) { + if ((declarator.id as BabelTypes.Identifier).name === name) { + removedExports.add(name); + return false; + } + } + return true; + } + ); + if (declaration.declarations.length === 0) { + shouldRemove = true; + } + } + + if (declaration && declaration.type === "FunctionDeclaration") { + for (let name of exportsToRemove) { + if (declaration.id?.name === name) { + shouldRemove = true; + removedExports.add(name); + } + } + } + + if (shouldRemove) { + path.remove(); + } + }, + }); + + if (removedExports.size === 0) { + // No server-specific exports found so there's + // no need to remove unused references + return generate(document, generateOptions); + } + + let referencesRemovedInThisPass: number; + + let sweepFunction = ( + path: NodePath< + | BabelTypes.FunctionDeclaration + | BabelTypes.FunctionExpression + | BabelTypes.ArrowFunctionExpression + > + ) => { + let identifier = getIdentifier(path); + if ( + identifier?.node && + referencedIdentifiers.has(identifier) && + !isIdentifierReferenced(identifier) + ) { + ++referencesRemovedInThisPass; + + if ( + t.isAssignmentExpression(path.parentPath.node) || + t.isVariableDeclarator(path.parentPath.node) + ) { + path.parentPath.remove(); + } else { + path.remove(); + } + } + }; + + let sweepImport = ( + path: NodePath< + | BabelTypes.ImportSpecifier + | BabelTypes.ImportDefaultSpecifier + | BabelTypes.ImportNamespaceSpecifier + > + ) => { + let local = path.get("local"); + if (referencedIdentifiers.has(local) && !isIdentifierReferenced(local)) { + ++referencesRemovedInThisPass; + path.remove(); + if ( + (path.parent as BabelTypes.ImportDeclaration).specifiers.length === 0 + ) { + path.parentPath.remove(); + } + } + }; + + // Traverse again to remove unused references. This happens at least once, + // then repeats until no more references are removed. + do { + referencesRemovedInThisPass = 0; + + traverse(document, { + Program(path) { + path.scope.crawl(); + }, + // eslint-disable-next-line no-loop-func + VariableDeclarator(variablePath) { + if (variablePath.node.id.type === "Identifier") { + let local = variablePath.get("id") as NodePath; + if ( + referencedIdentifiers.has(local) && + !isIdentifierReferenced(local) + ) { + ++referencesRemovedInThisPass; + variablePath.remove(); + } + } else if (variablePath.node.id.type === "ObjectPattern") { + let pattern = variablePath.get( + "id" + ) as NodePath; + + let beforeCount = referencesRemovedInThisPass; + let properties = pattern.get("properties"); + properties.forEach((property) => { + let local = property.get( + property.node.type === "ObjectProperty" + ? "value" + : property.node.type === "RestElement" + ? "argument" + : (function () { + throw new Error("invariant"); + })() + ) as NodePath; + + if ( + referencedIdentifiers.has(local) && + !isIdentifierReferenced(local) + ) { + ++referencesRemovedInThisPass; + property.remove(); + } + }); + + if ( + beforeCount !== referencesRemovedInThisPass && + pattern.get("properties").length < 1 + ) { + variablePath.remove(); + } + } else if (variablePath.node.id.type === "ArrayPattern") { + let pattern = variablePath.get( + "id" + ) as NodePath; + + let beforeCount = referencesRemovedInThisPass; + let elements = pattern.get("elements"); + elements.forEach((e) => { + let local: NodePath; + if (e.node?.type === "Identifier") { + local = e as NodePath; + } else if (e.node?.type === "RestElement") { + local = e.get("argument") as NodePath; + } else { + return; + } + + if ( + referencedIdentifiers.has(local) && + !isIdentifierReferenced(local) + ) { + ++referencesRemovedInThisPass; + e.remove(); + } + }); + + if ( + beforeCount !== referencesRemovedInThisPass && + pattern.get("elements").length < 1 + ) { + variablePath.remove(); + } + } + }, + FunctionDeclaration: sweepFunction, + FunctionExpression: sweepFunction, + ArrowFunctionExpression: sweepFunction, + ImportSpecifier: sweepImport, + ImportDefaultSpecifier: sweepImport, + ImportNamespaceSpecifier: sweepImport, + }); + } while (referencesRemovedInThisPass); + + return generate(document, generateOptions); +}; diff --git a/packages/remix-dev/vite/resolve-file-url.ts b/packages/remix-dev/vite/resolve-file-url.ts new file mode 100644 index 0000000000..37482705f5 --- /dev/null +++ b/packages/remix-dev/vite/resolve-file-url.ts @@ -0,0 +1,22 @@ +import * as path from "node:path"; + +import { importViteEsmSync } from "./import-vite-esm-sync"; + +export const resolveFileUrl = ( + { rootDirectory }: { rootDirectory: string }, + filePath: string +) => { + let vite = importViteEsmSync(); + let relativePath = path.relative(rootDirectory, filePath); + let isWithinRoot = + !relativePath.startsWith("..") && !path.isAbsolute(relativePath); + + if (!isWithinRoot) { + // Vite will prevent serving files outside of the workspace + // unless user explictly opts in with `server.fs.allow` + // https://vitejs.dev/config/server-options.html#server-fs-allow + return path.posix.join("/@fs", vite.normalizePath(filePath)); + } + + return "/" + vite.normalizePath(relativePath); +}; diff --git a/packages/remix-dev/vite/static/refresh-utils.cjs b/packages/remix-dev/vite/static/refresh-utils.cjs new file mode 100644 index 0000000000..a2891438a0 --- /dev/null +++ b/packages/remix-dev/vite/static/refresh-utils.cjs @@ -0,0 +1,183 @@ +// adapted from https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/src/refreshUtils.js +// This file gets injected into the browser as a part of the HMR runtime + +function debounce(fn, delay) { + let handle; + return () => { + clearTimeout(handle); + handle = setTimeout(fn, delay); + }; +} + +/* eslint-disable no-undef */ +const enqueueUpdate = debounce(async () => { + let manifest; + if (routeUpdates.size > 0) { + manifest = JSON.parse(JSON.stringify(__remixManifest)); + + for (let route of routeUpdates.values()) { + manifest.routes[route.id] = route; + let imported = window.__remixRouteModuleUpdates.get(route.id); + if (!imported) { + throw Error(`[remix:hmr] No module update found for route ${route.id}`); + } + let routeModule = { + ...imported, + // react-refresh takes care of updating these in-place, + // if we don't preserve existing values we'll loose state. + default: imported.default + ? window.__remixRouteModules[route.id]?.default ?? imported.default + : imported.default, + ErrorBoundary: imported.ErrorBoundary + ? window.__remixRouteModules[route.id]?.ErrorBoundary ?? + imported.ErrorBoundary + : imported.ErrorBoundary, + HydrateFallback: imported.HydrateFallback + ? window.__remixRouteModules[route.id]?.HydrateFallback ?? + imported.HydrateFallback + : imported.HydrateFallback, + }; + window.__remixRouteModules[route.id] = routeModule; + } + + let needsRevalidation = new Set( + Array.from(routeUpdates.values()) + .filter((route) => route.hasLoader || route.hasClientLoader) + .map((route) => route.id) + ); + + let routes = __remixRouter.createRoutesForHMR( + needsRevalidation, + manifest.routes, + window.__remixRouteModules, + window.__remixContext.future, + window.__remixContext.isSpaMode + ); + __remixRouter._internalSetRoutes(routes); + routeUpdates.clear(); + window.__remixRouteModuleUpdates.clear(); + } + + await revalidate(); + if (manifest) { + Object.assign(window.__remixManifest, manifest); + } + exports.performReactRefresh(); +}, 16); + +// Taken from https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/lib/runtime/RefreshUtils.js#L141 +// This allows to resister components not detected by SWC like styled component +function registerExportsForReactRefresh(filename, moduleExports) { + for (let key in moduleExports) { + if (key === "__esModule") continue; + let exportValue = moduleExports[key]; + if (exports.isLikelyComponentType(exportValue)) { + // 'export' is required to avoid key collision when renamed exports that + // shadow a local component name: https://github.com/vitejs/vite-plugin-react/issues/116 + // The register function has an identity check to not register twice the same component, + // so this is safe to not used the same key here. + exports.register(exportValue, filename + " export " + key); + } + } +} + +function validateRefreshBoundaryAndEnqueueUpdate( + prevExports, + nextExports, + // non-component exports that are handled by the framework (e.g. `meta` and `links` for route modules) + acceptExports = [] +) { + if ( + !predicateOnExport( + prevExports, + (key) => key in nextExports || acceptExports.includes(key) + ) + ) { + return "Could not Fast Refresh (export removed)"; + } + if ( + !predicateOnExport( + nextExports, + (key) => key in prevExports || acceptExports.includes(key) + ) + ) { + return "Could not Fast Refresh (new export)"; + } + + let hasExports = false; + let allExportsAreHandledOrUnchanged = predicateOnExport( + nextExports, + (key, value) => { + hasExports = true; + // Remix can handle Remix-specific exports (e.g. `meta` and `links`) + if (acceptExports.includes(key)) return true; + // React Fast Refresh can handle component exports + if (exports.isLikelyComponentType(value)) return true; + // Unchanged exports are implicitly handled + return prevExports[key] === nextExports[key]; + } + ); + if (hasExports && allExportsAreHandledOrUnchanged) { + enqueueUpdate(); + } else { + return "Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports"; + } +} + +function predicateOnExport(moduleExports, predicate) { + for (let key in moduleExports) { + if (key === "__esModule") continue; + let desc = Object.getOwnPropertyDescriptor(moduleExports, key); + if (desc && desc.get) return false; + if (!predicate(key, moduleExports[key])) return false; + } + return true; +} + +// Hides vite-ignored dynamic import so that Vite can skip analysis if no other +// dynamic import is present (https://github.com/vitejs/vite/pull/12732) +function __hmr_import(module) { + return import(/* @vite-ignore */ module); +} + +const routeUpdates = new Map(); +window.__remixRouteModuleUpdates = new Map(); + +async function revalidate() { + let { promise, resolve } = channel(); + let unsub = __remixRouter.subscribe((state) => { + if (state.revalidation === "idle") { + unsub(); + // Ensure RouterProvider setState has flushed before re-rendering + resolve(); + } + }); + window.__remixRevalidation = (window.__remixRevalidation || 0) + 1; + __remixRouter.revalidate(); + return promise; +} + +function channel() { + let resolve; + let reject; + + let promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return { promise, resolve, reject }; +} + +import.meta.hot.on("remix:hmr", async ({ route }) => { + window.__remixClearCriticalCss(); + + if (route) { + routeUpdates.set(route.id, route); + } +}); + +exports.__hmr_import = __hmr_import; +exports.registerExportsForReactRefresh = registerExportsForReactRefresh; +exports.validateRefreshBoundaryAndEnqueueUpdate = + validateRefreshBoundaryAndEnqueueUpdate; +exports.enqueueUpdate = enqueueUpdate; diff --git a/packages/remix-dev/vite/styles.ts b/packages/remix-dev/vite/styles.ts new file mode 100644 index 0000000000..9996afa1b4 --- /dev/null +++ b/packages/remix-dev/vite/styles.ts @@ -0,0 +1,217 @@ +import * as path from "node:path"; +import { type ServerBuild } from "@remix-run/server-runtime"; +import { matchRoutes } from "@remix-run/router"; +import { type ModuleNode, type ViteDevServer } from "vite"; + +import { type RemixConfig as ResolvedRemixConfig } from "../config"; +import { resolveFileUrl } from "./resolve-file-url"; + +type ServerRouteManifest = ServerBuild["routes"]; +type ServerRoute = ServerRouteManifest[string]; + +// Style collection logic adapted from solid-start: https://github.com/solidjs/solid-start + +// Vite doesn't expose these so we just copy the list for now +// https://github.com/vitejs/vite/blob/d6bde8b03d433778aaed62afc2be0630c8131908/packages/vite/src/node/constants.ts#L49C23-L50 +const cssFileRegExp = + /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/; +// https://github.com/vitejs/vite/blob/d6bde8b03d433778aaed62afc2be0630c8131908/packages/vite/src/node/plugins/css.ts#L160 +const cssModulesRegExp = new RegExp(`\\.module${cssFileRegExp.source}`); + +const isCssFile = (file: string) => cssFileRegExp.test(file); +export const isCssModulesFile = (file: string) => cssModulesRegExp.test(file); + +const getStylesForFiles = async ({ + viteDevServer, + rootDirectory, + cssModulesManifest, + files, +}: { + viteDevServer: ViteDevServer; + rootDirectory: string; + cssModulesManifest: Record; + files: string[]; +}): Promise => { + let styles: Record = {}; + let deps = new Set(); + + try { + for (let file of files) { + let normalizedPath = path + .resolve(rootDirectory, file) + .replace(/\\/g, "/"); + let node = await viteDevServer.moduleGraph.getModuleById(normalizedPath); + + // If the module is only present in the client module graph, the module + // won't have been found on the first request to the server. If so, we + // request the module so it's in the module graph, then try again. + if (!node) { + try { + await viteDevServer.transformRequest( + resolveFileUrl({ rootDirectory }, normalizedPath) + ); + } catch (err) { + console.error(err); + } + node = await viteDevServer.moduleGraph.getModuleById(normalizedPath); + } + + if (!node) { + console.log(`Could not resolve module for file: ${file}`); + continue; + } + + await findDeps(viteDevServer, node, deps); + } + } catch (err) { + console.error(err); + } + + for (let dep of deps) { + if ( + dep.file && + isCssFile(dep.file) && + !dep.url.endsWith("?url") // Ignore styles that resolved as URLs, otherwise we'll end up injecting URLs into the style tag contents + ) { + try { + let css = isCssModulesFile(dep.file) + ? cssModulesManifest[dep.file] + : (await viteDevServer.ssrLoadModule(dep.url)).default; + + if (css === undefined) { + throw new Error(); + } + + styles[dep.url] = css; + } catch { + console.warn(`Could not load ${dep.file}`); + // this can happen with dynamically imported modules, I think + // because the Vite module graph doesn't distinguish between + // static and dynamic imports? TODO investigate, submit fix + } + } + } + + return ( + Object.entries(styles) + .map(([fileName, css], i) => [ + `\n/* ${fileName + // Escape comment syntax in file paths + .replace(/\/\*/g, "/\\*") + .replace(/\*\//g, "*\\/")} */`, + css, + ]) + .flat() + .join("\n") || undefined + ); +}; + +const findDeps = async ( + vite: ViteDevServer, + node: ModuleNode, + deps: Set +) => { + // since `ssrTransformResult.deps` contains URLs instead of `ModuleNode`s, this process is asynchronous. + // instead of using `await`, we resolve all branches in parallel. + let branches: Promise[] = []; + + async function addFromNode(node: ModuleNode) { + if (!deps.has(node)) { + deps.add(node); + await findDeps(vite, node, deps); + } + } + + async function addFromUrl(url: string) { + let node = await vite.moduleGraph.getModuleByUrl(url); + + if (node) { + await addFromNode(node); + } + } + + if (node.ssrTransformResult) { + if (node.ssrTransformResult.deps) { + node.ssrTransformResult.deps.forEach((url) => + branches.push(addFromUrl(url)) + ); + } + } else { + node.importedModules.forEach((node) => branches.push(addFromNode(node))); + } + + await Promise.all(branches); +}; + +const groupRoutesByParentId = (manifest: ServerRouteManifest) => { + let routes: Record[]> = {}; + + Object.values(manifest).forEach((route) => { + let parentId = route.parentId || ""; + if (!routes[parentId]) { + routes[parentId] = []; + } + routes[parentId].push(route); + }); + + return routes; +}; + +// Create a map of routes by parentId to use recursively instead of +// repeatedly filtering the manifest. +const createRoutes = ( + manifest: ServerRouteManifest, + parentId: string = "", + routesByParentId: Record< + string, + Omit[] + > = groupRoutesByParentId(manifest) +): ServerRoute[] => { + return (routesByParentId[parentId] || []).map((route) => ({ + ...route, + children: createRoutes(manifest, route.id, routesByParentId), + })); +}; + +export const getStylesForUrl = async ({ + viteDevServer, + rootDirectory, + remixConfig, + entryClientFilePath, + cssModulesManifest, + build, + url, +}: { + viteDevServer: ViteDevServer; + rootDirectory: string; + remixConfig: Pick; + entryClientFilePath: string; + cssModulesManifest: Record; + build: ServerBuild; + url: string | undefined; +}): Promise => { + if (url === undefined || url.includes("?_data=")) { + return undefined; + } + + let routes = createRoutes(build.routes); + let appPath = path.relative(process.cwd(), remixConfig.appDirectory); + let documentRouteFiles = + matchRoutes(routes, url, build.basename)?.map((match) => + path.join(appPath, remixConfig.routes[match.route.id].file) + ) ?? []; + + let styles = await getStylesForFiles({ + viteDevServer, + rootDirectory, + cssModulesManifest, + files: [ + // Always include the client entry file when crawling the module graph for CSS + path.relative(rootDirectory, entryClientFilePath), + // Then include any styles from the matched routes + ...documentRouteFiles, + ], + }); + + return styles; +}; diff --git a/packages/remix-dev/vite/vmod.ts b/packages/remix-dev/vite/vmod.ts new file mode 100644 index 0000000000..5005069122 --- /dev/null +++ b/packages/remix-dev/vite/vmod.ts @@ -0,0 +1,3 @@ +export let id = (name: string) => `virtual:remix/${name}`; +export let resolve = (id: string) => `\0${id}`; +export let url = (id: string) => `/@id/__x00__${id}`; diff --git a/packages/remix-express/CHANGELOG.md b/packages/remix-express/CHANGELOG.md new file mode 100644 index 0000000000..14206febef --- /dev/null +++ b/packages/remix-express/CHANGELOG.md @@ -0,0 +1,422 @@ +# `@remix-run/express` + +## 2.9.0-pre.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.9.0-pre.0` + +## 2.8.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.8.1` + +## 2.8.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.8.0` + +## 2.7.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.7.2` + +## 2.7.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.7.1` + +## 2.7.0 + +### Minor Changes + +- Vite: Add a new `basename` option to the Vite plugin, allowing users to set the internal React Router [`basename`](https://reactrouter.com/en/main/routers/create-browser-router#basename) in order to to serve their applications underneath a subpath ([#8145](https://github.com/remix-run/remix/pull/8145)) + +### Patch Changes + +- Use `req.originalUrl` instead of `req.url` so that Remix sees the full URL ([#8145](https://github.com/remix-run/remix/pull/8145)) + + - Remix relies on the knowing the full URL to ensure that server and client code can function together, and does not support URL rewriting prior to the Remix handler + +- Updated dependencies: + - `@remix-run/node@2.7.0` + +## 2.6.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.6.0` + +## 2.5.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.5.1` + +## 2.5.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.5.0` + +## 2.4.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.4.1` + +## 2.4.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.4.0` + +## 2.3.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.3.1` + +## 2.3.0 + +### Patch Changes + +- Fix flash of unstyled content on initial page load in Vite dev when using a custom Express server ([#7937](https://github.com/remix-run/remix/pull/7937)) +- Updated dependencies: + - `@remix-run/node@2.3.0` + +## 2.2.0 + +### Patch Changes + +- Allow the `@remix-run/express` adapter to work behind a proxy when using `app.enable('trust proxy')` ([#7323](https://github.com/remix-run/remix/pull/7323)) + - Previously, this used `req.get('host')` to construct the Remix `Request`, but that does not respect `X-Forwarded-Host` + - This now uses `req.hostname` which will respect `X-Forwarded-Host` +- Updated dependencies: + - `@remix-run/node@2.2.0` + +## 2.1.0 + +### Patch Changes + +- Flush headers for `text/event-stream` responses ([#7619](https://github.com/remix-run/remix/pull/7619)) +- Updated dependencies: + - `@remix-run/node@2.1.0` + +## 2.0.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.0.1` + +## 2.0.0 + +### Major Changes + +- Require Node >=18.0.0 ([#6939](https://github.com/remix-run/remix/pull/6939)) + +- For preparation of using Node's built in fetch implementation, installing the fetch globals is now a responsibility of the app server ([#7009](https://github.com/remix-run/remix/pull/7009)) + + - If you are using `remix-serve`, nothing is required + - If you are using your own app server, you will need to install the globals yourself + + ```js filename=server.js + import { installGlobals } from "@remix-run/node"; + + installGlobals(); + ``` + +- `source-map-support` is now a responsibility of the app server ([#7009](https://github.com/remix-run/remix/pull/7009)) + + - If you are using `remix-serve`, nothing is required + - If you are using your own app server, you will need to install [`source-map-support`](https://www.npmjs.com/package/source-map-support) yourself. + + ```sh + npm i source-map-support + ``` + + ```js filename=server.js + import sourceMapSupport from "source-map-support"; + sourceMapSupport.install(); + ``` + +### Patch Changes + +- Switch to `headers.entries()` instead of non-spec-compliant `headers.raw()` in `sendRemixResponse` ([#7150](https://github.com/remix-run/remix/pull/7150)) +- Remove references to fetch polyfills in node and arc adapters ([#7230](https://github.com/remix-run/remix/pull/7230)) +- Updated dependencies: + - `@remix-run/node@2.0.0` + - `@remix-run/web-fetch@4.4.0` + - `@remix-run/web-file@3.1.0` + - `@remix-run/web-stream@1.1.0` + +## 1.19.3 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.19.3` + +## 1.19.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.19.2` + +## 1.19.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.19.1` + +## 1.19.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.19.0` + +## 1.18.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.18.1` + +## 1.18.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.18.0` + +## 1.17.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.17.1` + +## 1.17.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.17.0` + +## 1.16.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.16.1` + +## 1.16.0 + +### Patch Changes + +- feat: support async `getLoadContext` in all adapters ([#6170](https://github.com/remix-run/remix/pull/6170)) +- Updated dependencies: + - `@remix-run/node@1.16.0` + +## 1.15.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.15.0` + +## 1.14.3 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.14.3` + +## 1.14.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.14.2` + +## 1.14.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.14.1` + +## 1.14.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.14.0` + +## 1.13.0 + +### Patch Changes + +- Fix fetch `Request` creation for incoming URLs with double slashes ([#5336](https://github.com/remix-run/remix/pull/5336)) +- Updated dependencies: + - `@remix-run/node@1.13.0` + +## 1.12.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.12.0` + +## 1.11.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.11.1` + +## 1.11.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.11.0` + +## 1.10.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.10.1` + +## 1.10.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.10.0` + +## 1.9.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.9.0` + +## 1.8.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.8.2` + +## 1.8.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.8.1` + +## 1.8.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.8.0` + +## 1.7.6 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.7.6` + +## 1.7.5 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.7.5` + +## 1.7.4 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.7.4` + +## 1.7.3 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.7.3` + +## 1.7.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.7.2` + +## 1.7.1 + +### Patch Changes + +- Ensured that requests are properly aborted on closing of a `Response` instead of `Request` ([#3626](https://github.com/remix-run/remix/pull/3626)) +- Updated dependencies: + - `@remix-run/node@1.7.1` + +## 1.7.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.7.0` + +## 1.6.8 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.6.8` + +## 1.6.7 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.6.7` + +## 1.6.6 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.6.6` + +## 1.6.5 + +### Patch Changes + +- Updated dependencies + - `@remix-run/node@1.6.5` diff --git a/packages/remix-express/README.md b/packages/remix-express/README.md new file mode 100644 index 0000000000..40685a7476 --- /dev/null +++ b/packages/remix-express/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/remix-express/__tests__/server-test.ts b/packages/remix-express/__tests__/server-test.ts new file mode 100644 index 0000000000..0646d033af --- /dev/null +++ b/packages/remix-express/__tests__/server-test.ts @@ -0,0 +1,222 @@ +import { Readable } from "node:stream"; +import { + createReadableStreamFromReadable, + createRequestHandler as createRemixRequestHandler, +} from "@remix-run/node"; +import express from "express"; +import { createRequest, createResponse } from "node-mocks-http"; +import supertest from "supertest"; + +import { + createRemixHeaders, + createRemixRequest, + createRequestHandler, +} from "../server"; + +// We don't want to test that the remix server works here (that's what the +// playwright tests do), we just want to test the express adapter +jest.mock("@remix-run/node", () => { + let original = jest.requireActual("@remix-run/node"); + return { + ...original, + createRequestHandler: jest.fn(), + }; +}); +let mockedCreateRequestHandler = + createRemixRequestHandler as jest.MockedFunction< + typeof createRemixRequestHandler + >; + +function createApp() { + let app = express(); + + app.all( + "*", + // We don't have a real app to test, but it doesn't matter. We won't ever + // call through to the real createRequestHandler + // @ts-expect-error + createRequestHandler({ build: {} }) + ); + + return app; +} + +describe("express createRequestHandler", () => { + describe("basic requests", () => { + afterEach(() => { + mockedCreateRequestHandler.mockReset(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("handles requests", async () => { + mockedCreateRequestHandler.mockImplementation(() => async (req) => { + return new Response(`URL: ${new URL(req.url).pathname}`); + }); + + let request = supertest(createApp()); + let res = await request.get("/foo/bar"); + + expect(res.status).toBe(200); + expect(res.text).toBe("URL: /foo/bar"); + expect(res.headers["x-powered-by"]).toBe("Express"); + }); + + it("handles root // URLs", async () => { + mockedCreateRequestHandler.mockImplementation(() => async (req) => { + return new Response("URL: " + new URL(req.url).pathname); + }); + + let request = supertest(createApp()); + let res = await request.get("//"); + + expect(res.status).toBe(200); + expect(res.text).toBe("URL: //"); + }); + + it("handles nested // URLs", async () => { + mockedCreateRequestHandler.mockImplementation(() => async (req) => { + return new Response("URL: " + new URL(req.url).pathname); + }); + + let request = supertest(createApp()); + let res = await request.get("//foo//bar"); + + expect(res.status).toBe(200); + expect(res.text).toBe("URL: //foo//bar"); + }); + + it("handles null body", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + return new Response(null, { status: 200 }); + }); + + let request = supertest(createApp()); + let res = await request.get("/"); + + expect(res.status).toBe(200); + }); + + // https://github.com/node-fetch/node-fetch/blob/4ae35388b078bddda238277142bf091898ce6fda/test/response.js#L142-L148 + it("handles body as stream", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + let readable = Readable.from("hello world"); + let stream = createReadableStreamFromReadable(readable); + return new Response(stream, { status: 200 }); + }); + + let request = supertest(createApp()); + let res = await request.get("/"); + expect(res.status).toBe(200); + expect(res.text).toBe("hello world"); + }); + + it("handles status codes", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + return new Response(null, { status: 204 }); + }); + + let request = supertest(createApp()); + let res = await request.get("/"); + + expect(res.status).toBe(204); + }); + + it("sets headers", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + let headers = new Headers({ "X-Time-Of-Year": "most wonderful" }); + headers.append( + "Set-Cookie", + "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + headers.append( + "Set-Cookie", + "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + headers.append( + "Set-Cookie", + "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + return new Response(null, { headers }); + }); + + let request = supertest(createApp()); + let res = await request.get("/"); + + expect(res.headers["x-time-of-year"]).toBe("most wonderful"); + expect(res.headers["set-cookie"]).toEqual([ + "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax", + "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax", + "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax", + ]); + }); + }); +}); + +describe("express createRemixHeaders", () => { + describe("creates fetch headers from express headers", () => { + it("handles empty headers", () => { + let headers = createRemixHeaders({}); + expect(Object.fromEntries(headers.entries())).toMatchInlineSnapshot(`{}`); + }); + + it("handles simple headers", () => { + let headers = createRemixHeaders({ "x-foo": "bar" }); + expect(headers.get("x-foo")).toBe("bar"); + }); + + it("handles multiple headers", () => { + let headers = createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" }); + expect(headers.get("x-foo")).toBe("bar"); + expect(headers.get("x-bar")).toBe("baz"); + }); + + it("handles headers with multiple values", () => { + let headers = createRemixHeaders({ + "x-foo": ["bar", "baz"], + "x-bar": "baz", + }); + expect(headers.get("x-foo")).toEqual("bar, baz"); + expect(headers.get("x-bar")).toBe("baz"); + }); + + it("handles multiple set-cookie headers", () => { + let headers = createRemixHeaders({ + "set-cookie": [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", + ], + }); + expect(headers.getSetCookie()).toEqual([ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", + ]); + }); + }); +}); + +describe("express createRemixRequest", () => { + it("creates a request with the correct headers", async () => { + let expressRequest = createRequest({ + url: "/foo/bar", + method: "GET", + protocol: "http", + hostname: "localhost", + headers: { + "Cache-Control": "max-age=300, s-maxage=3600", + Host: "localhost:3000", + }, + }); + let expressResponse = createResponse(); + + let remixRequest = createRemixRequest(expressRequest, expressResponse); + + expect(remixRequest.method).toBe("GET"); + expect(remixRequest.headers.get("cache-control")).toBe( + "max-age=300, s-maxage=3600" + ); + expect(remixRequest.headers.get("host")).toBe("localhost:3000"); + }); +}); diff --git a/packages/remix-express/__tests__/setup.ts b/packages/remix-express/__tests__/setup.ts new file mode 100644 index 0000000000..917305ac93 --- /dev/null +++ b/packages/remix-express/__tests__/setup.ts @@ -0,0 +1,2 @@ +import { installGlobals } from "@remix-run/node"; +installGlobals(); diff --git a/packages/remix-express/index.ts b/packages/remix-express/index.ts new file mode 100644 index 0000000000..8d8383f2cf --- /dev/null +++ b/packages/remix-express/index.ts @@ -0,0 +1,2 @@ +export type { GetLoadContextFunction, RequestHandler } from "./server"; +export { createRequestHandler } from "./server"; diff --git a/packages/remix-express/jest.config.js b/packages/remix-express/jest.config.js new file mode 100644 index 0000000000..0587714c3b --- /dev/null +++ b/packages/remix-express/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "express", +}; diff --git a/packages/remix-express/package.json b/packages/remix-express/package.json new file mode 100644 index 0000000000..fb5d49bcd4 --- /dev/null +++ b/packages/remix-express/package.json @@ -0,0 +1,49 @@ +{ + "name": "@remix-run/express", + "version": "2.9.0-pre.0", + "description": "Express server request handler for Remix", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-express" + }, + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "@remix-run/node": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.9", + "@types/node": "^18.17.1", + "@types/supertest": "^2.0.10", + "express": "^4.17.1", + "node-mocks-http": "^1.10.1", + "supertest": "^6.3.3", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "express": "^4.17.1", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/remix-express/rollup.config.js b/packages/remix-express/rollup.config.js new file mode 100644 index 0000000000..fdb2d76f79 --- /dev/null +++ b/packages/remix-express/rollup.config.js @@ -0,0 +1,6 @@ +const { getAdapterConfig } = require("../../rollup.utils"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + return [getAdapterConfig("express")]; +}; diff --git a/packages/remix-express/server.ts b/packages/remix-express/server.ts new file mode 100644 index 0000000000..3c0d25bc40 --- /dev/null +++ b/packages/remix-express/server.ts @@ -0,0 +1,138 @@ +// IDK why this is needed when it's in the tsconfig.......... +// YAY PROJECT REFERENCES! +/// + +import type * as express from "express"; +import type { AppLoadContext, ServerBuild } from "@remix-run/node"; +import { + createRequestHandler as createRemixRequestHandler, + createReadableStreamFromReadable, + writeReadableStreamToWritable, +} from "@remix-run/node"; + +/** + * A function that returns the value to use as `context` in route `loader` and + * `action` functions. + * + * You can think of this as an escape hatch that allows you to pass + * environment/platform-specific values through to your loader/action, such as + * values that are generated by Express middleware like `req.session`. + */ +export type GetLoadContextFunction = ( + req: express.Request, + res: express.Response +) => Promise | AppLoadContext; + +export type RequestHandler = ( + req: express.Request, + res: express.Response, + next: express.NextFunction +) => Promise; + +/** + * Returns a request handler for Express that serves the response using Remix. + */ +export function createRequestHandler({ + build, + getLoadContext, + mode = process.env.NODE_ENV, +}: { + build: ServerBuild | (() => Promise); + getLoadContext?: GetLoadContextFunction; + mode?: string; +}): RequestHandler { + let handleRequest = createRemixRequestHandler(build, mode); + + return async ( + req: express.Request, + res: express.Response, + next: express.NextFunction + ) => { + try { + let request = createRemixRequest(req, res); + let loadContext = await getLoadContext?.(req, res); + + let response = await handleRequest(request, loadContext); + + await sendRemixResponse(res, response); + } catch (error: unknown) { + // Express doesn't support async functions, so we have to pass along the + // error manually using next(). + next(error); + } + }; +} + +export function createRemixHeaders( + requestHeaders: express.Request["headers"] +): Headers { + let headers = new Headers(); + + for (let [key, values] of Object.entries(requestHeaders)) { + if (values) { + if (Array.isArray(values)) { + for (let value of values) { + headers.append(key, value); + } + } else { + headers.set(key, values); + } + } + } + + return headers; +} + +export function createRemixRequest( + req: express.Request, + res: express.Response +): Request { + // req.hostname doesn't include port information so grab that from + // `X-Forwarded-Host` or `Host` + let [, hostnamePort] = req.get("X-Forwarded-Host")?.split(":") ?? []; + let [, hostPort] = req.get("host")?.split(":") ?? []; + let port = hostnamePort || hostPort; + // Use req.hostname here as it respects the "trust proxy" setting + let resolvedHost = `${req.hostname}${port ? `:${port}` : ""}`; + // Use `req.originalUrl` so Remix is aware of the full path + let url = new URL(`${req.protocol}://${resolvedHost}${req.originalUrl}`); + + // Abort action/loaders once we can no longer write a response + let controller = new AbortController(); + res.on("close", () => controller.abort()); + + let init: RequestInit = { + method: req.method, + headers: createRemixHeaders(req.headers), + signal: controller.signal, + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = createReadableStreamFromReadable(req); + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url.href, init); +} + +export async function sendRemixResponse( + res: express.Response, + nodeResponse: Response +): Promise { + res.statusMessage = nodeResponse.statusText; + res.status(nodeResponse.status); + + for (let [key, value] of nodeResponse.headers.entries()) { + res.append(key, value); + } + + if (nodeResponse.headers.get("Content-Type")?.match(/text\/event-stream/i)) { + res.flushHeaders(); + } + + if (nodeResponse.body) { + await writeReadableStreamToWritable(nodeResponse.body, res); + } else { + res.end(); + } +} diff --git a/packages/remix-express/tsconfig.json b/packages/remix-express/tsconfig.json new file mode 100644 index 0000000000..8b705bba97 --- /dev/null +++ b/packages/remix-express/tsconfig.json @@ -0,0 +1,17 @@ +{ + "include": ["**/*.ts"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "../../build/node_modules/@remix-run/express/dist" + } +} diff --git a/packages/remix-node/.gitignore b/packages/remix-node/.gitignore new file mode 100644 index 0000000000..79406fcb64 --- /dev/null +++ b/packages/remix-node/.gitignore @@ -0,0 +1,2 @@ +# TODO: Remove in v2 +globals.d.ts \ No newline at end of file diff --git a/packages/remix-node/CHANGELOG.md b/packages/remix-node/CHANGELOG.md new file mode 100644 index 0000000000..311817f1c8 --- /dev/null +++ b/packages/remix-node/CHANGELOG.md @@ -0,0 +1,578 @@ +# `@remix-run/node` + +## 2.9.0-pre.0 + +### Minor Changes + +- Use undici as our fetch polyfill going forward. #9106 ([#9111](https://github.com/remix-run/remix/pull/9111)) + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.9.0-pre.0` + +## 2.8.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.8.1` + +## 2.8.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.8.0` + +## 2.7.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.7.2` + +## 2.7.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.7.1` + +## 2.7.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.7.0` + +## 2.6.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.6.0` + +## 2.5.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.5.1` + +## 2.5.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.5.0` + +## 2.4.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.4.1` + +## 2.4.0 + +### Minor Changes + +- Deprecate `DataFunctionArgs` in favor of `LoaderFunctionArgs`/`ActionFunctionArgs`. This is aimed at keeping the types aligned across server/client loaders/actions now that `clientLoader`/`clientActon` functions have `serverLoader`/`serverAction` parameters which differentiate `ClientLoaderFunctionArgs`/`ClientActionFunctionArgs`. ([#8173](https://github.com/remix-run/remix/pull/8173)) + +### Patch Changes + +- Update to `@remix-run/web-fetch@4.4.2` ([#8231](https://github.com/remix-run/remix/pull/8231)) +- Updated dependencies: + - `@remix-run/server-runtime@2.4.0` + +## 2.3.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.3.1` + +## 2.3.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.3.0` + +## 2.2.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.2.0` + +## 2.1.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@2.1.0` + +## 2.0.1 + +### Patch Changes + +- Switch from `crypto.randomBytes` to `crypto.webcrypto.getRandomValues` for file session storage ID generation ([#7203](https://github.com/remix-run/remix/pull/7203)) +- Use native `Blob` class instead of polyfill ([#7217](https://github.com/remix-run/remix/pull/7217)) +- Updated dependencies: + - `@remix-run/server-runtime@2.0.1` + - [`@remix-run/web-fetch@4.4.1`](https://github.com/remix-run/web-std-io/releases/tag/%40remix-run%2Fweb-fetch%404.4.1) + +## 2.0.0 + +### Major Changes + +- Require Node >=18.0.0 ([#6939](https://github.com/remix-run/remix/pull/6939)) + +- Stop exporting the `fetch` API in favor of using the version in the global scope - which can be polyfilled via `installGlobals` ([#7293](https://github.com/remix-run/remix/pull/7293)) + +- Removed/adjusted types to prefer `unknown` over `any` and to align with underlying React Router types ([#7319](https://github.com/remix-run/remix/pull/7319), [#7354](https://github.com/remix-run/remix/pull/7354)): + + - Renamed the `useMatches()` return type from `RouteMatch` to `UIMatch` + - Renamed `LoaderArgs`/`ActionArgs` to `LoaderFunctionArgs`/`ActionFunctionArgs` + - `AppData` changed from `any` to `unknown` + - `Location["state"]` (`useLocation.state`) changed from `any` to `unknown` + - `UIMatch["data"]` (`useMatches()[i].data`) changed from `any` to `unknown` + - `UIMatch["handle"]` (`useMatches()[i].handle`) changed from `{ [k: string]: any }` to `unknown` + - `Fetcher["data"]` (`useFetcher().data`) changed from `any` to `unknown` + - `MetaMatch.handle` (used in `meta()`) changed from `any` to `unknown` + - `AppData`/`RouteHandle` are no longer exported as they are just aliases for `unknown` + +- The route `meta` API now defaults to the new "V2 Meta" API ([#6958](https://github.com/remix-run/remix/pull/6958)) + + - Please refer to the ([docs](https://remix.run/docs/en/2.0.0/route/meta) and [Preparing for V2](https://remix.run/docs/en/2.0.0/start/v2#route-meta) guide for more information. + +- For preparation of using Node's built in fetch implementation, installing the fetch globals is now a responsibility of the app server ([#7009](https://github.com/remix-run/remix/pull/7009)) + + - If you are using `remix-serve`, nothing is required + - If you are using your own app server, you will need to install the globals yourself + + ```js filename=server.js + import { installGlobals } from "@remix-run/node"; + + installGlobals(); + ``` + +- `source-map-support` is now a responsibility of the app server ([#7009](https://github.com/remix-run/remix/pull/7009)) + + - If you are using `remix-serve`, nothing is required + - If you are using your own app server, you will need to install [`source-map-support`](https://www.npmjs.com/package/source-map-support) yourself. + + ```sh + npm i source-map-support + ``` + + ```js filename=server.js + import sourceMapSupport from "source-map-support"; + sourceMapSupport.install(); + ``` + +- Removed support for "magic exports" from the `remix` package. This package can be removed from your `package.json` and you should update all imports to use the source `@remix-run/*` packages: ([#6895](https://github.com/remix-run/remix/pull/6895)) + + ```diff + - import type { ActionArgs } from "remix"; + - import { json, useLoaderData } from "remix"; + + import type { ActionArgs } from "@remix-run/node"; + + import { json } from "@remix-run/node"; + + import { useLoaderData } from "@remix-run/react"; + ``` + +### Minor Changes + +- Re-export the new `redirectDocument` method from React Router ([#7040](https://github.com/remix-run/remix/pull/7040), [#6842](https://github.com/remix-run/remix/pull/6842)) ([#7040](https://github.com/remix-run/remix/pull/7040)) + +### Patch Changes + +- Remove `atob`/`btoa` polyfills in favor of built-in versions ([#7206](https://github.com/remix-run/remix/pull/7206)) +- Export proper `ErrorResponse` type for usage alongside `isRouteErrorResponse` ([#7244](https://github.com/remix-run/remix/pull/7244)) +- Add the rest of the Web Streams API to `installGlobals` ([#7321](https://github.com/remix-run/remix/pull/7321)) +- Ensures `fetch()` return is `instanceof global Response` by removing extended classes for `NodeRequest` and `NodeResponse` in favor of custom interface type cast ([#7109](https://github.com/remix-run/remix/pull/7109)) +- Remove recursion from stream utilities ([#7245](https://github.com/remix-run/remix/pull/7245)) +- Updated dependencies: + - `@remix-run/server-runtime@2.0.0` + - `@remix-run/web-fetch@4.4.0` + - `@remix-run/web-file@3.1.0` + - `@remix-run/web-stream@1.1.0` + +## 1.19.3 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.19.3` + +## 1.19.2 + +### Patch Changes + +- Update to latest `@remix-run/web-*` packages ([#7026](https://github.com/remix-run/remix/pull/7026)) +- Updated dependencies: + - `@remix-run/server-runtime@1.19.2` + +## 1.19.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.19.1` + +## 1.19.0 + +### Patch Changes + +- Upgrade to [`@remix-run/web-fetch@4.3.5`](https://github.com/remix-run/web-std-io/releases/tag/%40remix-run%2Fweb-fetch%404.3.5). Submitted empty file inputs are now correctly parsed out as empty `File` instances instead of being surfaced as an empty string via `request.formData()` ([#6816](https://github.com/remix-run/remix/pull/6816)) +- Updated dependencies: + - `@remix-run/server-runtime@1.19.0` + +## 1.18.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.18.1` + +## 1.18.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.18.0` + +## 1.17.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.17.1` + +## 1.17.0 + +### Patch Changes + +- Add `HeadersArgs` type to be consistent with loaders/actions/meta and allows for using a `function` declaration in addition to an arrow function expression ([#6247](https://github.com/remix-run/remix/pull/6247)) + + ```tsx + import type { HeadersArgs } from "@remix-run/node"; // or cloudflare/deno + + export function headers({ loaderHeaders }: HeadersArgs) { + return { + "x-my-custom-thing": loaderHeaders.get("x-my-custom-thing") || "fallback", + }; + } + ``` + +- Fix `request.clone() instanceof Request` returning false. ([#6512](https://github.com/remix-run/remix/pull/6512)) + +- Updated dependencies: + - `@remix-run/server-runtime@1.17.0` + +## 1.16.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/server-runtime@1.16.1` + +## 1.16.0 + +### Patch Changes + +- add `@remix-run/node/install` side-effect to allow `node --require @remix-run/node/install` ([#6132](https://github.com/remix-run/remix/pull/6132)) +- add `logDevReady` as replacement for platforms that can't initialize async I/O outside of the request response lifecycle. ([#6204](https://github.com/remix-run/remix/pull/6204)) +- add missing files to published package ([#6179](https://github.com/remix-run/remix/pull/6179)) +- Updated dependencies: + - `@remix-run/server-runtime@1.16.0` + +## 1.15.0 + +### Minor Changes + +- We have made a few changes to the API for route module `meta` functions when using the `future.v2_meta` flag. **These changes are _only_ breaking for users who have opted in.** ([#5746](https://github.com/remix-run/remix/pull/5746)) + + - `V2_HtmlMetaDescriptor` has been renamed to `V2_MetaDescriptor` + - The `meta` function's arguments have been simplified + - `parentsData` has been removed, as each route's loader data is available on the `data` property of its respective `match` object + ```tsx + // before + export function meta({ parentsData }) { + return [{ title: parentsData["routes/some-route"].title }]; + } + // after + export function meta({ matches }) { + return [ + { + title: matches.find((match) => match.id === "routes/some-route") + .data.title, + }, + ]; + } + ``` + - The `route` property on route matches has been removed, as relevant match data is attached directly to the match object + ```tsx + // before + export function meta({ matches }) { + const rootModule = matches.find((match) => match.route.id === "root"); + } + // after + export function meta({ matches }) { + const rootModule = matches.find((match) => match.id === "root"); + } + ``` + - Added support for generating `" }; + expect(escapeHtml(JSON.stringify(evilObj))).toBe( + '{"evil":"\\u003cscript\\u003e\\u003c/script\\u003e"}' + ); + }); + + test("with angle brackets should parse back", () => { + let evilObj = { evil: "" }; + expect(JSON.parse(escapeHtml(JSON.stringify(evilObj)))).toMatchObject( + evilObj + ); + }); + + test("with ampersands should escape", () => { + let evilObj = { evil: "&" }; + expect(escapeHtml(JSON.stringify(evilObj))).toBe('{"evil":"\\u0026"}'); + }); + + test("with ampersands should parse back", () => { + let evilObj = { evil: "&" }; + expect(JSON.parse(escapeHtml(JSON.stringify(evilObj)))).toMatchObject( + evilObj + ); + }); + + test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should escape', () => { + let evilObj = { evil: "\u2028\u2029" }; + expect(escapeHtml(JSON.stringify(evilObj))).toBe( + '{"evil":"\\u2028\\u2029"}' + ); + }); + + test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should parse back', () => { + let evilObj = { evil: "\u2028\u2029" }; + expect(JSON.parse(escapeHtml(JSON.stringify(evilObj)))).toMatchObject( + evilObj + ); + }); + + test("escaped line terminators should work", () => { + expect(() => { + vm.runInNewContext( + "(" + escapeHtml(JSON.stringify({ evil: "\u2028\u2029" })) + ")" + ); + }).not.toThrow(); + }); +}); diff --git a/packages/remix-server-runtime/__tests__/responses-test.ts b/packages/remix-server-runtime/__tests__/responses-test.ts new file mode 100644 index 0000000000..0506d28c96 --- /dev/null +++ b/packages/remix-server-runtime/__tests__/responses-test.ts @@ -0,0 +1,96 @@ +import type { TypedResponse } from "../index"; +import { json, redirect } from "../index"; +import { isEqual } from "./utils"; + +describe("json", () => { + it("sets the Content-Type header", () => { + let response = json({}); + expect(response.headers.get("Content-Type")).toEqual( + "application/json; charset=utf-8" + ); + }); + + it("preserves existing headers, including Content-Type", () => { + let response = json( + {}, + { + headers: { + "Content-Type": "application/json; charset=iso-8859-1", + "X-Remix": "is awesome", + }, + } + ); + + expect(response.headers.get("Content-Type")).toEqual( + "application/json; charset=iso-8859-1" + ); + expect(response.headers.get("X-Remix")).toEqual("is awesome"); + }); + + it("encodes the response body", async () => { + let response = json({ hello: "remix" }); + expect(await response.json()).toEqual({ hello: "remix" }); + }); + + it("accepts status as a second parameter", () => { + let response = json({}, 201); + expect(response.status).toEqual(201); + }); + + it("infers input type", async () => { + let response = json({ hello: "remix" }); + isEqual>(true); + let result = await response.json(); + expect(result).toMatchObject({ hello: "remix" }); + }); + + it("disallows unmatched typed responses", async () => { + let response = json("hello"); + isEqual, typeof response>(false); + }); + + it("disallows unserializables", () => { + // @ts-expect-error + expect(() => json(124n)).toThrow(); + // @ts-expect-error + expect(() => json({ field: 124n })).toThrow(); + }); +}); + +describe("redirect", () => { + it("sets the status to 302 by default", () => { + let response = redirect("/login"); + expect(response.status).toEqual(302); + }); + + it("sets the status to 302 when only headers are given", () => { + let response = redirect("/login", { + headers: { + "X-Remix": "is awesome", + }, + }); + expect(response.status).toEqual(302); + }); + + it("sets the Location header", () => { + let response = redirect("/login"); + expect(response.headers.get("Location")).toEqual("/login"); + }); + + it("preserves existing headers, but not Location", () => { + let response = redirect("/login", { + headers: { + Location: "/", + "X-Remix": "is awesome", + }, + }); + + expect(response.headers.get("Location")).toEqual("/login"); + expect(response.headers.get("X-Remix")).toEqual("is awesome"); + }); + + it("accepts status as a second parameter", () => { + let response = redirect("/profile", 301); + expect(response.status).toEqual(301); + }); +}); diff --git a/packages/remix-server-runtime/__tests__/serialize-test.ts b/packages/remix-server-runtime/__tests__/serialize-test.ts new file mode 100644 index 0000000000..7b5a38b35d --- /dev/null +++ b/packages/remix-server-runtime/__tests__/serialize-test.ts @@ -0,0 +1,54 @@ +import type { SerializeFrom } from "../index"; +import { defer, json } from "../index"; +import { isEqual } from "./utils"; + +it("infers basic types", () => { + isEqual< + SerializeFrom<{ + hello?: string; + count: number | undefined; + date: Date | number; + isActive: boolean; + items: { name: string; price: number; orderedAt: Date }[]; + }>, + { + hello?: string; + count?: number; + date: string | number; + isActive: boolean; + items: { name: string; price: number; orderedAt: string }[]; + } + >(true); +}); + +it("infers deferred types", () => { + let get = (): Promise | undefined => { + if (Math.random() > 0.5) return Promise.resolve(new Date()); + return undefined; + }; + let loader = async () => + defer({ + critical: await Promise.resolve("hello"), + deferred: get(), + }); + isEqual< + SerializeFrom, + { + critical: string; + deferred: Promise | undefined; + } + >(true); +}); + +it("infers types from json", () => { + let loader = () => json({ data: "remix" }); + isEqual, { data: string }>(true); + + let asyncLoader = async () => json({ data: "remix" }); + isEqual, { data: string }>(true); +}); + +it("infers type from defer", () => { + let loader = async () => defer({ data: "remix" }); + isEqual, { data: string }>(true); +}); diff --git a/packages/remix-server-runtime/__tests__/server-test.ts b/packages/remix-server-runtime/__tests__/server-test.ts new file mode 100644 index 0000000000..1195e4fdaf --- /dev/null +++ b/packages/remix-server-runtime/__tests__/server-test.ts @@ -0,0 +1,2208 @@ +import type { StaticHandlerContext } from "@remix-run/router"; + +import { createRequestHandler } from ".."; +import { ServerMode } from "../mode"; +import type { ServerBuild } from "../build"; +import { mockServerBuild } from "./utils"; + +function spyConsole() { + // https://github.com/facebook/react/issues/7047 + let spy: any = {}; + + beforeAll(() => { + spy.console = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterAll(() => { + spy.console.mockRestore(); + }); + + return spy; +} + +describe("server", () => { + let routeId = "root"; + let build: ServerBuild = { + entry: { + module: { + default: async (request) => { + return new Response(`${request.method}, ${request.url} COMPONENT`); + }, + }, + }, + routes: { + [routeId]: { + id: routeId, + path: "", + module: { + action: ({ request }) => + new Response(`${request.method} ${request.url} ACTION`), + loader: ({ request }) => + new Response(`${request.method} ${request.url} LOADER`), + default: () => "COMPONENT", + }, + }, + }, + assets: { + routes: { + [routeId]: { + hasAction: true, + hasErrorBoundary: false, + hasLoader: true, + id: routeId, + module: routeId, + path: "", + }, + }, + }, + future: {}, + } as unknown as ServerBuild; + + describe("createRequestHandler", () => { + let spy = spyConsole(); + + beforeEach(() => { + spy.console.mockClear(); + }); + + let allowThrough = [ + ["GET", "/"], + ["GET", "/?_data=root"], + ["POST", "/"], + ["POST", "/?_data=root"], + ["PUT", "/"], + ["PUT", "/?_data=root"], + ["DELETE", "/"], + ["DELETE", "/?_data=root"], + ["PATCH", "/"], + ["PATCH", "/?_data=root"], + ]; + it.each(allowThrough)( + `allows through %s request to %s`, + async (method, to) => { + let handler = createRequestHandler(build); + let response = await handler( + new Request(`http://localhost:3000${to}`, { + method, + }) + ); + + expect(response.status).toBe(200); + let text = await response.text(); + expect(text).toContain(method); + let expected = !to.includes("?_data=root") + ? "COMPONENT" + : method === "GET" + ? "LOADER" + : "ACTION"; + expect(text).toContain(expected); + expect(spy.console).not.toHaveBeenCalled(); + } + ); + + it("strips body for HEAD requests", async () => { + let handler = createRequestHandler(build); + let response = await handler( + new Request("http://localhost:3000/", { + method: "HEAD", + }) + ); + + expect(await response.text()).toBe(""); + }); + }); +}); + +describe("shared server runtime", () => { + let spy = spyConsole(); + + beforeEach(() => { + spy.console.mockClear(); + }); + + let baseUrl = "http://test.com"; + + describe("resource routes", () => { + test("calls resource route loader", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(() => { + return "resource"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("resource"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(resourceLoader.mock.calls.length).toBe(1); + }); + + test("calls sub resource route loader", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(() => { + return "resource"; + }); + let subResourceLoader = jest.fn(() => { + return "sub"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + "routes/resource.sub": { + loader: subResourceLoader, + path: "resource/sub", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource/sub`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("sub"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(resourceLoader.mock.calls.length).toBe(0); + expect(subResourceLoader.mock.calls.length).toBe(1); + }); + + test("resource route loader allows thrown responses", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(() => { + throw new Response("resource"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.text()).toBe("resource"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(resourceLoader.mock.calls.length).toBe(1); + }); + + test("resource route loader responds with generic error when thrown", async () => { + let error = new Error("should be logged when resource loader throws"); + let loader = jest.fn(() => { + throw error; + }); + let build = mockServerBuild({ + "routes/resource": { + loader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect(await result.text()).toBe( + "Unexpected Server Error\n\nError: should be logged when resource loader throws" + ); + }); + + test("resource route loader responds with detailed error when thrown in development", async () => { + let error = new Error("should be logged when resource loader throws"); + let loader = jest.fn(() => { + throw error; + }); + let build = mockServerBuild({ + "routes/resource": { + loader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect((await result.text()).includes(error.message)).toBe(true); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("calls resource route action", async () => { + let rootAction = jest.fn(() => { + return "root"; + }); + let resourceAction = jest.fn(() => { + return "resource"; + }); + let build = mockServerBuild({ + root: { + default: {}, + action: rootAction, + }, + "routes/resource": { + action: resourceAction, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("resource"); + expect(rootAction.mock.calls.length).toBe(0); + expect(resourceAction.mock.calls.length).toBe(1); + }); + + test("calls sub resource route action", async () => { + let rootAction = jest.fn(() => { + return "root"; + }); + let resourceAction = jest.fn(() => { + return "resource"; + }); + let subResourceAction = jest.fn(() => { + return "sub"; + }); + let build = mockServerBuild({ + root: { + default: {}, + action: rootAction, + }, + "routes/resource": { + action: resourceAction, + path: "resource", + }, + "routes/resource.sub": { + action: subResourceAction, + path: "resource/sub", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource/sub`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("sub"); + expect(rootAction.mock.calls.length).toBe(0); + expect(resourceAction.mock.calls.length).toBe(0); + expect(subResourceAction.mock.calls.length).toBe(1); + }); + + test("resource route action allows thrown responses", async () => { + let rootAction = jest.fn(() => { + return "root"; + }); + let resourceAction = jest.fn(() => { + throw new Response("resource"); + }); + let build = mockServerBuild({ + root: { + default: {}, + action: rootAction, + }, + "routes/resource": { + action: resourceAction, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.text()).toBe("resource"); + expect(rootAction.mock.calls.length).toBe(0); + expect(resourceAction.mock.calls.length).toBe(1); + }); + + test("resource route action responds with generic error when thrown", async () => { + let error = new Error("should be logged when resource loader throws"); + let action = jest.fn(() => { + throw error; + }); + let build = mockServerBuild({ + "routes/resource": { + action, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect(await result.text()).toBe( + "Unexpected Server Error\n\nError: should be logged when resource loader throws" + ); + }); + + test("resource route action responds with detailed error when thrown in development", async () => { + let message = "should be logged when resource loader throws"; + let action = jest.fn(() => { + throw new Error(message); + }); + let build = mockServerBuild({ + "routes/resource": { + action, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect((await result.text()).includes(message)).toBe(true); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("aborts request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "resource"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/resource`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(await result.text()).toMatchInlineSnapshot(` + "Unexpected Server Error + + Error: queryRoute() call aborted: GET http://test.com/resource" + `); + }); + + test("aborts request (v3_throwAbortReason)", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "resource"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }, + { + future: { + v3_throwAbortReason: true, + }, + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/resource`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(await result.text()).toMatchInlineSnapshot(` + "Unexpected Server Error + + AbortError: This operation was aborted" + `); + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof DOMException).toBe( + true + ); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("AbortError"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "This operation was aborted" + ); + expect(handleErrorSpy.mock.calls[0][1].request.method).toBe("GET"); + expect(handleErrorSpy.mock.calls[0][1].request.url).toBe( + "http://test.com/resource" + ); + }); + }); + + describe("data requests", () => { + test("data request that does not match loader surfaces 400 error for boundary", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect((await result.json()).message).toBeTruthy(); + }); + + test("data request that does not match routeId surfaces 403 error for boundary", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + loader: () => null, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + // This bug wasn't that the router wasn't returning a 404 (it was), but + // that we weren't defensive when looking at match.params when we went + // to call handleDataRequest(), - and that threw it's own uncaught + // exception triggering a 500. We need to ensure that this build has a + // handleDataRequest implementation for this test to mean anything + expect(build.entry.module.handleDataRequest).toBeDefined(); + + let request = new Request(`${baseUrl}/?_data=routes/junk`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(403); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect((await result.json()).message).toBeTruthy(); + }); + + test("data request that does not match route surfaces 404 error for boundary", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + loader: () => null, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/junk?_data=routes/junk`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect((await result.json()).message).toBeTruthy(); + }); + + test("data request calls loader", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("index"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(1); + }); + + test("data request calls loader and responds with generic message and error header", async () => { + let rootLoader = jest.fn(() => { + throw new Error("test"); + }); + let testAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=root`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe("Unexpected Server Error"); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testAction.mock.calls.length).toBe(0); + }); + + test("data request calls loader and responds with detailed info and error header in development mode", async () => { + let message = + "data request loader error logged to console once in dev mode"; + let rootLoader = jest.fn(() => { + throw new Error(message); + }); + let testAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/test?_data=root`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe(message); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testAction.mock.calls.length).toBe(0); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("data request calls loader and responds with catch header", async () => { + let rootLoader = jest.fn(() => { + throw new Response("test", { status: 400 }); + }); + let testAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=root`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(await result.text()).toBe("test"); + expect(result.headers.get("X-Remix-Catch")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testAction.mock.calls.length).toBe(0); + }); + + test("data request calls action", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("test"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + }); + + test("data request calls action and responds with generic message and error header", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error("test"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe("Unexpected Server Error"); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + }); + + test("data request calls action and responds with detailed info and error header in development mode", async () => { + let message = + "data request action error logged to console once in dev mode"; + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error(message); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe(message); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("data request calls action and responds with catch header", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Response("test", { status: 400 }); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(await result.text()).toBe("test"); + expect(result.headers.get("X-Remix-Catch")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + }); + + test("data request calls layout action", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let rootAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + action: rootAction, + }, + "routes/_index": { + parentId: "root", + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=root`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("root"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(rootAction.mock.calls.length).toBe(1); + }); + + test("data request calls index action", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + action: indexAction, + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index&_data=routes/_index`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("index"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexAction.mock.calls.length).toBe(1); + }); + + test("data request handleDataRequest redirects are handled", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }); + build.entry.module.handleDataRequest.mockImplementation(async () => { + return new Response(null, { + status: 302, + headers: { + Location: "/redirect", + }, + }); + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(204); + expect(result.headers.get("X-Remix-Redirect")).toBe("/redirect"); + expect(result.headers.get("X-Remix-Status")).toBe("302"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(1); + }); + + test("aborts request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(await result.text()).toMatchInlineSnapshot( + `"{"message":"Unexpected Server Error"}"` + ); + }); + + test("aborts request (v3_throwAbortReason)", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }, + { + future: { + v3_throwAbortReason: true, + }, + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + let error = await result.json(); + expect(error.message).toBe("This operation was aborted"); + expect( + error.stack.startsWith("AbortError: This operation was aborted") + ).toBe(true); + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof DOMException).toBe( + true + ); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("AbortError"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "This operation was aborted" + ); + expect(handleErrorSpy.mock.calls[0][1].request.method).toBe("GET"); + expect(handleErrorSpy.mock.calls[0][1].request.url).toBe( + "http://test.com/?_data=routes/_index" + ); + }); + }); + + describe("document requests", () => { + test("not found document request for no matches and no ErrorBoundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(rootLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(404); + }); + + test("sets root as catch boundary for not found document request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(rootLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(404); + expect(context.loaderData).toEqual({}); + }); + + test("thrown loader responses bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("thrown loader responses catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"].status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("thrown action responses bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(testAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(400); + expect(context.loaderData).toEqual({ + root: null, + "routes/test": null, + }); + }); + + test("thrown action responses bubble up for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(indexAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(400); + expect(context.loaderData).toEqual({ + root: null, + "routes/_index": null, + }); + }); + + test("thrown action responses catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/test"].status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + "routes/test": null, + }); + }); + + test("thrown action responses catch deep for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"].status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + "routes/_index": null, + }); + }); + + test("thrown loader response after thrown action response bubble up action throw to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Response("layout", { status: 401 }); + }); + let testAction = jest.fn(() => { + throw new Response("action", { status: 400 }); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/test": { + parentId: "routes/__layout", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"].data).toBe("action"); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/test": null, + }); + }); + + test("thrown loader response after thrown index action response bubble up action throw to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Response("layout", { status: 401 }); + }); + let indexAction = jest.fn(() => { + throw new Response("action", { status: 400 }); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/index": { + parentId: "routes/__layout", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"].data).toBe("action"); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/index": null, + }); + }); + + test("loader errors bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Error("index"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("loader errors catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Error("index"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"]).toBeInstanceOf(Error); + expect(context.errors!["routes/_index"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/_index"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("action errors bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error("test"); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(testAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: null, + "routes/test": null, + }); + }); + + test("action errors bubble up for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Error("index"); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(indexAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: null, + "routes/_index": null, + }); + }); + + test("action errors catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error("test"); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/test"]).toBeInstanceOf(Error); + expect(context.errors!["routes/test"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/test"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/test": null, + }); + }); + + test("action errors catch deep for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Error("index"); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"]).toBeInstanceOf(Error); + expect(context.errors!["routes/_index"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/_index"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/_index": null, + }); + }); + + test("loader errors after action error bubble up action error to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Error("layout"); + }); + let testAction = jest.fn(() => { + throw new Error("action"); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/test": { + parentId: "routes/__layout", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error); + expect(context.errors!["routes/__layout"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/__layout"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/test": null, + }); + }); + + test("loader errors after index action error bubble up action error to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Error("layout"); + }); + let indexAction = jest.fn(() => { + throw new Error("action"); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/index": { + parentId: "routes/__layout", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error); + expect(context.errors!["routes/__layout"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/__layout"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/index": null, + }); + }); + + test("calls handleDocumentRequest again with new error when handleDocumentRequest throws", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let calledBefore = false; + let ogHandleDocumentRequest = build.entry.module.default; + build.entry.module.default = jest.fn(function () { + if (!calledBefore) { + throw new Error("thrown"); + } + calledBefore = true; + return ogHandleDocumentRequest.call(null, ...arguments); + }) as any; + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/404`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(2); + let context = calls[1][3].staticHandlerContext; + expect(context.errors.root).toBeTruthy(); + expect(context.errors!.root.message).toBe("thrown"); + expect(context.loaderData).toEqual({}); + }); + + test("unwraps responses thrown from handleDocumentRequest", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let ogHandleDocumentRequest = build.entry.module.default; + build.entry.module.default = function ( + _: Request, + responseStatusCode: number + ) { + if (responseStatusCode === 200) { + throw new Response("Uh oh!", { + status: 400, + statusText: "Bad Request", + }); + } + return ogHandleDocumentRequest.call(null, ...arguments); + } as any; + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(400); + }); + + test("returns generic message if handleDocumentRequest throws a second time", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + default: {}, + loader: indexLoader, + }, + }); + let lastThrownError; + build.entry.module.default = jest.fn(function () { + lastThrownError = new Error("rofl"); + throw lastThrownError; + }) as any; + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(await result.text()).toBe( + "Unexpected Server Error\n\nError: rofl" + ); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(2); + }); + + test("returns more detailed message if handleDocumentRequest throws a second time in development mode", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + path: "/", + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let errorMessage = + "thrown from handleDocumentRequest and expected to be logged in console only once"; + let lastThrownError; + build.entry.module.default = jest.fn(function () { + lastThrownError = new Error(errorMessage); + errorMessage = "second error thrown from handleDocumentRequest"; + throw lastThrownError; + }) as any; + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/`); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.text()).includes(errorMessage)).toBe(true); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(2); + expect(spy.console.mock.calls).toEqual([ + [ + new Error( + "thrown from handleDocumentRequest and expected to be logged in console only once" + ), + ], + [new Error("second error thrown from handleDocumentRequest")], + ]); + }); + + test("aborts request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "index"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default() {}, + loader: rootLoader, + }, + "routes/_index": { + loader: indexLoader, + index: true, + default: {}, + }, + }, + { + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(build.entry.module.default.mock.calls.length).toBe(0); + + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof Error).toBe(true); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("Error"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "query() call aborted: GET http://test.com/" + ); + }); + + test("aborts request (v3_throwAbortReason)", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "index"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: indexLoader, + index: true, + default: {}, + }, + }, + { + future: { + v3_throwAbortReason: true, + }, + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(build.entry.module.default.mock.calls.length).toBe(0); + + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof DOMException).toBe( + true + ); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("AbortError"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "This operation was aborted" + ); + expect(handleErrorSpy.mock.calls[0][1].request.method).toBe("GET"); + expect(handleErrorSpy.mock.calls[0][1].request.url).toBe( + "http://test.com/" + ); + }); + }); + + test("provides load context to server entrypoint", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + default: {}, + loader: indexLoader, + }, + }); + + build.entry.module.default = jest.fn( + async ( + request, + responseStatusCode, + responseHeaders, + entryContext, + loadContext + ) => + new Response(JSON.stringify(loadContext), { + status: responseStatusCode, + headers: responseHeaders, + }) + ); + + let handler = createRequestHandler(build, ServerMode.Development); + let request = new Request(`${baseUrl}/`, { method: "get" }); + let loadContext = { "load-context": "load-value" }; + + let result = await handler(request, loadContext); + expect(await result.text()).toBe(JSON.stringify(loadContext)); + }); +}); diff --git a/packages/remix-server-runtime/__tests__/sessions-test.ts b/packages/remix-server-runtime/__tests__/sessions-test.ts new file mode 100644 index 0000000000..041b5deb75 --- /dev/null +++ b/packages/remix-server-runtime/__tests__/sessions-test.ts @@ -0,0 +1,356 @@ +import { createCookieFactory } from "../cookies"; +import type { SignFunction, UnsignFunction } from "../crypto"; +import { + createSession, + createSessionStorageFactory, + isSession, +} from "../sessions"; +import { createCookieSessionStorageFactory } from "../sessions/cookieStorage"; +import { createMemorySessionStorageFactory } from "../sessions/memoryStorage"; + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0]; +} + +const sign: SignFunction = async (value, secret) => { + return JSON.stringify({ value, secret }); +}; +const unsign: UnsignFunction = async (signed, secret) => { + try { + let unsigned = JSON.parse(signed); + if (unsigned.secret !== secret) return false; + return unsigned.value; + } catch (e: unknown) { + return false; + } +}; +const createCookie = createCookieFactory({ sign, unsign }); +const createCookieSessionStorage = + createCookieSessionStorageFactory(createCookie); +const createSessionStorage = createSessionStorageFactory(createCookie); +const createMemorySessionStorage = + createMemorySessionStorageFactory(createSessionStorage); + +describe("Session", () => { + it("has an empty id by default", () => { + expect(createSession().id).toEqual(""); + }); + + it("correctly stores and retrieves values", () => { + let session = createSession(); + + session.set("user", "mjackson"); + session.flash("error", "boom"); + + expect(session.has("user")).toBe(true); + expect(session.get("user")).toBe("mjackson"); + // Normal values should remain in the session after get() + expect(session.has("user")).toBe(true); + expect(session.get("user")).toBe("mjackson"); + + expect(session.has("error")).toBe(true); + expect(session.get("error")).toBe("boom"); + // Flash values disappear after the first get() + expect(session.has("error")).toBe(false); + expect(session.get("error")).toBeUndefined(); + + session.unset("user"); + + expect(session.has("user")).toBe(false); + expect(session.get("user")).toBeUndefined(); + }); +}); + +describe("isSession", () => { + it("returns `true` for Session objects", () => { + expect(isSession(createSession())).toBe(true); + }); + + it("returns `false` for non-Session objects", () => { + expect(isSession({})).toBe(false); + expect(isSession([])).toBe(false); + expect(isSession("")).toBe(false); + expect(isSession(true)).toBe(false); + }); +}); + +describe("In-memory session storage", () => { + it("persists session data across requests", async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + }); + + it("uses random hash keys as session ids", async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + expect(session.id).toMatch(/^[a-z0-9]{8}$/); + }); +}); + +describe("Cookie session storage", () => { + it("persists session data across requests", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + }); + + it("returns an empty session for cookies that are not signed properly", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + + expect(session.get("user")).toEqual("mjackson"); + + let setCookie = await commitSession(session); + session = await getSession( + // Tamper with the session cookie... + getCookieFromSetCookie(setCookie).slice(0, -1) + ); + + expect(session.get("user")).toBeUndefined(); + }); + + it('"makes the default path of cookies to be /', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + expect(setCookie).toContain("Path=/"); + }); + + it("throws an error when the cookie size exceeds 4096 bytes", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + let longString = new Array(4097).fill("a").join(""); + session.set("over4096bytes", longString); + await expect(() => commitSession(session)).rejects.toThrow(); + }); + + it("destroys sessions using a past date", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + secrets: ["secret1"], + }, + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); + + it("destroys sessions that leverage maxAge", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + maxAge: 60 * 60, // 1 hour + secrets: ["secret1"], + }, + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); + + describe("warnings when providing options you may not want to", () => { + let spy = spyConsole(); + + it("warns against using `expires` when creating the session", async () => { + createCookieSessionStorage({ + cookie: { + secrets: ["secret1"], + expires: new Date(Date.now() + 60_000), + }, + }); + + expect(spy.console).toHaveBeenCalledTimes(1); + expect(spy.console).toHaveBeenCalledWith( + 'The "__session" cookie has an "expires" property set. This will cause the expires value to not be updated when the session is committed. Instead, you should set the expires value when serializing the cookie. You can use `commitSession(session, { expires })` if using a session storage object, or `cookie.serialize("value", { expires })` if you\'re using the cookie directly.' + ); + }); + + it("warns when not passing secrets when creating the session", async () => { + createCookieSessionStorage({ cookie: {} }); + + expect(spy.console).toHaveBeenCalledTimes(1); + expect(spy.console).toHaveBeenCalledWith( + 'The "__session" cookie is not signed, but session cookies should be signed to prevent tampering on the client before they are sent back to the server. See https://remix.run/utils/cookies#signing-cookies for more information.' + ); + }); + }); + + describe("when a new secret shows up in the rotation", () => { + it("unsigns old session cookies using the old secret and encodes new cookies using the new secret", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + + // A new secret enters the rotation... + let storage = createCookieSessionStorage({ + cookie: { secrets: ["secret2", "secret1"] }, + }); + getSession = storage.getSession; + commitSession = storage.commitSession; + + // Old cookies should still work with the old secret. + session = await storage.getSession(getCookieFromSetCookie(setCookie)); + expect(session.get("user")).toEqual("mjackson"); + + // New cookies should be signed using the new secret. + let setCookie2 = await storage.commitSession(session); + expect(setCookie2).not.toEqual(setCookie); + }); + }); +}); + +describe("Custom cookie-backed session storage", () => { + let memoryBacking = {}; + let createCookieBackedSessionStorage = + createSessionStorageFactory(createCookie); + let implementation = { + createData(data) { + let id = Math.random().toString(36).substring(2, 10); + memoryBacking[id] = data; + return Promise.resolve(id); + }, + readData(id) { + return Promise.resolve(memoryBacking[id] || null); + }, + updateData(id, data) { + memoryBacking[id] = data; + return Promise.resolve(); + }, + deleteData(id) { + memoryBacking[id] = null; + return Promise.resolve(memoryBacking[id]); + }, + }; + + it("persists session data across requests", async () => { + let { getSession, commitSession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + }); + + it("returns an empty session for cookies that are not signed properly", async () => { + let { getSession, commitSession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + session.set("user", "mjackson"); + + expect(session.get("user")).toEqual("mjackson"); + + let setCookie = await commitSession(session); + session = await getSession( + // Tamper with the session cookie... + getCookieFromSetCookie(setCookie).slice(0, -1) + ); + + expect(session.get("user")).toBeUndefined(); + }); + + it('"makes the default path of cookies to be /', async () => { + let { getSession, commitSession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + expect(setCookie).toContain("Path=/"); + }); + + it("destroys sessions using a past date", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"test=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); + + it("destroys sessions that leverage maxAge", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { + maxAge: 60 * 60, // 1 hour + secrets: ["test"], + }), + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"test=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); +}); + +function spyConsole() { + // https://github.com/facebook/react/issues/7047 + let spy: any = {}; + + beforeAll(() => { + spy.console = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + beforeEach(() => { + spy.console.mockClear(); + }); + + afterAll(() => { + spy.console.mockRestore(); + }); + + return spy; +} diff --git a/packages/remix-server-runtime/__tests__/setup.ts b/packages/remix-server-runtime/__tests__/setup.ts new file mode 100644 index 0000000000..451031301a --- /dev/null +++ b/packages/remix-server-runtime/__tests__/setup.ts @@ -0,0 +1,3 @@ +import { installGlobals } from "@remix-run/node"; + +installGlobals(); diff --git a/packages/remix-server-runtime/__tests__/utils.ts b/packages/remix-server-runtime/__tests__/utils.ts new file mode 100644 index 0000000000..dccade4a4f --- /dev/null +++ b/packages/remix-server-runtime/__tests__/utils.ts @@ -0,0 +1,109 @@ +import prettier from "prettier"; + +import type { + ActionFunction, + HandleErrorFunction, + HeadersFunction, + LoaderFunction, +} from "../"; +import type { FutureConfig } from "../entry"; +import type { EntryRoute, ServerRoute, ServerRouteManifest } from "../routes"; + +export function mockServerBuild( + routes: Record< + string, + { + parentId?: string; + index?: true; + path?: string; + default?: any; + ErrorBoundary?: any; + action?: ActionFunction; + headers?: HeadersFunction; + loader?: LoaderFunction; + } + >, + opts: { + future?: Partial; + handleError?: HandleErrorFunction; + } = {} +) { + return { + future: { + ...opts.future, + }, + assets: { + entry: { + imports: [""], + module: "", + }, + routes: Object.entries(routes).reduce((p, [id, config]) => { + let route: EntryRoute = { + hasAction: !!config.action, + hasErrorBoundary: !!config.ErrorBoundary, + hasLoader: !!config.loader, + id, + module: "", + index: config.index, + path: config.path, + parentId: config.parentId, + }; + return { + ...p, + [id]: route, + }; + }, {}), + url: "", + version: "", + }, + entry: { + module: { + default: jest.fn( + async ( + request, + responseStatusCode, + responseHeaders, + entryContext, + loadContext + ) => + new Response(null, { + status: responseStatusCode, + headers: responseHeaders, + }) + ), + handleDataRequest: jest.fn(async (response) => response), + handleError: opts.handleError, + }, + }, + routes: Object.entries(routes).reduce( + (p, [id, config]) => { + let route: Omit = { + id, + index: config.index, + path: config.path, + parentId: config.parentId, + module: { + default: config.default, + ErrorBoundary: config.ErrorBoundary, + action: config.action, + headers: config.headers, + loader: config.loader, + }, + }; + return { + ...p, + [id]: route, + }; + }, + {} + ), + }; +} + +export function prettyHtml(source: string): string { + return prettier.format(source, { parser: "html" }); +} + +export function isEqual( + arg: A extends B ? (B extends A ? true : false) : false +): void {} diff --git a/packages/remix-server-runtime/build.ts b/packages/remix-server-runtime/build.ts new file mode 100644 index 0000000000..859e306e3a --- /dev/null +++ b/packages/remix-server-runtime/build.ts @@ -0,0 +1,58 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "./routeModules"; +import type { AssetsManifest, EntryContext, FutureConfig } from "./entry"; +import type { ServerRouteManifest } from "./routes"; +import type { AppLoadContext } from "./data"; + +// NOTE: IF you modify `ServerBuild`, be sure to modify the +// `remix-dev/server-build.ts` file to reflect the new field as well + +/** + * The output of the compiler for the server build. + */ +export interface ServerBuild { + // v3 TODO: + // - Deprecate when we deprecate the old compiler + // - Remove in v3 + mode: string; + entry: { + module: ServerEntryModule; + }; + routes: ServerRouteManifest; + assets: AssetsManifest; + basename?: string; + publicPath: string; + assetsBuildDirectory: string; + future: FutureConfig; + isSpaMode: boolean; +} + +export interface HandleDocumentRequestFunction { + ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + context: EntryContext, + loadContext: AppLoadContext + ): Promise | Response; +} + +export interface HandleDataRequestFunction { + (response: Response, args: LoaderFunctionArgs | ActionFunctionArgs): + | Promise + | Response; +} + +export interface HandleErrorFunction { + (error: unknown, args: LoaderFunctionArgs | ActionFunctionArgs): void; +} + +/** + * A module that serves as the entry point for a Remix app during server + * rendering. + */ +export interface ServerEntryModule { + default: HandleDocumentRequestFunction; + handleDataRequest?: HandleDataRequestFunction; + handleError?: HandleErrorFunction; + streamTimeout?: number; +} diff --git a/packages/remix-server-runtime/cookies.ts b/packages/remix-server-runtime/cookies.ts new file mode 100644 index 0000000000..80884fa8fc --- /dev/null +++ b/packages/remix-server-runtime/cookies.ts @@ -0,0 +1,261 @@ +import type { CookieParseOptions, CookieSerializeOptions } from "cookie"; +import { parse, serialize } from "cookie"; + +import type { SignFunction, UnsignFunction } from "./crypto"; +import { warnOnce } from "./warnings"; + +export type { CookieParseOptions, CookieSerializeOptions }; + +export interface CookieSignatureOptions { + /** + * An array of secrets that may be used to sign/unsign the value of a cookie. + * + * The array makes it easy to rotate secrets. New secrets should be added to + * the beginning of the array. `cookie.serialize()` will always use the first + * value in the array, but `cookie.parse()` may use any of them so that + * cookies that were signed with older secrets still work. + */ + secrets?: string[]; +} + +export type CookieOptions = CookieParseOptions & + CookieSerializeOptions & + CookieSignatureOptions; + +/** + * A HTTP cookie. + * + * A Cookie is a logical container for metadata about a HTTP cookie; its name + * and options. But it doesn't contain a value. Instead, it has `parse()` and + * `serialize()` methods that allow a single instance to be reused for + * parsing/encoding multiple different values. + * + * @see https://remix.run/utils/cookies#cookie-api + */ +export interface Cookie { + /** + * The name of the cookie, used in the `Cookie` and `Set-Cookie` headers. + */ + readonly name: string; + + /** + * True if this cookie uses one or more secrets for verification. + */ + readonly isSigned: boolean; + + /** + * The Date this cookie expires. + * + * Note: This is calculated at access time using `maxAge` when no `expires` + * option is provided to `createCookie()`. + */ + readonly expires?: Date; + + /** + * Parses a raw `Cookie` header and returns the value of this cookie or + * `null` if it's not present. + */ + parse( + cookieHeader: string | null, + options?: CookieParseOptions + ): Promise; + + /** + * Serializes the given value to a string and returns the `Set-Cookie` + * header. + */ + serialize(value: any, options?: CookieSerializeOptions): Promise; +} + +export type CreateCookieFunction = ( + name: string, + cookieOptions?: CookieOptions +) => Cookie; + +/** + * Creates a logical container for managing a browser cookie from the server. + * + * @see https://remix.run/utils/cookies#createcookie + */ +export const createCookieFactory = + ({ + sign, + unsign, + }: { + sign: SignFunction; + unsign: UnsignFunction; + }): CreateCookieFunction => + (name, cookieOptions = {}) => { + let { secrets = [], ...options } = { + path: "/", + sameSite: "lax" as const, + ...cookieOptions, + }; + + warnOnceAboutExpiresCookie(name, options.expires); + + return { + get name() { + return name; + }, + get isSigned() { + return secrets.length > 0; + }, + get expires() { + // Max-Age takes precedence over Expires + return typeof options.maxAge !== "undefined" + ? new Date(Date.now() + options.maxAge * 1000) + : options.expires; + }, + async parse(cookieHeader, parseOptions) { + if (!cookieHeader) return null; + let cookies = parse(cookieHeader, { ...options, ...parseOptions }); + return name in cookies + ? cookies[name] === "" + ? "" + : await decodeCookieValue(unsign, cookies[name], secrets) + : null; + }, + async serialize(value, serializeOptions) { + return serialize( + name, + value === "" ? "" : await encodeCookieValue(sign, value, secrets), + { + ...options, + ...serializeOptions, + } + ); + }, + }; + }; + +export type IsCookieFunction = (object: any) => object is Cookie; + +/** + * Returns true if an object is a Remix cookie container. + * + * @see https://remix.run/utils/cookies#iscookie + */ +export const isCookie: IsCookieFunction = (object): object is Cookie => { + return ( + object != null && + typeof object.name === "string" && + typeof object.isSigned === "boolean" && + typeof object.parse === "function" && + typeof object.serialize === "function" + ); +}; + +async function encodeCookieValue( + sign: SignFunction, + value: any, + secrets: string[] +): Promise { + let encoded = encodeData(value); + + if (secrets.length > 0) { + encoded = await sign(encoded, secrets[0]); + } + + return encoded; +} + +async function decodeCookieValue( + unsign: UnsignFunction, + value: string, + secrets: string[] +): Promise { + if (secrets.length > 0) { + for (let secret of secrets) { + let unsignedValue = await unsign(value, secret); + if (unsignedValue !== false) { + return decodeData(unsignedValue); + } + } + + return null; + } + + return decodeData(value); +} + +function encodeData(value: any): string { + return btoa(myUnescape(encodeURIComponent(JSON.stringify(value)))); +} + +function decodeData(value: string): any { + try { + return JSON.parse(decodeURIComponent(myEscape(atob(value)))); + } catch (error: unknown) { + return {}; + } +} + +// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.escape.js +function myEscape(value: string): string { + let str = value.toString(); + let result = ""; + let index = 0; + let chr, code; + while (index < str.length) { + chr = str.charAt(index++); + if (/[\w*+\-./@]/.exec(chr)) { + result += chr; + } else { + code = chr.charCodeAt(0); + if (code < 256) { + result += "%" + hex(code, 2); + } else { + result += "%u" + hex(code, 4).toUpperCase(); + } + } + } + return result; +} + +function hex(code: number, length: number): string { + let result = code.toString(16); + while (result.length < length) result = "0" + result; + return result; +} + +// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.unescape.js +function myUnescape(value: string): string { + let str = value.toString(); + let result = ""; + let index = 0; + let chr, part; + while (index < str.length) { + chr = str.charAt(index++); + if (chr === "%") { + if (str.charAt(index) === "u") { + part = str.slice(index + 1, index + 5); + if (/^[\da-f]{4}$/i.exec(part)) { + result += String.fromCharCode(parseInt(part, 16)); + index += 5; + continue; + } + } else { + part = str.slice(index, index + 2); + if (/^[\da-f]{2}$/i.exec(part)) { + result += String.fromCharCode(parseInt(part, 16)); + index += 2; + continue; + } + } + } + result += chr; + } + return result; +} + +function warnOnceAboutExpiresCookie(name: string, expires?: Date) { + warnOnce( + !expires, + `The "${name}" cookie has an "expires" property set. ` + + `This will cause the expires value to not be updated when the session is committed. ` + + `Instead, you should set the expires value when serializing the cookie. ` + + `You can use \`commitSession(session, { expires })\` if using a session storage object, ` + + `or \`cookie.serialize("value", { expires })\` if you're using the cookie directly.` + ); +} diff --git a/packages/remix-server-runtime/crypto.ts b/packages/remix-server-runtime/crypto.ts new file mode 100644 index 0000000000..daedea742b --- /dev/null +++ b/packages/remix-server-runtime/crypto.ts @@ -0,0 +1,64 @@ +export type SignFunction = (value: string, secret: string) => Promise; + +export type UnsignFunction = ( + cookie: string, + secret: string +) => Promise; + +// TODO: Once Node v19 is supported we should use the globally provided +// Web Crypto API's and re-enable this code-path in "./cookies.ts" +// instead of referencing the `sign` and `unsign` globals. + +// const encoder = new TextEncoder(); + +// export const sign: SignFunction = async ( +// value: string, +// secret: string +// ): Promise => { +// let data = encoder.encode(value); +// let key = await createKey(secret, ["sign"]); +// let signature = await crypto.subtle.sign("HMAC", key, data); +// let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace( +// /=+$/, +// "" +// ); + +// return value + "." + hash; +// }; + +// export const unsign: UnsignFunction = async ( +// cookie: string, +// secret: string +// ): Promise => { +// let value = cookie.slice(0, cookie.lastIndexOf(".")); +// let hash = cookie.slice(cookie.lastIndexOf(".") + 1); + +// let data = encoder.encode(value); +// let key = await createKey(secret, ["verify"]); +// let signature = byteStringToUint8Array(atob(hash)); +// let valid = await crypto.subtle.verify("HMAC", key, signature, data); + +// return valid ? value : false; +// }; + +// const createKey = async ( +// secret: string, +// usages: CryptoKey["usages"] +// ): Promise => +// crypto.subtle.importKey( +// "raw", +// encoder.encode(secret), +// { name: "HMAC", hash: "SHA-256" }, +// false, +// usages +// ); + +// const byteStringToUint8Array = (byteString: string): Uint8Array => { +// let array = new Uint8Array(byteString.length); + +// for (let i = 0; i < byteString.length; i++) { +// array[i] = byteString.charCodeAt(i); +// } + +// return array; +// }; diff --git a/packages/remix-server-runtime/data.ts b/packages/remix-server-runtime/data.ts new file mode 100644 index 0000000000..59d70500d3 --- /dev/null +++ b/packages/remix-server-runtime/data.ts @@ -0,0 +1,167 @@ +import { + redirect, + json, + isDeferredData, + isResponse, + isRedirectStatusCode, +} from "./responses"; +import type { + ActionFunction, + ActionFunctionArgs, + LoaderFunction, + LoaderFunctionArgs, + ResponseStub, +} from "./routeModules"; + +/** + * An object of unknown type for route loaders and actions provided by the + * server's `getLoadContext()` function. This is defined as an empty interface + * specifically so apps can leverage declaration merging to augment this type + * globally: https://www.typescriptlang.org/docs/handbook/declaration-merging.html + */ +export interface AppLoadContext { + [key: string]: unknown; +} + +/** + * Data for a route that was returned from a `loader()`. + */ +export type AppData = unknown; + +export async function callRouteAction({ + loadContext, + action, + params, + request, + routeId, + singleFetch, + response, +}: { + request: Request; + action: ActionFunction; + params: ActionFunctionArgs["params"]; + loadContext: AppLoadContext; + routeId: string; + singleFetch: boolean; + response?: ResponseStub; +}) { + let result = await action({ + request: stripDataParam(stripIndexParam(request)), + context: loadContext, + params, + response, + }); + + if (result === undefined) { + throw new Error( + `You defined an action for route "${routeId}" but didn't return ` + + `anything from your \`action\` function. Please return a value or \`null\`.` + ); + } + + // Allow naked object returns when single fetch is enabled + if (singleFetch) { + return result; + } + + return isResponse(result) ? result : json(result); +} + +export async function callRouteLoader({ + loadContext, + loader, + params, + request, + routeId, + singleFetch, + response, +}: { + request: Request; + loader: LoaderFunction; + params: LoaderFunctionArgs["params"]; + loadContext: AppLoadContext; + routeId: string; + singleFetch: boolean; + response?: ResponseStub; +}) { + let result = await loader({ + request: stripDataParam(stripIndexParam(request)), + context: loadContext, + params, + response, + }); + + if (result === undefined) { + throw new Error( + `You defined a loader for route "${routeId}" but didn't return ` + + `anything from your \`loader\` function. Please return a value or \`null\`.` + ); + } + + if (isDeferredData(result)) { + if (result.init && isRedirectStatusCode(result.init.status || 200)) { + return redirect( + new Headers(result.init.headers).get("Location")!, + result.init + ); + } + return result; + } + + // Allow naked object returns when single fetch is enabled + if (singleFetch) { + return result; + } + + return isResponse(result) ? result : json(result); +} + +// TODO: Document these search params better +// and stop stripping these in V2. These break +// support for running in a SW and also expose +// valuable info to data funcs that is being asked +// for such as "is this a data request?". +function stripIndexParam(request: Request) { + let url = new URL(request.url); + let indexValues = url.searchParams.getAll("index"); + url.searchParams.delete("index"); + let indexValuesToKeep = []; + for (let indexValue of indexValues) { + if (indexValue) { + indexValuesToKeep.push(indexValue); + } + } + for (let toKeep of indexValuesToKeep) { + url.searchParams.append("index", toKeep); + } + + let init: RequestInit = { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + }; + + if (init.body) { + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url.href, init); +} + +function stripDataParam(request: Request) { + let url = new URL(request.url); + url.searchParams.delete("_data"); + let init: RequestInit = { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + }; + + if (init.body) { + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url.href, init); +} diff --git a/packages/remix-server-runtime/dev.ts b/packages/remix-server-runtime/dev.ts new file mode 100644 index 0000000000..712389a1c1 --- /dev/null +++ b/packages/remix-server-runtime/dev.ts @@ -0,0 +1,47 @@ +import type { ServerBuild } from "./build"; + +export async function broadcastDevReady(build: ServerBuild, origin?: string) { + origin ??= process.env.REMIX_DEV_ORIGIN; + if (!origin) throw Error("Dev server origin not set"); + let url = new URL(origin); + url.pathname = "ping"; + + let response = await fetch(url.href, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ buildHash: build.assets.version }), + }).catch((error) => { + console.error(`Could not reach Remix dev server at ${url}`); + throw error; + }); + if (!response.ok) { + console.error( + `Could not reach Remix dev server at ${url} (${response.status})` + ); + throw Error(await response.text()); + } +} + +export function logDevReady(build: ServerBuild) { + console.log(`[REMIX DEV] ${build.assets.version} ready`); +} + +type DevServerHooks = { + getCriticalCss?: ( + build: ServerBuild, + pathname: string + ) => Promise; + processRequestError?: (error: unknown) => void; +}; + +const globalDevServerHooksKey = "__remix_devServerHooks"; + +export function setDevServerHooks(devServerHooks: DevServerHooks) { + // @ts-expect-error + globalThis[globalDevServerHooksKey] = devServerHooks; +} + +export function getDevServerHooks(): DevServerHooks | undefined { + // @ts-expect-error + return globalThis[globalDevServerHooksKey]; +} diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts new file mode 100644 index 0000000000..b7d49a61c0 --- /dev/null +++ b/packages/remix-server-runtime/entry.ts @@ -0,0 +1,57 @@ +import type { StaticHandlerContext } from "@remix-run/router"; + +import type { SerializedError } from "./errors"; +import type { RouteManifest, ServerRouteManifest, EntryRoute } from "./routes"; +import type { RouteModules, EntryRouteModule } from "./routeModules"; + +export interface EntryContext { + manifest: AssetsManifest; + routeModules: RouteModules; + criticalCss?: string; + serverHandoffString?: string; + serverHandoffStream?: ReadableStream; + renderMeta?: { + didRenderScripts?: boolean; + streamCache?: Record< + number, + Promise & { + result?: { + done: boolean; + value: string; + }; + error?: unknown; + } + >; + }; + staticHandlerContext: StaticHandlerContext; + future: FutureConfig; + isSpaMode: boolean; + serializeError(error: Error): SerializedError; +} + +export interface FutureConfig { + v3_fetcherPersist: boolean; + v3_relativeSplatPath: boolean; + v3_throwAbortReason: boolean; + unstable_singleFetch: boolean; +} + +export interface AssetsManifest { + entry: { + imports: string[]; + module: string; + }; + routes: RouteManifest; + url: string; + version: string; + hmrRuntime?: string; +} + +export function createEntryRouteModules( + manifest: ServerRouteManifest +): RouteModules { + return Object.keys(manifest).reduce((memo, routeId) => { + memo[routeId] = manifest[routeId].module; + return memo; + }, {} as RouteModules); +} diff --git a/packages/remix-server-runtime/errors.ts b/packages/remix-server-runtime/errors.ts new file mode 100644 index 0000000000..8e0934e3fd --- /dev/null +++ b/packages/remix-server-runtime/errors.ts @@ -0,0 +1,117 @@ +import type { StaticHandlerContext } from "@remix-run/router"; +import { isRouteErrorResponse } from "@remix-run/router"; + +import { ServerMode } from "./mode"; + +/** + * This thing probably warrants some explanation. + * + * The whole point here is to emulate componentDidCatch for server rendering and + * data loading. It can get tricky. React can do this on component boundaries + * but doesn't support it for server rendering or data loading. We know enough + * with nested routes to be able to emulate the behavior (because we know them + * statically before rendering.) + * + * Each route can export an `ErrorBoundary`. + * + * - When rendering throws an error, the nearest error boundary will render + * (normal react componentDidCatch). This will be the route's own boundary, but + * if none is provided, it will bubble up to the parents. + * - When data loading throws an error, the nearest error boundary will render + * - When performing an action, the nearest error boundary for the action's + * route tree will render (no redirect happens) + * + * During normal react rendering, we do nothing special, just normal + * componentDidCatch. + * + * For server rendering, we mutate `renderBoundaryRouteId` to know the last + * layout that has an error boundary that tried to render. This emulates which + * layout would catch a thrown error. If the rendering fails, we catch the error + * on the server, and go again a second time with the emulator holding on to the + * information it needs to render the same error boundary as a dynamically + * thrown render error. + * + * When data loading, server or client side, we use the emulator to likewise + * hang on to the error and re-render at the appropriate layout (where a thrown + * error would have been caught by cDC). + * + * When actions throw, it all works the same. There's an edge case to be aware + * of though. Actions normally are required to redirect, but in the case of + * errors, we render the action's route with the emulator holding on to the + * error. If during this render a parent route/loader throws we ignore that new + * error and render the action's original error as deeply as possible. In other + * words, we simply ignore the new error and use the action's error in place + * because it came first, and that just wouldn't be fair to let errors cut in + * line. + */ + +export function sanitizeError(error: T, serverMode: ServerMode) { + if (error instanceof Error && serverMode !== ServerMode.Development) { + let sanitized = new Error("Unexpected Server Error"); + sanitized.stack = undefined; + return sanitized; + } + return error; +} + +export function sanitizeErrors( + errors: NonNullable, + serverMode: ServerMode +) { + return Object.entries(errors).reduce((acc, [routeId, error]) => { + return Object.assign(acc, { [routeId]: sanitizeError(error, serverMode) }); + }, {}); +} + +// must be type alias due to inference issues on interfaces +// https://github.com/microsoft/TypeScript/issues/15300 +export type SerializedError = { + message: string; + stack?: string; +}; + +export function serializeError( + error: Error, + serverMode: ServerMode +): SerializedError { + let sanitized = sanitizeError(error, serverMode); + return { + message: sanitized.message, + stack: sanitized.stack, + }; +} + +export function serializeErrors( + errors: StaticHandlerContext["errors"], + serverMode: ServerMode +): StaticHandlerContext["errors"] { + if (!errors) return null; + let entries = Object.entries(errors); + let serialized: StaticHandlerContext["errors"] = {}; + for (let [key, val] of entries) { + // Hey you! If you change this, please change the corresponding logic in + // deserializeErrors in remix-react/errors.ts :) + if (isRouteErrorResponse(val)) { + serialized[key] = { ...val, __type: "RouteErrorResponse" }; + } else if (val instanceof Error) { + let sanitized = sanitizeError(val, serverMode); + serialized[key] = { + message: sanitized.message, + stack: sanitized.stack, + __type: "Error", + // If this is a subclass (i.e., ReferenceError), send up the type so we + // can re-create the same type during hydration. This will only apply + // in dev mode since all production errors are sanitized to normal + // Error instances + ...(sanitized.name !== "Error" + ? { + __subType: sanitized.name, + } + : {}), + }; + } else { + serialized[key] = val; + } + } + return serialized; +} diff --git a/packages/remix-server-runtime/formData.ts b/packages/remix-server-runtime/formData.ts new file mode 100644 index 0000000000..710cec273d --- /dev/null +++ b/packages/remix-server-runtime/formData.ts @@ -0,0 +1,67 @@ +// @ts-ignore +import { streamMultipart } from "@web3-storage/multipart-parser"; + +export type UploadHandlerPart = { + name: string; + filename?: string; + contentType: string; + data: AsyncIterable; +}; + +export type UploadHandler = ( + part: UploadHandlerPart +) => Promise; + +export function composeUploadHandlers( + ...handlers: UploadHandler[] +): UploadHandler { + return async (part) => { + for (let handler of handlers) { + let value = await handler(part); + if (typeof value !== "undefined" && value !== null) { + return value; + } + } + + return undefined; + }; +} + +/** + * Allows you to handle multipart forms (file uploads) for your app. + * + * TODO: Update this comment + * @see https://remix.run/utils/parse-multipart-form-data + */ +export async function parseMultipartFormData( + request: Request, + uploadHandler: UploadHandler +): Promise { + let contentType = request.headers.get("Content-Type") || ""; + let [type, boundary] = contentType.split(/\s*;\s*boundary=/); + + if (!request.body || !boundary || type !== "multipart/form-data") { + throw new TypeError("Could not parse content as FormData."); + } + + let formData = new FormData(); + let parts: AsyncIterable = + streamMultipart(request.body, boundary); + + for await (let part of parts) { + if (part.done) break; + + if (typeof part.filename === "string") { + // only pass basename as the multipart/form-data spec recommends + // https://datatracker.ietf.org/doc/html/rfc7578#section-4.2 + part.filename = part.filename.split(/[/\\]/).pop(); + } + + let value = await uploadHandler(part); + if (typeof value !== "undefined" && value !== null) { + formData.append(part.name, value as any); + } + } + + return formData; +} diff --git a/packages/remix-server-runtime/headers.ts b/packages/remix-server-runtime/headers.ts new file mode 100644 index 0000000000..c17e1cea7d --- /dev/null +++ b/packages/remix-server-runtime/headers.ts @@ -0,0 +1,99 @@ +import type { StaticHandlerContext } from "@remix-run/router"; +import { splitCookiesString } from "set-cookie-parser"; + +import type { ServerBuild } from "./build"; + +export function getDocumentHeaders( + build: ServerBuild, + context: StaticHandlerContext +): Headers { + let boundaryIdx = context.errors + ? context.matches.findIndex((m) => context.errors![m.route.id]) + : -1; + let matches = + boundaryIdx >= 0 + ? context.matches.slice(0, boundaryIdx + 1) + : context.matches; + + let errorHeaders: Headers | undefined; + + if (boundaryIdx >= 0) { + // Look for any errorHeaders from the boundary route down, which can be + // identified by the presence of headers but no data + let { actionHeaders, actionData, loaderHeaders, loaderData } = context; + context.matches.slice(boundaryIdx).some((match) => { + let id = match.route.id; + if (actionHeaders[id] && (!actionData || actionData[id] === undefined)) { + errorHeaders = actionHeaders[id]; + } else if (loaderHeaders[id] && loaderData[id] === undefined) { + errorHeaders = loaderHeaders[id]; + } + return errorHeaders != null; + }); + } + + return matches.reduce((parentHeaders, match, idx) => { + let { id } = match.route; + let routeModule = build.routes[id].module; + let loaderHeaders = context.loaderHeaders[id] || new Headers(); + let actionHeaders = context.actionHeaders[id] || new Headers(); + + // Only expose errorHeaders to the leaf headers() function to + // avoid duplication via parentHeaders + let includeErrorHeaders = + errorHeaders != undefined && idx === matches.length - 1; + // Only prepend cookies from errorHeaders at the leaf renderable route + // when it's not the same as loaderHeaders/actionHeaders to avoid + // duplicate cookies + let includeErrorCookies = + includeErrorHeaders && + errorHeaders !== loaderHeaders && + errorHeaders !== actionHeaders; + + // Use the parent headers for any route without a `headers` export + if (routeModule.headers == null) { + let headers = new Headers(parentHeaders); + if (includeErrorCookies) { + prependCookies(errorHeaders!, headers); + } + prependCookies(actionHeaders, headers); + prependCookies(loaderHeaders, headers); + return headers; + } + + let headers = new Headers( + routeModule.headers + ? typeof routeModule.headers === "function" + ? routeModule.headers({ + loaderHeaders, + parentHeaders, + actionHeaders, + errorHeaders: includeErrorHeaders ? errorHeaders : undefined, + }) + : routeModule.headers + : undefined + ); + + // Automatically preserve Set-Cookie headers from bubbled responses, + // loaders, errors, and parent routes + if (includeErrorCookies) { + prependCookies(errorHeaders!, headers); + } + prependCookies(actionHeaders, headers); + prependCookies(loaderHeaders, headers); + prependCookies(parentHeaders, headers); + + return headers; + }, new Headers()); +} + +function prependCookies(parentHeaders: Headers, childHeaders: Headers): void { + let parentSetCookieString = parentHeaders.get("Set-Cookie"); + + if (parentSetCookieString) { + let cookies = splitCookiesString(parentSetCookieString); + cookies.forEach((cookie) => { + childHeaders.append("Set-Cookie", cookie); + }); + } +} diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts new file mode 100644 index 0000000000..7744129cdb --- /dev/null +++ b/packages/remix-server-runtime/index.ts @@ -0,0 +1,87 @@ +// Default implementations for the Remix server runtime interface +export { createCookieFactory, isCookie } from "./cookies"; +export { + composeUploadHandlers as unstable_composeUploadHandlers, + parseMultipartFormData as unstable_parseMultipartFormData, +} from "./formData"; +export { defer, json, redirect, redirectDocument } from "./responses"; +export type { + SingleFetchResult as UNSAFE_SingleFetchResult, + SingleFetchResults as UNSAFE_SingleFetchResults, +} from "./single-fetch"; +export { SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol } from "./single-fetch"; +export { createRequestHandler } from "./server"; +export { + createSession, + createSessionStorageFactory, + isSession, +} from "./sessions"; +export { createCookieSessionStorageFactory } from "./sessions/cookieStorage"; +export { createMemorySessionStorageFactory } from "./sessions/memoryStorage"; +export { createMemoryUploadHandler as unstable_createMemoryUploadHandler } from "./upload/memoryUploadHandler"; +export { MaxPartSizeExceededError } from "./upload/errors"; +export { + broadcastDevReady, + logDevReady, + setDevServerHooks as unstable_setDevServerHooks, +} from "./dev"; + +// Types for the Remix server runtime interface +export type { + CreateCookieFunction, + CreateCookieSessionStorageFunction, + CreateMemorySessionStorageFunction, + CreateRequestHandlerFunction, + CreateSessionFunction, + CreateSessionStorageFunction, + IsCookieFunction, + IsSessionFunction, + JsonFunction, + RedirectFunction, +} from "./interface"; + +// Remix server runtime packages should re-export these types +export type { + ActionFunction, + ActionFunctionArgs, + AppLoadContext, + Cookie, + CookieOptions, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, + DataFunctionArgs, + EntryContext, + ErrorResponse, + FlashSessionData, + HandleDataRequestFunction, + HandleDocumentRequestFunction, + HeadersArgs, + HeadersFunction, + HtmlLinkDescriptor, + LinkDescriptor, + LinksFunction, + LoaderFunction, + LoaderFunctionArgs, + MemoryUploadHandlerFilterArgs, + MemoryUploadHandlerOptions, + HandleErrorFunction, + PageLinkDescriptor, + RequestHandler, + SerializeFrom, + ServerBuild, + ServerEntryModule, + ServerRuntimeMetaArgs, + ServerRuntimeMetaDescriptor, + ServerRuntimeMetaFunction, + Session, + SessionData, + SessionIdStorageStrategy, + SessionStorage, + SignFunction, + TypedDeferredData, + TypedResponse, + UnsignFunction, + UploadHandler, + UploadHandlerPart, +} from "./reexport"; diff --git a/packages/remix-server-runtime/interface.ts b/packages/remix-server-runtime/interface.ts new file mode 100644 index 0000000000..be54c937c3 --- /dev/null +++ b/packages/remix-server-runtime/interface.ts @@ -0,0 +1,10 @@ +export type { CreateCookieFunction, IsCookieFunction } from "./cookies"; +export type { JsonFunction, RedirectFunction } from "./responses"; +export type { CreateRequestHandlerFunction } from "./server"; +export type { + CreateSessionFunction, + CreateSessionStorageFunction, + IsSessionFunction, +} from "./sessions"; +export type { CreateCookieSessionStorageFunction } from "./sessions/cookieStorage"; +export type { CreateMemorySessionStorageFunction } from "./sessions/memoryStorage"; diff --git a/packages/remix-server-runtime/invariant.ts b/packages/remix-server-runtime/invariant.ts new file mode 100644 index 0000000000..123cc25cb4 --- /dev/null +++ b/packages/remix-server-runtime/invariant.ts @@ -0,0 +1,16 @@ +export default function invariant( + value: boolean, + message?: string +): asserts value; +export default function invariant( + value: T | null | undefined, + message?: string +): asserts value is T; +export default function invariant(value: any, message?: string) { + if (value === false || value === null || typeof value === "undefined") { + console.error( + "The following error is a bug in Remix; please open an issue! https://github.com/remix-run/remix/issues/new" + ); + throw new Error(message); + } +} diff --git a/packages/remix-server-runtime/jest.config.js b/packages/remix-server-runtime/jest.config.js new file mode 100644 index 0000000000..92a3431fdc --- /dev/null +++ b/packages/remix-server-runtime/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "server-runtime", +}; diff --git a/packages/remix-server-runtime/jsonify.ts b/packages/remix-server-runtime/jsonify.ts new file mode 100644 index 0000000000..80e524ff57 --- /dev/null +++ b/packages/remix-server-runtime/jsonify.ts @@ -0,0 +1,261 @@ +import { + expectType, + type Equal, + type Expect, + type MutualExtends, +} from "./typecheck"; + +// prettier-ignore +// `Jsonify` emulates `let y = JSON.parse(JSON.stringify(x))`, but for types +// so that we can infer the shape of the data sent over the network. +export type Jsonify = + // any + IsAny extends true ? any : + + // toJSON + T extends { toJSON(): infer U } ? (U extends JsonValue ? U : unknown) : + + // primitives + T extends JsonPrimitive ? T : + T extends String ? string : + T extends Number ? number : + T extends Boolean ? boolean : + + // Promises JSON.stringify to an empty object + T extends Promise ? EmptyObject : + + // Map & Set + T extends Map ? EmptyObject : + T extends Set ? EmptyObject : + + // TypedArray + T extends TypedArray ? Record : + + // Not JSON serializable + T extends NotJson ? never : + + // tuple & array + T extends [] ? [] : + T extends readonly [infer F, ...infer R] ? [NeverToNull>, ...Jsonify] : + T extends readonly unknown[] ? Array>>: + + // object + T extends Record ? JsonifyObject : + + // unknown + unknown extends T ? unknown : + + never + +// value is always not JSON => true +// value is always JSON => false +// value is somtimes JSON, sometimes not JSON => boolean +// note: cannot be inlined as logic requires union distribution +type ValueIsNotJson = T extends NotJson ? true : false; + +// note: remove optionality so that produced values are never `undefined`, +// only `true`, `false`, or `boolean` +type IsNotJson = { [K in keyof T]-?: ValueIsNotJson }; + +type JsonifyValues = { [K in keyof T]: Jsonify }; + +// prettier-ignore +type JsonifyObject> = + // required + { [K in keyof T as + unknown extends T[K] ? never : + IsNotJson[K] extends false ? K : + never + ]: JsonifyValues[K] } & + // optional + { [K in keyof T as + unknown extends T[K] ? K : + // if the value is always JSON, then it's not optional + IsNotJson[K] extends false ? never : + // if the value is always not JSON, omit it entirely + IsNotJson[K] extends true ? never : + // if the value is mixed, then it's optional + K + ]? : JsonifyValues[K]} + +// types ------------------------------------------------------------ + +type JsonPrimitive = string | number | boolean | null; + +type JsonArray = JsonValue[] | readonly JsonValue[]; + +// prettier-ignore +type JsonObject = + { [K in string]: JsonValue } & + { [K in string]?: JsonValue } + +type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +type NotJson = undefined | symbol | AnyFunction; + +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; + +// tests ------------------------------------------------------------ + +// prettier-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type _tests = [ + // any + Expect, any>>, + + // primitives + Expect, string>>, + Expect, number>>, + Expect, boolean>>, + Expect, null>>, + Expect, string>>, + Expect, number>>, + Expect, boolean>>, + Expect>, EmptyObject>>, + + // Map & Set + Expect>, EmptyObject>>, + Expect>, EmptyObject>>, + + // TypedArray + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + + // Not Json + Expect, never>>, + Expect, never>>, + Expect void>, never>>, + Expect, never>>, + + // toJson + Expect, "stuff">>, + Expect, string>>, + Expect, unknown>>, + Expect, unknown>>, + Expect, string>>, + + + // tuple & array + Expect, []>>, + Expect, [1, 'two', string, null, false]>>, + Expect, (string | number)[]>>, + Expect, null[]>>, + Expect, [1,2,3]>>, + + // object + Expect>, {}>>, + Expect>, {a: string}>>, + Expect>, {a?: string}>>, + Expect>, {a?: string}>>, + Expect>, {a: string, b?: string}>>, + Expect>, {}>>, + Expect>>, Record>>, + Expect>>, Record>>, + Expect}>, { payload: Record}>>, + Expect any); + optionalFunctionUnion?: string | (() => any); + optionalFunctionUnionUndefined: string | (() => any) | undefined; + + // Should be omitted + requiredFunction: () => any; + optionalFunction?: () => any; + optionalFunctionUndefined: (() => any) | undefined; + }>>, { + requiredString: string + requiredUnion: number | boolean + + optionalString?: string; + optionalUnion?: number | string; + optionalStringUndefined?: string | undefined; + optionalUnionUndefined?: number | string | undefined; + requiredFunctionUnion?: string + optionalFunctionUnion?: string; + optionalFunctionUnionUndefined?: string + }>>, + + // unknown + Expect, unknown>>, + Expect, unknown[]>>, + Expect, [unknown, 1]>>, + Expect>, {a?: unknown}>>, + Expect>, {a?: unknown, b: 'hello'}>>, + + // never + Expect, never>>, + Expect>, {a: never}>>, + Expect>, {a: never, b:string}>>, + Expect>, {a: never, b: string} | {a: string, b: never}>>, + + // class + Expect>, {a: string}>>, +]; + +class MyClass { + a: string; + b: () => string; + + constructor() { + this.a = "hello"; + this.b = () => "world"; + } +} + +// real-world example: `InvoiceLineItem` from `stripe` +type Recursive = { + a: Date; + recur?: Recursive; +}; +declare const recursive: Jsonify; +expectType<{ a: string; recur?: Jsonify }>( + recursive.recur!.recur!.recur! +); + +// real-world example: `Temporal` from `@js-temporal/polyfill` +interface BooleanWithToJson extends Boolean { + toJSON(): string; +} + +// utils ------------------------------------------------------------ + +type Pretty = { [K in keyof T]: T[K] }; + +type AnyFunction = (...args: any[]) => unknown; + +type NeverToNull = [T] extends [never] ? null : T; + +// adapted from https://github.com/sindresorhus/type-fest/blob/main/source/empty-object.d.ts +declare const emptyObjectSymbol: unique symbol; +export type EmptyObject = { [emptyObjectSymbol]?: never }; + +// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts +type IsAny = 0 extends 1 & T ? true : false; diff --git a/packages/remix-server-runtime/links.ts b/packages/remix-server-runtime/links.ts new file mode 100644 index 0000000000..4cfab9f0f0 --- /dev/null +++ b/packages/remix-server-runtime/links.ts @@ -0,0 +1,192 @@ +type Primitive = null | undefined | string | number | boolean | symbol | bigint; + +type LiteralUnion = + | LiteralType + | (BaseType & Record); + +interface HtmlLinkProps { + /** + * Address of the hyperlink + */ + href?: string; + + /** + * How the element handles crossorigin requests + */ + crossOrigin?: "anonymous" | "use-credentials"; + + /** + * Relationship between the document containing the hyperlink and the destination resource + */ + rel: LiteralUnion< + | "alternate" + | "dns-prefetch" + | "icon" + | "manifest" + | "modulepreload" + | "next" + | "pingback" + | "preconnect" + | "prefetch" + | "preload" + | "prerender" + | "search" + | "stylesheet", + string + >; + + /** + * Applicable media: "screen", "print", "(max-width: 764px)" + */ + media?: string; + + /** + * Integrity metadata used in Subresource Integrity checks + */ + integrity?: string; + + /** + * Language of the linked resource + */ + hrefLang?: string; + + /** + * Hint for the type of the referenced resource + */ + type?: string; + + /** + * Referrer policy for fetches initiated by the element + */ + referrerPolicy?: + | "" + | "no-referrer" + | "no-referrer-when-downgrade" + | "same-origin" + | "origin" + | "strict-origin" + | "origin-when-cross-origin" + | "strict-origin-when-cross-origin" + | "unsafe-url"; + + /** + * Sizes of the icons (for rel="icon") + */ + sizes?: string; + + /** + * Potential destination for a preload request (for rel="preload" and rel="modulepreload") + */ + as?: LiteralUnion< + | "audio" + | "audioworklet" + | "document" + | "embed" + | "fetch" + | "font" + | "frame" + | "iframe" + | "image" + | "manifest" + | "object" + | "paintworklet" + | "report" + | "script" + | "serviceworker" + | "sharedworker" + | "style" + | "track" + | "video" + | "worker" + | "xslt", + string + >; + + /** + * Color to use when customizing a site's icon (for rel="mask-icon") + */ + color?: string; + + /** + * Whether the link is disabled + */ + disabled?: boolean; + + /** + * The title attribute has special semantics on this element: Title of the link; CSS style sheet set name. + */ + title?: string; + + /** + * Images to use in different situations, e.g., high-resolution displays, + * small monitors, etc. (for rel="preload") + */ + imageSrcSet?: string; + + /** + * Image sizes for different page layouts (for rel="preload") + */ + imageSizes?: string; +} + +interface HtmlLinkPreloadImage extends HtmlLinkProps { + /** + * Relationship between the document containing the hyperlink and the destination resource + */ + rel: "preload"; + + /** + * Potential destination for a preload request (for rel="preload" and rel="modulepreload") + */ + as: "image"; + + /** + * Address of the hyperlink + */ + href?: string; + + /** + * Images to use in different situations, e.g., high-resolution displays, + * small monitors, etc. (for rel="preload") + */ + imageSrcSet: string; + + /** + * Image sizes for different page layouts (for rel="preload") + */ + imageSizes?: string; +} + +/** + * Represents a `` element. + * + * WHATWG Specification: https://html.spec.whatwg.org/multipage/semantics.html#the-link-element + */ +export type HtmlLinkDescriptor = + // Must have an href *unless* it's a `` with an + // `imageSrcSet` and `imageSizes` props + | (HtmlLinkProps & Pick, "href">) + | (HtmlLinkPreloadImage & Pick, "imageSizes">) + | (HtmlLinkPreloadImage & + Pick, "href"> & { imageSizes?: never }); + +export interface PageLinkDescriptor + extends Omit< + HtmlLinkDescriptor, + | "href" + | "rel" + | "type" + | "sizes" + | "imageSrcSet" + | "imageSizes" + | "as" + | "color" + | "title" + > { + /** + * The absolute path of the page to prefetch. + */ + page: string; +} + +export type LinkDescriptor = HtmlLinkDescriptor | PageLinkDescriptor; diff --git a/packages/remix-server-runtime/markup.ts b/packages/remix-server-runtime/markup.ts new file mode 100644 index 0000000000..4ab1fdcc78 --- /dev/null +++ b/packages/remix-server-runtime/markup.ts @@ -0,0 +1,19 @@ +// This escapeHtml utility is based on https://github.com/zertosh/htmlescape +// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE + +// We've chosen to inline the utility here to reduce the number of npm dependencies we have, +// slightly decrease the code size compared the original package and make it esm compatible. + +const ESCAPE_LOOKUP: { [match: string]: string } = { + "&": "\\u0026", + ">": "\\u003e", + "<": "\\u003c", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +const ESCAPE_REGEX = /[&><\u2028\u2029]/g; + +export function escapeHtml(html: string) { + return html.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]); +} diff --git a/packages/remix-server-runtime/mode.ts b/packages/remix-server-runtime/mode.ts new file mode 100644 index 0000000000..903aaa3bee --- /dev/null +++ b/packages/remix-server-runtime/mode.ts @@ -0,0 +1,16 @@ +/** + * The mode to use when running the server. + */ +export enum ServerMode { + Development = "development", + Production = "production", + Test = "test", +} + +export function isServerMode(value: any): value is ServerMode { + return ( + value === ServerMode.Development || + value === ServerMode.Production || + value === ServerMode.Test + ); +} diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json new file mode 100644 index 0000000000..b0e6a75170 --- /dev/null +++ b/packages/remix-server-runtime/package.json @@ -0,0 +1,51 @@ +{ + "name": "@remix-run/server-runtime", + "version": "2.9.0-pre.0", + "description": "Server runtime for Remix", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-server-runtime" + }, + "license": "MIT", + "sideEffects": false, + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "module": "dist/esm/index.js", + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "@remix-run/router": "1.16.0-pre.0", + "@types/cookie": "^0.6.0", + "@web3-storage/multipart-parser": "^1.0.0", + "cookie": "^0.6.0", + "set-cookie-parser": "^2.4.8", + "source-map": "^0.7.3", + "turbo-stream": "^2.0.0" + }, + "devDependencies": { + "@types/set-cookie-parser": "^2.4.1", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/remix-server-runtime/reexport.ts b/packages/remix-server-runtime/reexport.ts new file mode 100644 index 0000000000..cc7fc46f50 --- /dev/null +++ b/packages/remix-server-runtime/reexport.ts @@ -0,0 +1,63 @@ +export type { ErrorResponse } from "@remix-run/router"; + +export type { + HandleDataRequestFunction, + HandleDocumentRequestFunction, + HandleErrorFunction, + ServerBuild, + ServerEntryModule, +} from "./build"; + +export type { UploadHandlerPart, UploadHandler } from "./formData"; +export type { + MemoryUploadHandlerOptions, + MemoryUploadHandlerFilterArgs, +} from "./upload/memoryUploadHandler"; + +export type { + Cookie, + CookieOptions, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, +} from "./cookies"; + +export type { SignFunction, UnsignFunction } from "./crypto"; + +export type { AppLoadContext } from "./data"; + +export type { EntryContext } from "./entry"; + +export type { + HtmlLinkDescriptor, + LinkDescriptor, + PageLinkDescriptor, +} from "./links"; + +export type { TypedDeferredData, TypedResponse } from "./responses"; + +export type { + ActionFunction, + ActionFunctionArgs, + DataFunctionArgs, + HeadersArgs, + HeadersFunction, + LinksFunction, + LoaderFunction, + LoaderFunctionArgs, + ServerRuntimeMetaArgs, + ServerRuntimeMetaDescriptor, + ServerRuntimeMetaFunction, +} from "./routeModules"; + +export type { SerializeFrom } from "./serialize"; + +export type { RequestHandler } from "./server"; + +export type { + Session, + SessionData, + SessionIdStorageStrategy, + SessionStorage, + FlashSessionData, +} from "./sessions"; diff --git a/packages/remix-server-runtime/responses.ts b/packages/remix-server-runtime/responses.ts new file mode 100644 index 0000000000..32a609cd5c --- /dev/null +++ b/packages/remix-server-runtime/responses.ts @@ -0,0 +1,211 @@ +import { + defer as routerDefer, + json as routerJson, + redirect as routerRedirect, + redirectDocument as routerRedirectDocument, + type UNSAFE_DeferredData as DeferredData, + type TrackedPromise, +} from "@remix-run/router"; + +import { serializeError } from "./errors"; +import type { ServerMode } from "./mode"; + +declare const typedDeferredDataBrand: unique symbol; + +export type TypedDeferredData> = Pick< + DeferredData, + "init" +> & { + data: Data; + readonly [typedDeferredDataBrand]: "TypedDeferredData"; +}; + +export type DeferFunction = >( + data: Data, + init?: number | ResponseInit +) => TypedDeferredData; + +export type JsonFunction = ( + data: Data, + init?: number | ResponseInit +) => TypedResponse; + +// must be a type since this is a subtype of response +// interfaces must conform to the types they extend +export type TypedResponse = Omit & { + json(): Promise; +}; + +/** + * This is a shortcut for creating `application/json` responses. Converts `data` + * to JSON and sets the `Content-Type` header. + * + * @see https://remix.run/utils/json + */ +export const json: JsonFunction = (data, init = {}) => { + return routerJson(data, init); +}; + +/** + * This is a shortcut for creating Remix deferred responses + * + * @see https://remix.run/utils/defer + */ +export const defer: DeferFunction = (data, init = {}) => { + return routerDefer(data, init) as unknown as TypedDeferredData; +}; + +export type RedirectFunction = ( + url: string, + init?: number | ResponseInit +) => TypedResponse; + +/** + * A redirect response. Sets the status code and the `Location` header. + * Defaults to "302 Found". + * + * @see https://remix.run/utils/redirect + */ +export const redirect: RedirectFunction = (url, init = 302) => { + return routerRedirect(url, init) as TypedResponse; +}; + +/** + * A redirect response that will force a document reload to the new location. + * Sets the status code and the `Location` header. + * Defaults to "302 Found". + * + * @see https://remix.run/utils/redirect + */ +export const redirectDocument: RedirectFunction = (url, init = 302) => { + return routerRedirectDocument(url, init) as TypedResponse; +}; + +export function isDeferredData(value: any): value is DeferredData { + let deferred: DeferredData = value; + return ( + deferred && + typeof deferred === "object" && + typeof deferred.data === "object" && + typeof deferred.subscribe === "function" && + typeof deferred.cancel === "function" && + typeof deferred.resolveData === "function" + ); +} + +export function isResponse(value: any): value is Response { + return ( + value != null && + typeof value.status === "number" && + typeof value.statusText === "string" && + typeof value.headers === "object" && + typeof value.body !== "undefined" + ); +} + +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); +export function isRedirectStatusCode(statusCode: number): boolean { + return redirectStatusCodes.has(statusCode); +} +export function isRedirectResponse(response: Response): boolean { + return isRedirectStatusCode(response.status); +} + +function isTrackedPromise(value: any): value is TrackedPromise { + return ( + value != null && typeof value.then === "function" && value._tracked === true + ); +} + +// TODO: Figure out why ReadableStream types are borked sooooooo badly +// in this file. Probably related to our TS configurations and configs +// bleeding into each other. +const DEFERRED_VALUE_PLACEHOLDER_PREFIX = "__deferred_promise:"; +export function createDeferredReadableStream( + deferredData: DeferredData, + signal: AbortSignal, + serverMode: ServerMode +): any { + let encoder = new TextEncoder(); + let stream = new ReadableStream({ + async start(controller: any) { + let criticalData: any = {}; + + let preresolvedKeys: string[] = []; + for (let [key, value] of Object.entries(deferredData.data)) { + if (isTrackedPromise(value)) { + criticalData[key] = `${DEFERRED_VALUE_PLACEHOLDER_PREFIX}${key}`; + if ( + typeof value._data !== "undefined" || + typeof value._error !== "undefined" + ) { + preresolvedKeys.push(key); + } + } else { + criticalData[key] = value; + } + } + + // Send the critical data + controller.enqueue(encoder.encode(JSON.stringify(criticalData) + "\n\n")); + + for (let preresolvedKey of preresolvedKeys) { + enqueueTrackedPromise( + controller, + encoder, + preresolvedKey, + deferredData.data[preresolvedKey] as TrackedPromise, + serverMode + ); + } + + let unsubscribe = deferredData.subscribe((aborted, settledKey) => { + if (settledKey) { + enqueueTrackedPromise( + controller, + encoder, + settledKey, + deferredData.data[settledKey] as TrackedPromise, + serverMode + ); + } + }); + await deferredData.resolveData(signal); + unsubscribe(); + controller.close(); + }, + }); + + return stream; +} + +function enqueueTrackedPromise( + controller: any, + encoder: TextEncoder, + settledKey: string, + promise: TrackedPromise, + serverMode: ServerMode +) { + if ("_error" in promise) { + controller.enqueue( + encoder.encode( + "error:" + + JSON.stringify({ + [settledKey]: + promise._error instanceof Error + ? serializeError(promise._error, serverMode) + : promise._error, + }) + + "\n\n" + ) + ); + } else { + controller.enqueue( + encoder.encode( + "data:" + + JSON.stringify({ [settledKey]: promise._data ?? null }) + + "\n\n" + ) + ); + } +} diff --git a/packages/remix-server-runtime/rollup.config.js b/packages/remix-server-runtime/rollup.config.js new file mode 100644 index 0000000000..66faabb57b --- /dev/null +++ b/packages/remix-server-runtime/rollup.config.js @@ -0,0 +1,73 @@ +/* eslint-disable import/no-nodejs-modules */ +const path = require("node:path"); +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + getOutputDir, + isBareModuleId, + createBanner, + copyToPlaygrounds, +} = require("../../rollup.utils"); +const { name: packageName, version } = require("./package.json"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + let sourceDir = "packages/remix-server-runtime"; + let outputDir = getOutputDir(packageName); + let outputDist = path.join(outputDir, "dist"); + + return [ + { + external(id) { + return isBareModuleId(id); + }, + input: `${sourceDir}/index.ts`, + output: { + banner: createBanner(packageName, version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "named", + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts", ".tsx"], + }), + nodeResolve({ extensions: [".ts", ".tsx"] }), + copy({ + targets: [ + { src: "LICENSE.md", dest: [outputDir, sourceDir] }, + { src: `${sourceDir}/package.json`, dest: outputDir }, + { src: `${sourceDir}/README.md`, dest: outputDir }, + ], + }), + copyToPlaygrounds(), + ], + }, + { + external(id) { + return isBareModuleId(id); + }, + input: `${sourceDir}/index.ts`, + output: { + banner: createBanner(packageName, version), + dir: `${outputDist}/esm`, + format: "esm", + preserveModules: true, + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts", ".tsx"], + }), + nodeResolve({ extensions: [".ts", ".tsx"] }), + copyToPlaygrounds(), + ], + }, + ]; +}; diff --git a/packages/remix-server-runtime/routeMatching.ts b/packages/remix-server-runtime/routeMatching.ts new file mode 100644 index 0000000000..fe8d20a4c3 --- /dev/null +++ b/packages/remix-server-runtime/routeMatching.ts @@ -0,0 +1,29 @@ +import type { Params, AgnosticRouteObject } from "@remix-run/router"; +import { matchRoutes } from "@remix-run/router"; + +import type { ServerRoute } from "./routes"; + +export interface RouteMatch { + params: Params; + pathname: string; + route: Route; +} + +export function matchServerRoutes( + routes: ServerRoute[], + pathname: string, + basename?: string +): RouteMatch[] | null { + let matches = matchRoutes( + routes as unknown as AgnosticRouteObject[], + pathname, + basename + ); + if (!matches) return null; + + return matches.map((match) => ({ + params: match.params, + pathname: match.pathname, + route: match.route as unknown as ServerRoute, + })); +} diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts new file mode 100644 index 0000000000..74832b6b38 --- /dev/null +++ b/packages/remix-server-runtime/routeModules.ts @@ -0,0 +1,288 @@ +import type { + ActionFunction as RRActionFunction, + ActionFunctionArgs as RRActionFunctionArgs, + AgnosticRouteMatch, + LoaderFunction as RRLoaderFunction, + LoaderFunctionArgs as RRLoaderFunctionArgs, + Location, + Params, +} from "@remix-run/router"; + +import type { AppData, AppLoadContext } from "./data"; +import type { LinkDescriptor } from "./links"; +import type { SerializeFrom } from "./serialize"; + +export interface RouteModules { + [routeId: string]: RouteModule | undefined; +} + +/** + * @deprecated Use `LoaderFunctionArgs`/`ActionFunctionArgs` instead + */ +export type DataFunctionArgs = RRActionFunctionArgs & + RRLoaderFunctionArgs & { + // Context is always provided in Remix, and typed for module augmentation support. + // RR also doesn't export DataFunctionArgs, so we extend the two interfaces here + // even tough they're identical under the hood + context: AppLoadContext; + }; + +export const ResponseStubOperationsSymbol = Symbol("ResponseStubOperations"); +export type ResponseStubOperation = [ + "set" | "append" | "delete", + string, + string? +]; +/** + * A stubbed response to let you set the status/headers of your response from + * loader/action functions + */ +export type ResponseStub = { + status: number | undefined; + headers: Headers; + [ResponseStubOperationsSymbol]: ResponseStubOperation[]; +}; + +/** + * A function that handles data mutations for a route on the server + */ +export type ActionFunction = ( + args: ActionFunctionArgs +) => ReturnType; + +/** + * Arguments passed to a route `action` function + */ +export type ActionFunctionArgs = RRActionFunctionArgs & { + // Context is always provided in Remix, and typed for module augmentation support. + context: AppLoadContext; + // TODO: (v7) Make this non-optional once single-fetch is the default + response?: ResponseStub; +}; + +/** + * A function that handles data mutations for a route on the client + * @private Public API is exported from @remix-run/react + */ +type ClientActionFunction = ( + args: ClientActionFunctionArgs +) => ReturnType; + +/** + * Arguments passed to a route `clientAction` function + * @private Public API is exported from @remix-run/react + */ +export type ClientActionFunctionArgs = RRActionFunctionArgs & { + serverAction: () => Promise>; +}; + +/** + * A function that loads data for a route on the server + */ +export type LoaderFunction = ( + args: LoaderFunctionArgs +) => ReturnType; + +/** + * Arguments passed to a route `loader` function + */ +export type LoaderFunctionArgs = RRLoaderFunctionArgs & { + // Context is always provided in Remix, and typed for module augmentation support. + context: AppLoadContext; + // TODO: (v7) Make this non-optional once single-fetch is the default + response?: ResponseStub; +}; + +/** + * A function that loads data for a route on the client + * @private Public API is exported from @remix-run/react + */ +type ClientLoaderFunction = (( + args: ClientLoaderFunctionArgs +) => ReturnType) & { + hydrate?: boolean; +}; + +/** + * Arguments passed to a route `clientLoader` function + * @private Public API is exported from @remix-run/react + */ +export type ClientLoaderFunctionArgs = RRLoaderFunctionArgs & { + serverLoader: () => Promise>; +}; + +export type HeadersArgs = { + loaderHeaders: Headers; + parentHeaders: Headers; + actionHeaders: Headers; + errorHeaders: Headers | undefined; +}; + +/** + * A function that returns HTTP headers to be used for a route. These headers + * will be merged with (and take precedence over) headers from parent routes. + */ +export interface HeadersFunction { + (args: HeadersArgs): Headers | HeadersInit; +} + +/** + * A function that defines `` tags to be inserted into the `` of + * the document on route transitions. + */ +export interface LinksFunction { + (): LinkDescriptor[]; +} + +/** + * A function that returns an array of data objects to use for rendering + * metadata HTML tags in a route. These tags are not rendered on descendant + * routes in the route hierarchy. In other words, they will only be rendered on + * the route in which they are exported. + * + * @param Loader - The type of the current route's loader function + * @param MatchLoaders - Mapping from a parent route's filepath to its loader + * function type + * + * Note that parent route filepaths are relative to the `app/` directory. + * + * For example, if this meta function is for `/sales/customers/$customerId`: + * + * ```ts + * // app/root.tsx + * const loader = () => { + * return json({ hello: "world" } as const) + * } + * export type Loader = typeof loader + * + * // app/routes/sales.tsx + * const loader = () => { + * return json({ salesCount: 1074 }) + * } + * export type Loader = typeof loader + * + * // app/routes/sales/customers.tsx + * const loader = () => { + * return json({ customerCount: 74 }) + * } + * export type Loader = typeof loader + * + * // app/routes/sales/customers/$customersId.tsx + * import type { Loader as RootLoader } from "../../../root" + * import type { Loader as SalesLoader } from "../../sales" + * import type { Loader as CustomersLoader } from "../../sales/customers" + * + * const loader = () => { + * return json({ name: "Customer name" }) + * } + * + * const meta: MetaFunction = ({ data, matches }) => { + * const { name } = data + * // ^? string + * const { customerCount } = matches.find((match) => match.id === "routes/sales/customers").data + * // ^? number + * const { salesCount } = matches.find((match) => match.id === "routes/sales").data + * // ^? number + * const { hello } = matches.find((match) => match.id === "root").data + * // ^? "world" + * } + * ``` + */ +export interface ServerRuntimeMetaFunction< + Loader extends LoaderFunction | unknown = unknown, + ParentsLoaders extends Record = Record< + string, + unknown + > +> { + ( + args: ServerRuntimeMetaArgs + ): ServerRuntimeMetaDescriptor[]; +} + +interface ServerRuntimeMetaMatch< + RouteId extends string = string, + Loader extends LoaderFunction | unknown = unknown +> { + id: RouteId; + pathname: AgnosticRouteMatch["pathname"]; + data: Loader extends LoaderFunction ? SerializeFrom : unknown; + handle?: RouteHandle; + params: AgnosticRouteMatch["params"]; + meta: ServerRuntimeMetaDescriptor[]; + error?: unknown; +} + +type ServerRuntimeMetaMatches< + MatchLoaders extends Record = Record< + string, + unknown + > +> = Array< + { + [K in keyof MatchLoaders]: ServerRuntimeMetaMatch< + Exclude, + MatchLoaders[K] + >; + }[keyof MatchLoaders] +>; + +export interface ServerRuntimeMetaArgs< + Loader extends LoaderFunction | unknown = unknown, + MatchLoaders extends Record = Record< + string, + unknown + > +> { + data: + | (Loader extends LoaderFunction ? SerializeFrom : AppData) + | undefined; + params: Params; + location: Location; + matches: ServerRuntimeMetaMatches; + error?: unknown; +} + +export type ServerRuntimeMetaDescriptor = + | { charSet: "utf-8" } + | { title: string } + | { name: string; content: string } + | { property: string; content: string } + | { httpEquiv: string; content: string } + | { "script:ld+json": LdJsonObject } + | { tagName: "meta" | "link"; [name: string]: string } + | { [name: string]: unknown }; + +type LdJsonObject = { [Key in string]: LdJsonValue } & { + [Key in string]?: LdJsonValue | undefined; +}; +type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[]; +type LdJsonPrimitive = string | number | boolean | null; +type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray; + +/** + * An arbitrary object that is associated with a route. + */ +export type RouteHandle = unknown; + +export interface EntryRouteModule { + clientAction?: ClientActionFunction; + clientLoader?: ClientLoaderFunction; + ErrorBoundary?: any; // Weakly typed because server-runtime is not React-aware + HydrateFallback?: any; // Weakly typed because server-runtime is not React-aware + Layout?: any; // Weakly typed because server-runtime is not React-aware + default: any; // Weakly typed because server-runtime is not React-aware + handle?: RouteHandle; + links?: LinksFunction; + meta?: ServerRuntimeMetaFunction; +} + +export interface ServerRouteModule extends EntryRouteModule { + action?: ActionFunction; + headers?: HeadersFunction | { [name: string]: string }; + loader?: LoaderFunction; +} diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts new file mode 100644 index 0000000000..5aade6661d --- /dev/null +++ b/packages/remix-server-runtime/routes.ts @@ -0,0 +1,141 @@ +import type { + AgnosticDataRouteObject, + LoaderFunctionArgs as RRLoaderFunctionArgs, + ActionFunctionArgs as RRActionFunctionArgs, +} from "@remix-run/router"; + +import { callRouteAction, callRouteLoader } from "./data"; +import type { FutureConfig } from "./entry"; +import type { ResponseStub, ServerRouteModule } from "./routeModules"; + +export interface RouteManifest { + [routeId: string]: Route; +} + +export type ServerRouteManifest = RouteManifest>; + +// NOTE: make sure to change the Route in remix-react if you change this +export interface Route { + index?: boolean; + caseSensitive?: boolean; + id: string; + parentId?: string; + path?: string; +} + +// NOTE: make sure to change the EntryRoute in remix-react if you change this +export interface EntryRoute extends Route { + hasAction: boolean; + hasLoader: boolean; + hasClientAction: boolean; + hasClientLoader: boolean; + hasErrorBoundary: boolean; + imports?: string[]; + css?: string[]; + module: string; + parentId?: string; +} + +export interface ServerRoute extends Route { + children: ServerRoute[]; + module: ServerRouteModule; +} + +function groupRoutesByParentId(manifest: ServerRouteManifest) { + let routes: Record[]> = {}; + + Object.values(manifest).forEach((route) => { + let parentId = route.parentId || ""; + if (!routes[parentId]) { + routes[parentId] = []; + } + routes[parentId].push(route); + }); + + return routes; +} + +// Create a map of routes by parentId to use recursively instead of +// repeatedly filtering the manifest. +export function createRoutes( + manifest: ServerRouteManifest, + parentId: string = "", + routesByParentId: Record< + string, + Omit[] + > = groupRoutesByParentId(manifest) +): ServerRoute[] { + return (routesByParentId[parentId] || []).map((route) => ({ + ...route, + children: createRoutes(manifest, route.id, routesByParentId), + })); +} + +// Convert the Remix ServerManifest into DataRouteObject's for use with +// createStaticHandler +export function createStaticHandlerDataRoutes( + manifest: ServerRouteManifest, + future: FutureConfig, + parentId: string = "", + routesByParentId: Record< + string, + Omit[] + > = groupRoutesByParentId(manifest) +): AgnosticDataRouteObject[] { + return (routesByParentId[parentId] || []).map((route) => { + let commonRoute = { + // Always include root due to default boundaries + hasErrorBoundary: + route.id === "root" || route.module.ErrorBoundary != null, + id: route.id, + path: route.path, + loader: route.module.loader + ? // Need to use RR's version here to permit the optional context even + // though we know it'll always be provided in remix + (args: RRLoaderFunctionArgs, dataStrategyCtx?: unknown) => + callRouteLoader({ + request: args.request, + params: args.params, + loadContext: args.context, + loader: route.module.loader!, + routeId: route.id, + singleFetch: future.unstable_singleFetch === true, + response: ( + dataStrategyCtx as unknown as { response: ResponseStub } + )?.response, + }) + : undefined, + action: route.module.action + ? (args: RRActionFunctionArgs, dataStrategyCtx?: unknown) => + callRouteAction({ + request: args.request, + params: args.params, + loadContext: args.context, + action: route.module.action!, + routeId: route.id, + singleFetch: future.unstable_singleFetch === true, + response: ( + dataStrategyCtx as unknown as { response: ResponseStub } + )?.response, + }) + : undefined, + handle: route.module.handle, + }; + + return route.index + ? { + index: true, + ...commonRoute, + } + : { + caseSensitive: route.caseSensitive, + children: createStaticHandlerDataRoutes( + manifest, + future, + route.id, + routesByParentId + ), + ...commonRoute, + }; + }); +} diff --git a/packages/remix-server-runtime/serialize.ts b/packages/remix-server-runtime/serialize.ts new file mode 100644 index 0000000000..c3d4822f49 --- /dev/null +++ b/packages/remix-server-runtime/serialize.ts @@ -0,0 +1,177 @@ +import type { EmptyObject, Jsonify } from "./jsonify"; +import type { TypedDeferredData, TypedResponse } from "./responses"; +import type { + ClientActionFunctionArgs, + ClientLoaderFunctionArgs, +} from "./routeModules"; +import { expectType } from "./typecheck"; +import { type Expect, type Equal } from "./typecheck"; + +// prettier-ignore +/** + * Infer JSON serialized data type returned by a loader or action, while + * avoiding deserialization if the input type if it's a clientLoader or + * clientAction that returns a non-Response + * + * For example: + * `type LoaderData = SerializeFrom` + */ +export type SerializeFrom = + T extends (...args: any[]) => infer Output ? + Parameters extends [ClientLoaderFunctionArgs | ClientActionFunctionArgs] ? + // Client data functions may not serialize + SerializeClient> + : + // Serialize responses + Serialize> + : + // Back compat: manually defined data type, not inferred from loader nor action + Jsonify> +; + +// note: cannot be inlined as logic requires union distribution +// prettier-ignore +type SerializeClient = + Output extends TypedDeferredData ? + // top-level promises + & { + [K in keyof U as K extends symbol + ? never + : Promise extends U[K] + ? K + : never]: DeferValueClient; // use generic to distribute over union + } + // non-promises + & { + [K in keyof U as Promise extends U[K] ? never : K]: U[K]; + } + : + Output extends TypedResponse ? Jsonify : + Awaited + +// prettier-ignore +type DeferValueClient = + T extends undefined ? undefined : + T extends Promise ? Promise> : + T; + +// note: cannot be inlined as logic requires union distribution +// prettier-ignore +type Serialize = + Output extends TypedDeferredData ? + // top-level promises + & { + [K in keyof U as + K extends symbol ? never : + Promise extends U[K] ? K : + never + ]: DeferValue; // use generic to distribute over union + } + // non-promises + & Jsonify<{ + [K in keyof U as + Promise extends U[K] ? never : + K + ]: U[K]; + }> + : + Output extends TypedResponse ? Jsonify : + Jsonify; + +// prettier-ignore +type DeferValue = + T extends undefined ? undefined : + T extends Promise ? Promise>> : + Jsonify; + +// tests ------------------------------------------------------------ + +type Pretty = { [K in keyof T]: T[K] }; + +type Loader = () => Promise>; + +type LoaderDefer> = () => Promise< + TypedDeferredData +>; + +type LoaderBoth< + T1 extends Record, + T2 extends Record +> = () => Promise | TypedDeferredData>; + +type ClientLoaderRaw> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise; // returned non-Response + +type ClientLoaderResponse> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise>; // returned responses + +type ClientLoaderDefer> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise>; // returned responses + +type ClientLoaderResponseAndDefer< + T1 extends Record, + T2 extends Record +> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise< + TypedResponse | TypedDeferredData +>; + +type ClientLoaderRawAndDefer< + T1 extends Record, + T2 extends Record +> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise>; + +// prettier-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type _tests = [ + // back compat: plain object + Expect>, {a: string}>>, + + // only thrown responses (e.g. redirects) + Expect>, never>>, + + // basic loader data + Expect>>, {a: string}>>, + + // infer data type from `toJSON` + Expect>>, {a: string}>>, + + // regression test for specific field names + Expect>>, {a: string, name: number, data: boolean}>>, + + // defer top-level promises + Expect}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false>, + + // conditional defer or json + Expect }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: EmptyObject } | { c: string; lazy: Promise<{ d: number }> } ? true : false>, + + // clientLoader raw JSON + Expect>>, {a: string}>>, + Expect }>>>, {a: Date, b: Map}>>, + + // clientLoader json() Response + Expect>>, {a: string}>>, + Expect>>, {a: string}>>, + + // clientLoader defer() data + Expect}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false>, + + // clientLoader conditional defer or json + Expect }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: EmptyObject } | { c: string; lazy: Promise<{ d: number }> } ? true : false>, + + // clientLoader conditional defer or raw + Expect }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: Promise } | { c: string; lazy: Promise<{ d: number }> } ? true : false>, +]; + +// recursive +type Recursive = { a: string; recur?: Recursive }; +declare const recursive: SerializeFrom>; +expectType<{ a: string; recur?: Jsonify }>( + recursive.recur!.recur!.recur! +); diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts new file mode 100644 index 0000000000..269f56551c --- /dev/null +++ b/packages/remix-server-runtime/server.ts @@ -0,0 +1,680 @@ +import type { + UNSAFE_DeferredData as DeferredData, + ErrorResponse, + StaticHandler, +} from "@remix-run/router"; +import { + UNSAFE_DEFERRED_SYMBOL as DEFERRED_SYMBOL, + getStaticContextFromError, + isRouteErrorResponse, + createStaticHandler, + json as routerJson, + stripBasename, + UNSAFE_ErrorResponseImpl as ErrorResponseImpl, +} from "@remix-run/router"; + +import type { AppLoadContext } from "./data"; +import type { HandleErrorFunction, ServerBuild } from "./build"; +import type { EntryContext } from "./entry"; +import { createEntryRouteModules } from "./entry"; +import { sanitizeErrors, serializeError, serializeErrors } from "./errors"; +import { getDocumentHeaders } from "./headers"; +import invariant from "./invariant"; +import { ServerMode, isServerMode } from "./mode"; +import { matchServerRoutes } from "./routeMatching"; +import type { ServerRoute } from "./routes"; +import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; +import { + createDeferredReadableStream, + isRedirectResponse, + isRedirectStatusCode, + isResponse, +} from "./responses"; +import { createServerHandoffString } from "./serverHandoff"; +import { getDevServerHooks } from "./dev"; +import type { SingleFetchResult, SingleFetchResults } from "./single-fetch"; +import { + encodeViaTurboStream, + getResponseStubs, + getSingleFetchDataStrategy, + getSingleFetchRedirect, + mergeResponseStubs, + singleFetchAction, + singleFetchLoaders, + SingleFetchRedirectSymbol, +} from "./single-fetch"; + +export type RequestHandler = ( + request: Request, + loadContext?: AppLoadContext +) => Promise; + +export type CreateRequestHandlerFunction = ( + build: ServerBuild | (() => ServerBuild | Promise), + mode?: string +) => RequestHandler; + +function derive(build: ServerBuild, mode?: string) { + let routes = createRoutes(build.routes); + let dataRoutes = createStaticHandlerDataRoutes(build.routes, build.future); + let serverMode = isServerMode(mode) ? mode : ServerMode.Production; + let staticHandler = createStaticHandler(dataRoutes, { + basename: build.basename, + future: { + v7_relativeSplatPath: build.future?.v3_relativeSplatPath === true, + v7_throwAbortReason: build.future?.v3_throwAbortReason === true, + }, + }); + + let errorHandler = + build.entry.module.handleError || + ((error, { request }) => { + if (serverMode !== ServerMode.Test && !request.signal.aborted) { + console.error( + // @ts-expect-error This is "private" from users but intended for internal use + isRouteErrorResponse(error) && error.error ? error.error : error + ); + } + }); + return { + routes, + dataRoutes, + serverMode, + staticHandler, + errorHandler, + }; +} + +export const createRequestHandler: CreateRequestHandlerFunction = ( + build, + mode +) => { + let _build: ServerBuild; + let routes: ServerRoute[]; + let serverMode: ServerMode; + let staticHandler: StaticHandler; + let errorHandler: HandleErrorFunction; + + return async function requestHandler(request, loadContext = {}) { + _build = typeof build === "function" ? await build() : build; + mode ??= _build.mode; + if (typeof build === "function") { + let derived = derive(_build, mode); + routes = derived.routes; + serverMode = derived.serverMode; + staticHandler = derived.staticHandler; + errorHandler = derived.errorHandler; + } else if (!routes || !serverMode || !staticHandler || !errorHandler) { + let derived = derive(_build, mode); + routes = derived.routes; + serverMode = derived.serverMode; + staticHandler = derived.staticHandler; + errorHandler = derived.errorHandler; + } + + let url = new URL(request.url); + + let matches = matchServerRoutes(routes, url.pathname, _build.basename); + let params = matches && matches.length > 0 ? matches[0].params : {}; + let handleError = (error: unknown) => { + if (mode === ServerMode.Development) { + getDevServerHooks()?.processRequestError?.(error); + } + + errorHandler(error, { + context: loadContext, + params, + request, + }); + }; + + let response: Response; + if (url.searchParams.has("_data")) { + if (_build.future.unstable_singleFetch) { + handleError( + new Error( + "Warning: Single fetch-enabled apps should not be making ?_data requests, " + + "this is likely to break in the future" + ) + ); + } + let routeId = url.searchParams.get("_data")!; + + response = await handleDataRequest( + serverMode, + _build, + staticHandler, + routeId, + request, + loadContext, + handleError + ); + + if (_build.entry.module.handleDataRequest) { + response = await _build.entry.module.handleDataRequest(response, { + context: loadContext, + params, + request, + }); + + if (isRedirectResponse(response)) { + response = createRemixRedirectResponse(response, _build.basename); + } + } + } else if ( + _build.future.unstable_singleFetch && + url.pathname.endsWith(".data") + ) { + let handlerUrl = new URL(request.url); + handlerUrl.pathname = handlerUrl.pathname + .replace(/\.data$/, "") + .replace(/^\/_root$/, "/"); + + let singleFetchMatches = matchServerRoutes( + routes, + handlerUrl.pathname, + _build.basename + ); + + response = await handleSingleFetchRequest( + serverMode, + _build, + staticHandler, + request, + handlerUrl, + loadContext, + handleError + ); + + if (_build.entry.module.handleDataRequest) { + response = await _build.entry.module.handleDataRequest(response, { + context: loadContext, + params: singleFetchMatches ? singleFetchMatches[0].params : {}, + request, + }); + + if (isRedirectResponse(response)) { + let result: SingleFetchResult | SingleFetchResults = + getSingleFetchRedirect(response.status, response.headers); + + if (request.method === "GET") { + result = { + [SingleFetchRedirectSymbol]: result, + }; + } + let headers = new Headers(response.headers); + headers.set("Content-Type", "text/x-turbo"); + + return new Response( + encodeViaTurboStream( + result, + request.signal, + _build.entry.module.streamTimeout, + serverMode + ), + { + status: 200, + headers, + } + ); + } + } + } else if ( + matches && + matches[matches.length - 1].route.module.default == null && + matches[matches.length - 1].route.module.ErrorBoundary == null + ) { + response = await handleResourceRequest( + serverMode, + staticHandler, + matches.slice(-1)[0].route.id, + request, + loadContext, + handleError + ); + } else { + let criticalCss = + mode === ServerMode.Development + ? await getDevServerHooks()?.getCriticalCss?.(_build, url.pathname) + : undefined; + + response = await handleDocumentRequest( + serverMode, + _build, + staticHandler, + request, + loadContext, + handleError, + criticalCss + ); + } + + if (request.method === "HEAD") { + return new Response(null, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + } + + return response; + }; +}; + +async function handleDataRequest( + serverMode: ServerMode, + build: ServerBuild, + staticHandler: StaticHandler, + routeId: string, + request: Request, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +) { + try { + let response = await staticHandler.queryRoute(request, { + routeId, + requestContext: loadContext, + }); + + if (isRedirectResponse(response)) { + return createRemixRedirectResponse(response, build.basename); + } + + if (DEFERRED_SYMBOL in response) { + let deferredData = response[DEFERRED_SYMBOL] as DeferredData; + let body = createDeferredReadableStream( + deferredData, + request.signal, + serverMode + ); + let init = deferredData.init || {}; + let headers = new Headers(init.headers); + headers.set("Content-Type", "text/remix-deferred"); + // Mark successful responses with a header so we can identify in-flight + // network errors that are missing this header + headers.set("X-Remix-Response", "yes"); + init.headers = headers; + return new Response(body, init); + } + + // Mark all successful responses with a header so we can identify in-flight + // network errors that are missing this header + response.headers.set("X-Remix-Response", "yes"); + return response; + } catch (error: unknown) { + if (isResponse(error)) { + error.headers.set("X-Remix-Catch", "yes"); + return error; + } + + if (isRouteErrorResponse(error)) { + handleError(error); + return errorResponseToJson(error, serverMode); + } + + let errorInstance = + error instanceof Error || error instanceof DOMException + ? error + : new Error("Unexpected Server Error"); + handleError(errorInstance); + return routerJson(serializeError(errorInstance, serverMode), { + status: 500, + headers: { + "X-Remix-Error": "yes", + }, + }); + } +} + +async function handleSingleFetchRequest( + serverMode: ServerMode, + build: ServerBuild, + staticHandler: StaticHandler, + request: Request, + handlerUrl: URL, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +): Promise { + let { result, headers, status } = + request.method !== "GET" + ? await singleFetchAction( + serverMode, + staticHandler, + request, + handlerUrl, + loadContext, + handleError + ) + : await singleFetchLoaders( + serverMode, + staticHandler, + request, + handlerUrl, + loadContext, + handleError + ); + + // Mark all successful responses with a header so we can identify in-flight + // network errors that are missing this header + let resultHeaders = new Headers(headers); + resultHeaders.set("X-Remix-Response", "yes"); + resultHeaders.set("Content-Type", "text/x-turbo"); + + // Note: Deferred data is already just Promises, so we don't have to mess + // `activeDeferreds` or anything :) + return new Response( + encodeViaTurboStream( + result, + request.signal, + build.entry.module.streamTimeout, + serverMode + ), + { + status: status || 200, + headers: resultHeaders, + } + ); +} + +async function handleDocumentRequest( + serverMode: ServerMode, + build: ServerBuild, + staticHandler: StaticHandler, + request: Request, + loadContext: AppLoadContext, + handleError: (err: unknown) => void, + criticalCss?: string +) { + let context; + let responseStubs = getResponseStubs(); + try { + context = await staticHandler.query(request, { + requestContext: loadContext, + unstable_dataStrategy: build.future.unstable_singleFetch + ? getSingleFetchDataStrategy(responseStubs) + : undefined, + }); + } catch (error: unknown) { + handleError(error); + return new Response(null, { status: 500 }); + } + + if (isResponse(context)) { + return context; + } + + // Sanitize errors outside of development environments + if (context.errors) { + Object.values(context.errors).forEach((err) => { + // @ts-expect-error This is "private" from users but intended for internal use + if (!isRouteErrorResponse(err) || err.error) { + handleError(err); + } + }); + context.errors = sanitizeErrors(context.errors, serverMode); + } + + let statusCode: number; + let headers: Headers; + if (build.future.unstable_singleFetch) { + let merged = mergeResponseStubs(context, responseStubs); + statusCode = merged.statusCode; + headers = merged.headers; + + if (isRedirectStatusCode(statusCode) && headers.has("Location")) { + return new Response(null, { + status: statusCode, + headers, + }); + } + } else { + statusCode = context.statusCode; + headers = getDocumentHeaders(build, context); + } + + // Server UI state to send to the client. + // - When single fetch is enabled, this is streamed down via `serverHandoffStream` + // - Otherwise it's stringified into `serverHandoffString` + let state = { + loaderData: context.loaderData, + actionData: context.actionData, + errors: serializeErrors(context.errors, serverMode), + }; + let entryContext: EntryContext = { + manifest: build.assets, + routeModules: createEntryRouteModules(build.routes), + staticHandlerContext: context, + criticalCss, + serverHandoffString: createServerHandoffString({ + url: context.location.pathname, + basename: build.basename, + criticalCss, + future: build.future, + isSpaMode: build.isSpaMode, + ...(!build.future.unstable_singleFetch ? { state } : null), + }), + ...(build.future.unstable_singleFetch + ? { + serverHandoffStream: encodeViaTurboStream( + state, + request.signal, + build.entry.module.streamTimeout, + serverMode + ), + renderMeta: {}, + } + : null), + future: build.future, + isSpaMode: build.isSpaMode, + serializeError: (err) => serializeError(err, serverMode), + }; + + let handleDocumentRequestFunction = build.entry.module.default; + try { + return await handleDocumentRequestFunction( + request, + statusCode, + headers, + entryContext, + loadContext + ); + } catch (error: unknown) { + handleError(error); + + let errorForSecondRender = error; + + // If they threw a response, unwrap it into an ErrorResponse like we would + // have for a loader/action + if (isResponse(error)) { + try { + let data = await unwrapResponse(error); + errorForSecondRender = new ErrorResponseImpl( + error.status, + error.statusText, + data + ); + } catch (e) { + // If we can't unwrap the response - just leave it as-is + } + } + + // Get a new StaticHandlerContext that contains the error at the right boundary + context = getStaticContextFromError( + staticHandler.dataRoutes, + context, + errorForSecondRender + ); + + // Sanitize errors outside of development environments + if (context.errors) { + context.errors = sanitizeErrors(context.errors, serverMode); + } + + // Get a new entryContext for the second render pass + // Server UI state to send to the client. + // - When single fetch is enabled, this is streamed down via `serverHandoffStream` + // - Otherwise it's stringified into `serverHandoffString` + let state = { + loaderData: context.loaderData, + actionData: context.actionData, + errors: serializeErrors(context.errors, serverMode), + }; + entryContext = { + ...entryContext, + staticHandlerContext: context, + serverHandoffString: createServerHandoffString({ + url: context.location.pathname, + basename: build.basename, + future: build.future, + isSpaMode: build.isSpaMode, + ...(!build.future.unstable_singleFetch ? { state } : null), + }), + ...(build.future.unstable_singleFetch + ? { + serverHandoffStream: encodeViaTurboStream( + state, + request.signal, + build.entry.module.streamTimeout, + serverMode + ), + renderMeta: {}, + } + : null), + }; + + try { + return await handleDocumentRequestFunction( + request, + context.statusCode, + headers, + entryContext, + loadContext + ); + } catch (error: any) { + handleError(error); + return returnLastResortErrorResponse(error, serverMode); + } + } +} + +async function handleResourceRequest( + serverMode: ServerMode, + staticHandler: StaticHandler, + routeId: string, + request: Request, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +) { + try { + // Note we keep the routeId here to align with the Remix handling of + // resource routes which doesn't take ?index into account and just takes + // the leaf match + let response = await staticHandler.queryRoute(request, { + routeId, + requestContext: loadContext, + }); + if (typeof response === "object") { + invariant( + !(DEFERRED_SYMBOL in response), + `You cannot return a \`defer()\` response from a Resource Route. Did you ` + + `forget to export a default UI component from the "${routeId}" route?` + ); + } + // callRouteLoader/callRouteAction always return responses (w/o single fetch). + // With single fetch, users should always be Responses from resource routes + invariant( + isResponse(response), + "Expected a Response to be returned from queryRoute" + ); + return response; + } catch (error: unknown) { + if (isResponse(error)) { + // Note: Not functionally required but ensures that our response headers + // match identically to what Remix returns + error.headers.set("X-Remix-Catch", "yes"); + return error; + } + + if (isRouteErrorResponse(error)) { + if (error) { + handleError(error); + } + return errorResponseToJson(error, serverMode); + } + + handleError(error); + return returnLastResortErrorResponse(error, serverMode); + } +} + +function errorResponseToJson( + errorResponse: ErrorResponse, + serverMode: ServerMode +): Response { + return routerJson( + serializeError( + // @ts-expect-error This is "private" from users but intended for internal use + errorResponse.error || new Error("Unexpected Server Error"), + serverMode + ), + { + status: errorResponse.status, + statusText: errorResponse.statusText, + headers: { + "X-Remix-Error": "yes", + }, + } + ); +} + +function returnLastResortErrorResponse(error: any, serverMode?: ServerMode) { + let message = "Unexpected Server Error"; + + if (serverMode !== ServerMode.Production) { + message += `\n\n${String(error)}`; + } + + // Good grief folks, get your act together 😂! + return new Response(message, { + status: 500, + headers: { + "Content-Type": "text/plain", + }, + }); +} + +function unwrapResponse(response: Response) { + let contentType = response.headers.get("Content-Type"); + // Check between word boundaries instead of startsWith() due to the last + // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type + return contentType && /\bapplication\/json\b/.test(contentType) + ? response.body == null + ? null + : response.json() + : response.text(); +} + +function createRemixRedirectResponse( + response: Response, + basename: string | undefined +) { + // We don't have any way to prevent a fetch request from following + // redirects. So we use the `X-Remix-Redirect` header to indicate the + // next URL, and then "follow" the redirect manually on the client. + let headers = new Headers(response.headers); + let redirectUrl = headers.get("Location")!; + headers.set( + "X-Remix-Redirect", + basename ? stripBasename(redirectUrl, basename) || redirectUrl : redirectUrl + ); + headers.set("X-Remix-Status", String(response.status)); + headers.delete("Location"); + if (response.headers.get("Set-Cookie") !== null) { + headers.set("X-Remix-Revalidate", "yes"); + } + + return new Response(null, { + status: 204, + headers, + }); +} diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts new file mode 100644 index 0000000000..7328388ac3 --- /dev/null +++ b/packages/remix-server-runtime/serverHandoff.ts @@ -0,0 +1,31 @@ +import type { HydrationState } from "@remix-run/router"; + +import type { FutureConfig } from "./entry"; +import { escapeHtml } from "./markup"; + +type ValidateShape = + // If it extends T + T extends Shape + ? // and there are no leftover props after removing the base + Exclude extends never + ? // we are good + T + : // otherwise it's either too many or too few props + never + : never; + +// TODO: Remove Promises from serialization +export function createServerHandoffString(serverHandoff: { + // Don't allow StaticHandlerContext to be passed in verbatim, since then + // we'd end up including duplicate info + state?: ValidateShape; + criticalCss?: string; + url: string; + basename: string | undefined; + future: FutureConfig; + isSpaMode: boolean; +}): string { + // Uses faster alternative of jsesc to escape data returned from the loaders. + // This string is inserted directly into the HTML in the `` element. + return escapeHtml(JSON.stringify(serverHandoff)); +} diff --git a/packages/remix-server-runtime/sessions.ts b/packages/remix-server-runtime/sessions.ts new file mode 100644 index 0000000000..c00eedb232 --- /dev/null +++ b/packages/remix-server-runtime/sessions.ts @@ -0,0 +1,314 @@ +import type { CookieParseOptions, CookieSerializeOptions } from "cookie"; + +import type { Cookie, CookieOptions, CreateCookieFunction } from "./cookies"; +import { isCookie } from "./cookies"; +import { warnOnce } from "./warnings"; + +/** + * An object of name/value pairs to be used in the session. + */ +export interface SessionData { + [name: string]: any; +} + +/** + * Session persists data across HTTP requests. + * + * @see https://remix.run/utils/sessions#session-api + */ +export interface Session { + /** + * A unique identifier for this session. + * + * Note: This will be the empty string for newly created sessions and + * sessions that are not backed by a database (i.e. cookie-based sessions). + */ + readonly id: string; + + /** + * The raw data contained in this session. + * + * This is useful mostly for SessionStorage internally to access the raw + * session data to persist. + */ + readonly data: FlashSessionData; + + /** + * Returns `true` if the session has a value for the given `name`, `false` + * otherwise. + */ + has(name: (keyof Data | keyof FlashData) & string): boolean; + + /** + * Returns the value for the given `name` in this session. + */ + get( + name: Key + ): + | (Key extends keyof Data ? Data[Key] : undefined) + | (Key extends keyof FlashData ? FlashData[Key] : undefined) + | undefined; + + /** + * Sets a value in the session for the given `name`. + */ + set(name: Key, value: Data[Key]): void; + + /** + * Sets a value in the session that is only valid until the next `get()`. + * This can be useful for temporary values, like error messages. + */ + flash( + name: Key, + value: FlashData[Key] + ): void; + + /** + * Removes a value from the session. + */ + unset(name: keyof Data & string): void; +} + +export type FlashSessionData = Partial< + Data & { + [Key in keyof FlashData as FlashDataKey]: FlashData[Key]; + } +>; +type FlashDataKey = `__flash_${Key}__`; +function flash(name: Key): FlashDataKey { + return `__flash_${name}__`; +} + +export type CreateSessionFunction = ( + initialData?: Data, + id?: string +) => Session; + +/** + * Creates a new Session object. + * + * Note: This function is typically not invoked directly by application code. + * Instead, use a `SessionStorage` object's `getSession` method. + * + * @see https://remix.run/utils/sessions#createsession + */ +export const createSession: CreateSessionFunction = < + Data = SessionData, + FlashData = Data +>( + initialData: Partial = {}, + id = "" +): Session => { + let map = new Map(Object.entries(initialData)) as Map< + keyof Data | FlashDataKey, + any + >; + + return { + get id() { + return id; + }, + get data() { + return Object.fromEntries(map) as FlashSessionData; + }, + has(name) { + return ( + map.has(name as keyof Data) || + map.has(flash(name as keyof FlashData & string)) + ); + }, + get(name) { + if (map.has(name as keyof Data)) return map.get(name as keyof Data); + + let flashName = flash(name as keyof FlashData & string); + if (map.has(flashName)) { + let value = map.get(flashName); + map.delete(flashName); + return value; + } + + return undefined; + }, + set(name, value) { + map.set(name, value); + }, + flash(name, value) { + map.set(flash(name), value); + }, + unset(name) { + map.delete(name); + }, + }; +}; + +export type IsSessionFunction = (object: any) => object is Session; + +/** + * Returns true if an object is a Remix session. + * + * @see https://remix.run/utils/sessions#issession + */ +export const isSession: IsSessionFunction = (object): object is Session => { + return ( + object != null && + typeof object.id === "string" && + typeof object.data !== "undefined" && + typeof object.has === "function" && + typeof object.get === "function" && + typeof object.set === "function" && + typeof object.flash === "function" && + typeof object.unset === "function" + ); +}; + +/** + * SessionStorage stores session data between HTTP requests and knows how to + * parse and create cookies. + * + * A SessionStorage creates Session objects using a `Cookie` header as input. + * Then, later it generates the `Set-Cookie` header to be used in the response. + */ +export interface SessionStorage { + /** + * Parses a Cookie header from a HTTP request and returns the associated + * Session. If there is no session associated with the cookie, this will + * return a new Session with no data. + */ + getSession: ( + cookieHeader?: string | null, + options?: CookieParseOptions + ) => Promise>; + + /** + * Stores all data in the Session and returns the Set-Cookie header to be + * used in the HTTP response. + */ + commitSession: ( + session: Session, + options?: CookieSerializeOptions + ) => Promise; + + /** + * Deletes all data associated with the Session and returns the Set-Cookie + * header to be used in the HTTP response. + */ + destroySession: ( + session: Session, + options?: CookieSerializeOptions + ) => Promise; +} + +/** + * SessionIdStorageStrategy is designed to allow anyone to easily build their + * own SessionStorage using `createSessionStorage(strategy)`. + * + * This strategy describes a common scenario where the session id is stored in + * a cookie but the actual session data is stored elsewhere, usually in a + * database or on disk. A set of create, read, update, and delete operations + * are provided for managing the session data. + */ +export interface SessionIdStorageStrategy< + Data = SessionData, + FlashData = Data +> { + /** + * The Cookie used to store the session id, or options used to automatically + * create one. + */ + cookie?: Cookie | (CookieOptions & { name?: string }); + + /** + * Creates a new record with the given data and returns the session id. + */ + createData: ( + data: FlashSessionData, + expires?: Date + ) => Promise; + + /** + * Returns data for a given session id, or `null` if there isn't any. + */ + readData: (id: string) => Promise | null>; + + /** + * Updates data for the given session id. + */ + updateData: ( + id: string, + data: FlashSessionData, + expires?: Date + ) => Promise; + + /** + * Deletes data for a given session id from the data store. + */ + deleteData: (id: string) => Promise; +} + +export type CreateSessionStorageFunction = < + Data = SessionData, + FlashData = Data +>( + strategy: SessionIdStorageStrategy +) => SessionStorage; + +/** + * Creates a SessionStorage object using a SessionIdStorageStrategy. + * + * Note: This is a low-level API that should only be used if none of the + * existing session storage options meet your requirements. + * + * @see https://remix.run/utils/sessions#createsessionstorage + */ +export const createSessionStorageFactory = + (createCookie: CreateCookieFunction): CreateSessionStorageFunction => + ({ cookie: cookieArg, createData, readData, updateData, deleteData }) => { + let cookie = isCookie(cookieArg) + ? cookieArg + : createCookie(cookieArg?.name || "__session", cookieArg); + + warnOnceAboutSigningSessionCookie(cookie); + + return { + async getSession(cookieHeader, options) { + let id = cookieHeader && (await cookie.parse(cookieHeader, options)); + let data = id && (await readData(id)); + return createSession(data || {}, id || ""); + }, + async commitSession(session, options) { + let { id, data } = session; + let expires = + options?.maxAge != null + ? new Date(Date.now() + options.maxAge * 1000) + : options?.expires != null + ? options.expires + : cookie.expires; + + if (id) { + await updateData(id, data, expires); + } else { + id = await createData(data, expires); + } + + return cookie.serialize(id, options); + }, + async destroySession(session, options) { + await deleteData(session.id); + return cookie.serialize("", { + ...options, + maxAge: undefined, + expires: new Date(0), + }); + }, + }; + }; + +export function warnOnceAboutSigningSessionCookie(cookie: Cookie) { + warnOnce( + cookie.isSigned, + `The "${cookie.name}" cookie is not signed, but session cookies should be ` + + `signed to prevent tampering on the client before they are sent back to the ` + + `server. See https://remix.run/utils/cookies#signing-cookies ` + + `for more information.` + ); +} diff --git a/packages/remix-server-runtime/sessions/cookieStorage.ts b/packages/remix-server-runtime/sessions/cookieStorage.ts new file mode 100644 index 0000000000..09a2e76568 --- /dev/null +++ b/packages/remix-server-runtime/sessions/cookieStorage.ts @@ -0,0 +1,69 @@ +import type { CreateCookieFunction } from "../cookies"; +import { isCookie } from "../cookies"; +import type { + SessionStorage, + SessionIdStorageStrategy, + SessionData, +} from "../sessions"; +import { warnOnceAboutSigningSessionCookie, createSession } from "../sessions"; + +interface CookieSessionStorageOptions { + /** + * The Cookie used to store the session data on the client, or options used + * to automatically create one. + */ + cookie?: SessionIdStorageStrategy["cookie"]; +} + +export type CreateCookieSessionStorageFunction = < + Data = SessionData, + FlashData = Data +>( + options?: CookieSessionStorageOptions +) => SessionStorage; + +/** + * Creates and returns a SessionStorage object that stores all session data + * directly in the session cookie itself. + * + * This has the advantage that no database or other backend services are + * needed, and can help to simplify some load-balanced scenarios. However, it + * also has the limitation that serialized session data may not exceed the + * browser's maximum cookie size. Trade-offs! + * + * @see https://remix.run/utils/sessions#createcookiesessionstorage + */ +export const createCookieSessionStorageFactory = + (createCookie: CreateCookieFunction): CreateCookieSessionStorageFunction => + ({ cookie: cookieArg } = {}) => { + let cookie = isCookie(cookieArg) + ? cookieArg + : createCookie(cookieArg?.name || "__session", cookieArg); + + warnOnceAboutSigningSessionCookie(cookie); + + return { + async getSession(cookieHeader, options) { + return createSession( + (cookieHeader && (await cookie.parse(cookieHeader, options))) || {} + ); + }, + async commitSession(session, options) { + let serializedCookie = await cookie.serialize(session.data, options); + if (serializedCookie.length > 4096) { + throw new Error( + "Cookie length will exceed browser maximum. Length: " + + serializedCookie.length + ); + } + return serializedCookie; + }, + async destroySession(_session, options) { + return cookie.serialize("", { + ...options, + maxAge: undefined, + expires: new Date(0), + }); + }, + }; + }; diff --git a/packages/remix-server-runtime/sessions/memoryStorage.ts b/packages/remix-server-runtime/sessions/memoryStorage.ts new file mode 100644 index 0000000000..9ad13d7f7c --- /dev/null +++ b/packages/remix-server-runtime/sessions/memoryStorage.ts @@ -0,0 +1,73 @@ +import type { + SessionData, + SessionStorage, + SessionIdStorageStrategy, + CreateSessionStorageFunction, + FlashSessionData, +} from "../sessions"; + +interface MemorySessionStorageOptions { + /** + * The Cookie used to store the session id on the client, or options used + * to automatically create one. + */ + cookie?: SessionIdStorageStrategy["cookie"]; +} + +export type CreateMemorySessionStorageFunction = < + Data = SessionData, + FlashData = Data +>( + options?: MemorySessionStorageOptions +) => SessionStorage; + +/** + * Creates and returns a simple in-memory SessionStorage object, mostly useful + * for testing and as a reference implementation. + * + * Note: This storage does not scale beyond a single process, so it is not + * suitable for most production scenarios. + * + * @see https://remix.run/utils/sessions#creatememorysessionstorage + */ +export const createMemorySessionStorageFactory = + ( + createSessionStorage: CreateSessionStorageFunction + ): CreateMemorySessionStorageFunction => + ({ + cookie, + }: MemorySessionStorageOptions = {}): SessionStorage => { + let map = new Map< + string, + { data: FlashSessionData; expires?: Date } + >(); + + return createSessionStorage({ + cookie, + async createData(data, expires) { + let id = Math.random().toString(36).substring(2, 10); + map.set(id, { data, expires }); + return id; + }, + async readData(id) { + if (map.has(id)) { + let { data, expires } = map.get(id)!; + + if (!expires || expires > new Date()) { + return data; + } + + // Remove expired session data. + if (expires) map.delete(id); + } + + return null; + }, + async updateData(id, data, expires) { + map.set(id, { data, expires }); + }, + async deleteData(id) { + map.delete(id); + }, + }); + }; diff --git a/packages/remix-server-runtime/single-fetch.ts b/packages/remix-server-runtime/single-fetch.ts new file mode 100644 index 0000000000..2d983b6fd4 --- /dev/null +++ b/packages/remix-server-runtime/single-fetch.ts @@ -0,0 +1,445 @@ +import type { + StaticHandler, + unstable_DataStrategyFunctionArgs as DataStrategyFunctionArgs, + StaticHandlerContext, +} from "@remix-run/router"; +import { + isRouteErrorResponse, + UNSAFE_ErrorResponseImpl as ErrorResponseImpl, +} from "@remix-run/router"; +import { encode } from "turbo-stream"; + +import type { AppLoadContext } from "./data"; +import { sanitizeError, sanitizeErrors } from "./errors"; +import { ServerMode } from "./mode"; +import type { ResponseStub, ResponseStubOperation } from "./routeModules"; +import { ResponseStubOperationsSymbol } from "./routeModules"; +import { isDeferredData, isRedirectStatusCode, isResponse } from "./responses"; + +export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); +const ResponseStubActionSymbol = Symbol("ResponseStubAction"); + +type SingleFetchRedirectResult = { + redirect: string; + status: number; + revalidate: boolean; + reload: boolean; +}; +export type SingleFetchResult = + | { data: unknown } + | { error: unknown } + | SingleFetchRedirectResult; + +export type SingleFetchResults = { + [key: string]: SingleFetchResult; + [SingleFetchRedirectSymbol]?: SingleFetchRedirectResult; +}; + +export function getSingleFetchDataStrategy( + responseStubs: ReturnType +) { + return async ({ request, matches }: DataStrategyFunctionArgs) => { + let results = await Promise.all( + matches.map(async (match) => { + let responseStub: ResponseStub | undefined; + if (request.method !== "GET") { + responseStub = responseStubs[ResponseStubActionSymbol]; + } else { + responseStub = responseStubs[match.route.id]; + } + + let result = await match.resolve(async (handler) => { + let data = await handler({ response: responseStub }); + return { type: "data", result: data }; + }); + + // Transfer raw Response status/headers to responseStubs + if (isResponse(result.result)) { + proxyResponseToResponseStub( + result.result.status, + result.result.headers, + responseStub + ); + } else if (isDeferredData(result.result) && result.result.init) { + proxyResponseToResponseStub( + result.result.init.status, + new Headers(result.result.init.headers), + responseStub + ); + } + + return result; + }) + ); + return results; + }; +} + +export async function singleFetchAction( + serverMode: ServerMode, + staticHandler: StaticHandler, + request: Request, + handlerUrl: URL, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +): Promise<{ result: SingleFetchResult; headers: Headers; status: number }> { + try { + let handlerRequest = new Request(handlerUrl, { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + ...(request.body ? { duplex: "half" } : undefined), + }); + + let responseStubs = getResponseStubs(); + let result = await staticHandler.query(handlerRequest, { + requestContext: loadContext, + skipLoaderErrorBubbling: true, + skipLoaders: true, + unstable_dataStrategy: getSingleFetchDataStrategy(responseStubs), + }); + + // Unlike `handleDataRequest`, when singleFetch is enabled, queryRoute does + // let non-Response return values through + if (isResponse(result)) { + return { + result: getSingleFetchRedirect(result.status, result.headers), + headers: result.headers, + status: 200, + }; + } + + let context = result; + + let singleFetchResult: SingleFetchResult; + let { statusCode, headers } = mergeResponseStubs(context, responseStubs); + + if (isRedirectStatusCode(statusCode) && headers.has("Location")) { + return { + result: getSingleFetchRedirect(statusCode, headers), + headers, + status: 200, // Don't want the `fetch` call to follow the redirect + }; + } + + // Sanitize errors outside of development environments + if (context.errors) { + Object.values(context.errors).forEach((err) => { + // @ts-expect-error This is "private" from users but intended for internal use + if (!isRouteErrorResponse(err) || err.error) { + handleError(err); + } + }); + context.errors = sanitizeErrors(context.errors, serverMode); + } + + if (context.errors) { + let error = Object.values(context.errors)[0]; + singleFetchResult = { error: isResponseStub(error) ? null : error }; + } else { + singleFetchResult = { data: Object.values(context.actionData || {})[0] }; + } + + return { + result: singleFetchResult, + headers, + status: statusCode, + }; + } catch (error) { + handleError(error); + // These should only be internal remix errors, no need to deal with responseStubs + return { + result: { error }, + headers: new Headers(), + status: 500, + }; + } +} + +export async function singleFetchLoaders( + serverMode: ServerMode, + staticHandler: StaticHandler, + request: Request, + handlerUrl: URL, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +): Promise<{ result: SingleFetchResults; headers: Headers; status: number }> { + try { + let handlerRequest = new Request(handlerUrl, { + headers: request.headers, + signal: request.signal, + }); + let loadRouteIds = + new URL(request.url).searchParams.get("_routes")?.split(",") || undefined; + + let responseStubs = getResponseStubs(); + let result = await staticHandler.query(handlerRequest, { + requestContext: loadContext, + loadRouteIds, + skipLoaderErrorBubbling: true, + unstable_dataStrategy: getSingleFetchDataStrategy(responseStubs), + }); + + if (isResponse(result)) { + return { + result: { + [SingleFetchRedirectSymbol]: getSingleFetchRedirect( + result.status, + result.headers + ), + }, + headers: result.headers, + status: 200, // Don't want the `fetch` call to follow the redirect + }; + } + + let context = result; + + let { statusCode, headers } = mergeResponseStubs(context, responseStubs); + + if (isRedirectStatusCode(statusCode) && headers.has("Location")) { + return { + result: { + [SingleFetchRedirectSymbol]: getSingleFetchRedirect( + statusCode, + headers + ), + }, + headers, + status: 200, // Don't want the `fetch` call to follow the redirect + }; + } + + // Sanitize errors outside of development environments + if (context.errors) { + Object.values(context.errors).forEach((err) => { + // @ts-expect-error This is "private" from users but intended for internal use + if (!isRouteErrorResponse(err) || err.error) { + handleError(err); + } + }); + context.errors = sanitizeErrors(context.errors, serverMode); + } + + // Aggregate results based on the matches we intended to load since we get + // `null` values back in `context.loaderData` for routes we didn't load + let results: SingleFetchResults = {}; + let loadedMatches = loadRouteIds + ? context.matches.filter( + (m) => m.route.loader && loadRouteIds!.includes(m.route.id) + ) + : context.matches; + + loadedMatches.forEach((m) => { + let data = context.loaderData?.[m.route.id]; + let error = context.errors?.[m.route.id]; + if (error !== undefined) { + if (isResponseStub(error)) { + results[m.route.id] = { error: null }; + } else { + results[m.route.id] = { error }; + } + } else if (data !== undefined) { + results[m.route.id] = { data }; + } + }); + + return { + result: results, + headers, + status: statusCode, + }; + } catch (error: unknown) { + handleError(error); + // These should only be internal remix errors, no need to deal with responseStubs + return { + result: { root: { error } }, + headers: new Headers(), + status: 500, + }; + } +} + +export function isResponseStub(value: any): value is ResponseStub { + return ( + value && typeof value === "object" && ResponseStubOperationsSymbol in value + ); +} + +function getResponseStub(status?: number) { + let headers = new Headers(); + let operations: ResponseStubOperation[] = []; + let headersProxy = new Proxy(headers, { + get(target, prop, receiver) { + if (prop === "set" || prop === "append" || prop === "delete") { + return (name: string, value: string) => { + operations.push([prop, name, value]); + Reflect.apply(target[prop], target, [name, value]); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + return { + status, + headers: headersProxy, + [ResponseStubOperationsSymbol]: operations, + }; +} + +export function getResponseStubs() { + return new Proxy({} as Record, { + get(responseStubCache, prop) { + let cached = responseStubCache[prop]; + if (!cached) { + responseStubCache[prop] = cached = getResponseStub(); + } + return cached; + }, + }); +} + +function proxyResponseToResponseStub( + status: number | undefined, + headers: Headers, + responseStub: ResponseStub +) { + if (status != null && responseStub.status == null) { + responseStub.status = status; + } + for (let [k, v] of headers) { + if (k.toLowerCase() !== "set-cookie") { + responseStub.headers.set(k, v); + } + } + + // Unsure why this is complaining? It's fine in VSCode but fails with tsc... + // @ts-ignore - ignoring instead of expecting because otherwise build fails locally + for (let v of headers.getSetCookie()) { + responseStub.headers.append("Set-Cookie", v); + } +} + +export function mergeResponseStubs( + context: StaticHandlerContext, + responseStubs: ReturnType +) { + let statusCode: number | undefined = undefined; + let headers = new Headers(); + + // Action followed by top-down loaders + let actionStub = responseStubs[ResponseStubActionSymbol]; + let stubs = [ + actionStub, + ...context.matches.map((m) => responseStubs[m.route.id]), + ]; + + for (let stub of stubs) { + // Take the highest error/redirect, or the lowest success value - preferring + // action 200's over loader 200s + if ( + // first status found on the way down + (statusCode === undefined && stub.status) || + // deeper 2xx status found while not overriding the action status + (statusCode !== undefined && + statusCode < 300 && + stub.status && + statusCode !== actionStub?.status) + ) { + statusCode = stub.status; + } + + // Replay headers operations in order + let ops = stub[ResponseStubOperationsSymbol]; + for (let [op, ...args] of ops) { + // @ts-expect-error + headers[op](...args); + } + } + + // If no response stubs set it, use whatever we got back from the router + // context which handles internal ErrorResponse cases like 404/405's where + // we may never run a loader/action + if (statusCode === undefined) { + statusCode = context.statusCode; + } + if (statusCode === undefined) { + statusCode = 200; + } + + return { statusCode, headers }; +} + +export function getSingleFetchRedirect( + status: number, + headers: Headers +): SingleFetchRedirectResult { + return { + redirect: headers.get("Location")!, + status, + revalidate: + // Technically X-Remix-Revalidate isn't needed here - that was an implementation + // detail of ?_data requests as our way to tell the front end to revalidate when + // we didn't have a response body to include that information in. + // With single fetch, we tell the front end via this revalidate boolean field. + // However, we're respecting it for now because it may be something folks have + // used in their own responses + // TODO(v3): Consider removing or making this official public API + headers.has("X-Remix-Revalidate") || headers.has("Set-Cookie"), + reload: headers.has("X-Remix-Reload-Document"), + }; +} + +// Note: If you change this function please change the corresponding +// decodeViaTurboStream function in server-runtime +export function encodeViaTurboStream( + data: any, + requestSignal: AbortSignal, + streamTimeout: number | undefined, + serverMode: ServerMode +) { + let controller = new AbortController(); + // How long are we willing to wait for all of the promises in `data` to resolve + // before timing out? We default this to 50ms shorter than the default value for + // `ABORT_DELAY` in our built-in `entry.server.tsx` so that once we reject we + // have time to flush the rejections down through React's rendering stream before ` + // we call abort() on that. If the user provides their own it's up to them to + // decouple the aborting of the stream from the aborting of React's renderToPipeableStream + let timeoutId = setTimeout( + () => controller.abort(new Error("Server Timeout")), + typeof streamTimeout === "number" ? streamTimeout : 4950 + ); + requestSignal.addEventListener("abort", () => clearTimeout(timeoutId)); + + return encode(data, { + signal: controller.signal, + plugins: [ + (value) => { + // Even though we sanitized errors on context.errors prior to responding, + // we still need to handle this for any deferred data that rejects with an + // Error - as those will not be sanitized yet + if (value instanceof Error) { + let { name, message, stack } = + serverMode === ServerMode.Production + ? sanitizeError(value, serverMode) + : value; + return ["SanitizedError", name, message, stack]; + } + + if (value instanceof ErrorResponseImpl) { + let { data, status, statusText } = value; + return ["ErrorResponse", data, status, statusText]; + } + + if ( + value && + typeof value === "object" && + SingleFetchRedirectSymbol in value + ) { + return ["SingleFetchRedirect", value[SingleFetchRedirectSymbol]]; + } + }, + ], + }); +} diff --git a/packages/remix-server-runtime/tsconfig.json b/packages/remix-server-runtime/tsconfig.json new file mode 100644 index 0000000000..57baa337e0 --- /dev/null +++ b/packages/remix-server-runtime/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["**/*.ts"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "../../build/node_modules/@remix-run/server-runtime/dist", + + // Avoid naming conflicts between lib.dom.d.ts and globals.ts + "skipLibCheck": true + } +} diff --git a/packages/remix-server-runtime/typecheck.ts b/packages/remix-server-runtime/typecheck.ts new file mode 100644 index 0000000000..7f6ffeab23 --- /dev/null +++ b/packages/remix-server-runtime/typecheck.ts @@ -0,0 +1,15 @@ +// typecheck that expression is assignable to type +export function expectType(_expression: T) {} + +// prettier-ignore +// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts +export type Equal = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false + +// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts +export type Expect = T; + +// looser, lazy equality check for recursive types +// prettier-ignore +export type MutualExtends = [A] extends [B] ? [B] extends [A] ? true : false : false diff --git a/packages/remix-server-runtime/upload/errors.ts b/packages/remix-server-runtime/upload/errors.ts new file mode 100644 index 0000000000..e5ed3d42f6 --- /dev/null +++ b/packages/remix-server-runtime/upload/errors.ts @@ -0,0 +1,5 @@ +export class MaxPartSizeExceededError extends Error { + constructor(public field: string, public maxBytes: number) { + super(`Field "${field}" exceeded upload size of ${maxBytes} bytes.`); + } +} diff --git a/packages/remix-server-runtime/upload/memoryUploadHandler.ts b/packages/remix-server-runtime/upload/memoryUploadHandler.ts new file mode 100644 index 0000000000..27dec693b5 --- /dev/null +++ b/packages/remix-server-runtime/upload/memoryUploadHandler.ts @@ -0,0 +1,50 @@ +import type { UploadHandler } from "../formData"; +import { MaxPartSizeExceededError } from "./errors"; + +export type MemoryUploadHandlerFilterArgs = { + filename?: string; + contentType: string; + name: string; +}; + +export type MemoryUploadHandlerOptions = { + /** + * The maximum upload size allowed. If the size is exceeded an error will be thrown. + * Defaults to 3000000B (3MB). + */ + maxPartSize?: number; + /** + * + * @param filename + * @param mimetype + * @param encoding + */ + filter?(args: MemoryUploadHandlerFilterArgs): boolean | Promise; +}; + +export function createMemoryUploadHandler({ + filter, + maxPartSize = 3000000, +}: MemoryUploadHandlerOptions = {}): UploadHandler { + return async ({ filename, contentType, name, data }) => { + if (filter && !(await filter({ filename, contentType, name }))) { + return undefined; + } + + let size = 0; + let chunks = []; + for await (let chunk of data) { + size += chunk.byteLength; + if (size > maxPartSize) { + throw new MaxPartSizeExceededError(name, maxPartSize); + } + chunks.push(chunk); + } + + if (typeof filename === "string") { + return new File(chunks, filename, { type: contentType }); + } + + return await new Blob(chunks, { type: contentType }).text(); + }; +} diff --git a/packages/remix-server-runtime/warnings.ts b/packages/remix-server-runtime/warnings.ts new file mode 100644 index 0000000000..45acd96010 --- /dev/null +++ b/packages/remix-server-runtime/warnings.ts @@ -0,0 +1,8 @@ +const alreadyWarned: { [message: string]: boolean } = {}; + +export function warnOnce(condition: boolean, message: string): void { + if (!condition && !alreadyWarned[message]) { + alreadyWarned[message] = true; + console.warn(message); + } +} diff --git a/packages/remix-testing/CHANGELOG.md b/packages/remix-testing/CHANGELOG.md new file mode 100644 index 0000000000..49c28166b6 --- /dev/null +++ b/packages/remix-testing/CHANGELOG.md @@ -0,0 +1,465 @@ +# `@remix-run/testing` + +## 2.9.0-pre.0 + +### Minor Changes + +- New `future.unstable_singleFetch` flag ([#8773](https://github.com/remix-run/remix/pull/8773)) + + - Naked objects returned from loaders/actions are no longer automatically converted to JSON responses. They'll be streamed as-is via `turbo-stream` so `Date`'s will become `Date` through `useLoaderData()` + - You can return naked objects with `Promise`'s without needing to use `defer()` - including nested `Promise`'s + - If you need to return a custom status code or custom response headers, you can still use the `defer` utility + - `` is no longer used. Instead, you should `export const streamTimeout` from `entry.server.tsx` and the remix server runtime will use that as the delay to abort the streamed response + - If you export your own streamTimeout, you should decouple that from aborting the react `renderToPipeableStream`. You should always ensure that react is aborted _afer_ the stream is aborted so that abort rejections can be flushed down + - Actions no longer automatically revalidate on 4xx/5xx responses (via RR `future.unstable_skipActionErrorRevalidation` flag) - you can return a 2xx to opt-into revalidation or use `shouldRevalidate` + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.9.0-pre.0` + - `@remix-run/react@2.9.0-pre.0` + +## 2.8.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.8.1` + - `@remix-run/node@2.8.1` + +## 2.8.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.8.0` + - `@remix-run/node@2.8.0` + +## 2.7.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.7.2` + - `@remix-run/react@2.7.2` + +## 2.7.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.7.1` + - `@remix-run/react@2.7.1` + +## 2.7.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.7.0` + - `@remix-run/node@2.7.0` + +## 2.6.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.6.0` + - `@remix-run/node@2.6.0` + +## 2.5.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.5.1` + - `@remix-run/node@2.5.1` + +## 2.5.0 + +### Minor Changes + +- Add unstable support for "SPA Mode" ([#8457](https://github.com/remix-run/remix/pull/8457)) + + You can opt into SPA Mode by setting `unstable_ssr: false` in your Remix Vite plugin config: + + ```js + // vite.config.ts + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + import { defineConfig } from "vite"; + + export default defineConfig({ + plugins: [remix({ unstable_ssr: false })], + }); + ``` + + Development in SPA Mode is just like a normal Remix app, and still uses the Remix dev server for HMR/HDR: + + ```sh + remix vite:dev + ``` + + Building in SPA Mode will generate an `index.html` file in your client assets directory: + + ```sh + remix vite:build + ``` + + To run your SPA, you serve your client assets directory via an HTTP server: + + ```sh + npx http-server build/client + ``` + + For more information, please refer to the [SPA Mode docs](https://remix.run/future/spa-mode). + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.5.0` + - `@remix-run/node@2.5.0` + +## 2.4.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.4.1` + - `@remix-run/node@2.4.1` + +## 2.4.0 + +### Minor Changes + +- Add support for `clientLoader`/`clientAction`/`HydrateFallback` route exports ([RFC](https://github.com/remix-run/remix/discussions/7634)). ([#8173](https://github.com/remix-run/remix/pull/8173)) +- Add a new `future.v3_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. For more information, please see the React Router [`6.21.0` Release Notes](https://github.com/remix-run/react-router/blob/release-next/CHANGELOG.md#futurev7_relativesplatpath) and the [`useResolvedPath` docs](https://remix.run/hooks/use-resolved-path#splat-paths). ([#8216](https://github.com/remix-run/remix/pull/8216)) + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.4.0` + - `@remix-run/node@2.4.0` + +## 2.3.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.3.1` + - `@remix-run/node@2.3.1` + +## 2.3.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.3.0` + - `@remix-run/node@2.3.0` + +## 2.2.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. + - See "Future > Vite" in the Remix Docs for details + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.2.0` + - `@remix-run/node@2.2.0` + +## 2.1.0 + +### Minor Changes + +- Remove the `unstable_` prefix from `createRemixStub` - after real-world experience, we're confident in the API and ready to commit to it ([#7647](https://github.com/remix-run/remix/pull/7647)) + - **Note**: This involves 1 small breaking change. The `` prop has been renamed to `` + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.1.0` + - `@remix-run/node@2.1.0` + +## 2.0.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.0.1` + - `@remix-run/node@2.0.1` + +## 2.0.0 + +### Major Changes + +- Drop React 17 support ([#7121](https://github.com/remix-run/remix/pull/7121)) +- Require Node >=18.0.0 ([#6939](https://github.com/remix-run/remix/pull/6939)) +- Remove `v2_normalizeFormMethod` future flag - all `formMethod` values will be normalized in v2 ([#6875](https://github.com/remix-run/remix/pull/6875)) +- Remove `v2_routeConvention` flag - the flat route file convention is now standard ([#6969](https://github.com/remix-run/remix/pull/6969)) +- Remove `v2_headers` flag - it is now the default behavior to use the deepest `headers` function in the route tree ([#6979](https://github.com/remix-run/remix/pull/6979)) +- Remove `v2_errorBoundary` flag and `CatchBoundary` implementation ([#6906](https://github.com/remix-run/remix/pull/6906)) +- The route `meta` API now defaults to the new "V2 Meta" API ([#6958](https://github.com/remix-run/remix/pull/6958)) + - Please refer to the ([docs](https://remix.run/docs/en/2.0.0/route/meta) and [Preparing for V2](https://remix.run/docs/en/2.0.0/start/v2#route-meta) guide for more information. +- Promote the `future.v2_dev` flag in `remix.config.js` to a root level `dev` config ([#7002](https://github.com/remix-run/remix/pull/7002)) +- Removed support for "magic exports" from the `remix` package. This package can be removed from your `package.json` and you should update all imports to use the source `@remix-run/*` packages: ([#6895](https://github.com/remix-run/remix/pull/6895)) + + ```diff + - import type { ActionArgs } from "remix"; + - import { json, useLoaderData } from "remix"; + + import type { ActionArgs } from "@remix-run/node"; + + import { json } from "@remix-run/node"; + + import { useLoaderData } from "@remix-run/react"; + ``` + +### Minor Changes + +- `unstable_createRemixStub` now supports adding `meta`/`links` functions on stubbed Remix routes ([#7186](https://github.com/remix-run/remix/pull/7186)) + - ⚠️ `unstable_createRemixStub` no longer supports the `element`/`errorElement` properties on routes. You must use `Component`/`ErrorBoundary` to match what you would export from a Remix route module. +- Update Remix to use React Router `route.lazy` for module loading ([#7133](https://github.com/remix-run/remix/pull/7133)) + +### Patch Changes + +- Fix types for `StubRouteObject` `children` property ([#7098](https://github.com/remix-run/remix/pull/7098)) +- Updated dependencies: + - `@remix-run/react@2.0.0` + - `@remix-run/node@2.0.0` + - [`react-router-dom@6.16.0`](https://github.com/remix-run/react-router/releases/tag/react-router%406.16.0) + - [`@remix-run/router@1.9.0`](https://github.com/remix-run/react-router/blob/main/packages/router/CHANGELOG.md#190) + +## 1.19.3 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.19.3` + - `@remix-run/react@1.19.3` + +## 1.19.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.19.2` + - `@remix-run/react@1.19.2` + +## 1.19.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.19.1` + - `@remix-run/react@1.19.1` + +## 1.19.0 + +### Patch Changes + +- Bump RR 6.14.2 ([#6854](https://github.com/remix-run/remix/pull/6854)) +- Updated dependencies: + - `@remix-run/react@1.19.0` + - `@remix-run/node@1.19.0` + - [`react-router-dom@6.14.2`](https://github.com/remix-run/react-router/releases/tag/react-router%406.14.2) + - [`@remix-run/router@1.7.2`](https://github.com/remix-run/react-router/blob/main/packages/router/CHANGELOG.md#172) + +## 1.18.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@1.18.1` + - `@remix-run/node@1.18.1` + - [`react-router-dom@6.14.1`](https://github.com/remix-run/react-router/releases/tag/react-router%406.14.1) + - [`@remix-run/router@1.7.1`](https://github.com/remix-run/react-router/blob/main/packages/router/CHANGELOG.md#171) + +## 1.18.0 + +### Minor Changes + +- stabilize v2 dev server ([#6615](https://github.com/remix-run/remix/pull/6615)) + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@1.18.0` + - `@remix-run/node@1.18.0` + +## 1.17.1 + +### Patch Changes + +- Updated dependencies: + - [`react-router-dom@6.13.0`](https://github.com/remix-run/react-router/releases/tag/react-router%406.13.0) + - `@remix-run/react@1.17.1` + - `@remix-run/node@1.17.1` + +## 1.17.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@1.17.0` + - `@remix-run/node@1.17.0` + - [`react-router-dom@6.12.0`](https://github.com/remix-run/react-router/releases/tag/react-router%406.12.0) + - [`@remix-run/router@1.6.3`](https://github.com/remix-run/react-router/blob/main/packages/router/CHANGELOG.md#163) + +## 1.16.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@1.16.1` + - `@remix-run/node@1.16.1` + +## 1.16.0 + +### Minor Changes + +- Enable support for [CSS Modules](https://github.com/css-modules/css-modules), [Vanilla Extract](http://vanilla-extract.style) and CSS side-effect imports ([#6046](https://github.com/remix-run/remix/pull/6046)) + + These CSS bundling features were previously only available via `future.unstable_cssModules`, `future.unstable_vanillaExtract` and `future.unstable_cssSideEffectImports` options in `remix.config.js`, but they have now been stabilized. + + In order to use these features, check out our guide to [CSS bundling](https://remix.run/docs/en/1.16.0/guides/styling#css-bundling) in your project. + +### Patch Changes + +- feat(remix-testing): cast types to use Remix type definitions + allow passing context ([#6065](https://github.com/remix-run/remix/pull/6065)) +- Updated dependencies: + - [`react-router-dom@6.11.0`](https://github.com/remix-run/react-router/releases/tag/react-router%406.11.0) + - [`@remix-run/router@1.6.0`](https://github.com/remix-run/react-router/blob/main/packages/router/CHANGELOG.md#160) + - `@remix-run/react@1.16.0` + - `@remix-run/node@1.16.0` + +## 1.15.0 + +### Patch Changes + +- Bumped React Router dependencies to the latest version. [See the release notes for more details.](https://github.com/remix-run/react-router/releases/tag/react-router%406.10.0) ([`e14699547`](https://github.com/remix-run/remix/commit/e1469954737a2e45636b6aef73dc9ae251fb1b20)) +- Updated dependencies: + - `@remix-run/react@1.15.0` + - `@remix-run/node@1.15.0` + +## 1.14.3 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.14.3` + - `@remix-run/react@1.14.3` + +## 1.14.2 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.14.2` + - `@remix-run/react@1.14.2` + +## 1.14.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@1.14.1` + - `@remix-run/node@1.14.1` + +## 1.14.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@1.14.0` + - `@remix-run/node@1.14.0` + - `@remix-run/router@1.3.3` + - `react-router-dom@8.6.2` + +## 1.13.0 + +### Minor Changes + +- Add built-in support for PostCSS via the `future.unstable_postcss` feature flag ([#5229](https://github.com/remix-run/remix/pull/5229)) +- Add built-in support for Tailwind via the `future.unstable_tailwind` feature flag ([#5229](https://github.com/remix-run/remix/pull/5229)) + +### Patch Changes + +- Bump React Router dependencies to the latest version. [See the release notes for more details.](https://github.com/remix-run/react-router/releases/tag/react-router%406.8.1) ([#5389](https://github.com/remix-run/remix/pull/5389)) +- Updated dependencies: + - `@remix-run/react@1.13.0` + - `@remix-run/node@1.13.0` + +## 1.12.0 + +### Patch Changes + +- Ensure all routes have IDs when using the `createRemixStub` testing helper ([#5128](https://github.com/remix-run/remix/pull/5128)) +- Bump React Router dependencies to the latest version. [See the release notes for more details.](https://github.com/remix-run/react-router/releases/tag/react-router%406.8.0) ([#5242](https://github.com/remix-run/remix/pull/5242)) +- Updated dependencies: + - `@remix-run/react@1.12.0` + - `@remix-run/node@1.12.0` + +## 1.11.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@1.11.1` + - `@remix-run/react@1.11.1` + +## 1.11.0 + +### Minor Changes + +- Added support for [Vanilla Extract](https://vanilla-extract.style) via the `unstable_vanillaExtract` future flag. **IMPORTANT:** Features marked with `unstable` are … unstable. While we're confident in the use cases they solve, the API and implementation may change without a major version bump. ([#5040](https://github.com/remix-run/remix/pull/5040)) +- Add support for CSS side-effect imports via the `unstable_cssSideEffectImports` future flag. **IMPORTANT:** Features marked with `unstable` are … unstable. While we're confident in the use cases they solve, the API and implementation may change without a major version bump. ([#4919](https://github.com/remix-run/remix/pull/4919)) +- Add support for CSS Modules via the `unstable_cssModules` future flag. **IMPORTANT:** Features marked with `unstable` are … unstable. While we're confident in the use cases they solve, the API and implementation may change without a major version bump. ([#4852](https://github.com/remix-run/remix/pull/4852)) + +### Patch Changes + +- Introduces the `defer()` API from `@remix-run/router` with support for server-rendering and HTTP streaming. This utility allows you to defer values returned from `loader` functions by returning promises instead of resolved values. This has been refered to as _"sending a promise over the wire"_. ([#4920](https://github.com/remix-run/remix/pull/4920)) + + Informational Resources: + + - + - + + Documentation Resources (better docs specific to Remix are in the works): + + - + - + - + - + +- Updated dependencies: + - `@remix-run/react@1.11.0` + - `@remix-run/node@1.11.0` + +## 1.10.1 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@1.10.1` + - `@remix-run/node@1.10.1` + +## 1.10.0 + +### Patch Changes + +- Remove internal `installGlobals` function now that `@remix-run/web-form-data` includes support for passing a `HTMLFormElement` ([#4755](https://github.com/remix-run/remix/pull/4755)) +- Use React Router data APIs directly ([#4915](https://github.com/remix-run/remix/pull/4915)) +- Updated dependencies: + - `@remix-run/react@1.10.0` + - `@remix-run/node@1.10.0` + +## 1.9.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@1.9.0` + - `@remix-run/server-runtime@1.9.0` + - `@remix-run/node@1.9.0` diff --git a/packages/remix-testing/README.md b/packages/remix-testing/README.md new file mode 100644 index 0000000000..40685a7476 --- /dev/null +++ b/packages/remix-testing/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/remix-testing/__tests__/stub-test.tsx b/packages/remix-testing/__tests__/stub-test.tsx new file mode 100644 index 0000000000..87ad7f968c --- /dev/null +++ b/packages/remix-testing/__tests__/stub-test.tsx @@ -0,0 +1,227 @@ +import * as React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import user from "@testing-library/user-event"; +import { createRemixStub } from "@remix-run/testing"; +import { + Form, + Outlet, + useActionData, + useFetcher, + useLoaderData, + useMatches, +} from "@remix-run/react"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; + +test("renders a route", () => { + let RemixStub = createRemixStub([ + { + path: "/", + Component: () =>
    HOME
    , + }, + ]); + + render(); + + expect(screen.getByText("HOME")).toBeInTheDocument(); +}); + +test("renders a nested route", () => { + let RemixStub = createRemixStub([ + { + Component() { + return ( +
    +

    ROOT

    + +
    + ); + }, + children: [ + { + path: "/", + Component: () =>
    INDEX
    , + }, + ], + }, + ]); + + render(); + + expect(screen.getByText("ROOT")).toBeInTheDocument(); + expect(screen.getByText("INDEX")).toBeInTheDocument(); +}); + +test("loaders work", async () => { + let RemixStub = createRemixStub([ + { + path: "/", + Component() { + let data = useLoaderData(); + return
    Message: {data.message}
    ; + }, + loader() { + return json({ message: "hello" }); + }, + }, + ]); + + render(); + + await waitFor(() => screen.findByText("Message: hello")); +}); + +test("actions work", async () => { + let RemixStub = createRemixStub([ + { + path: "/", + Component() { + let data = useActionData() as { message: string } | undefined; + return ( +
    + + {data ?
    Message: {data.message}
    : null} +
    + ); + }, + action() { + return json({ message: "hello" }); + }, + }, + ]); + + render(); + + user.click(screen.getByText("Submit")); + await waitFor(() => screen.findByText("Message: hello")); +}); + +test("fetchers work", async () => { + let count = 0; + let RemixStub = createRemixStub([ + { + path: "/", + Component() { + let fetcher = useFetcher<{ count: number }>(); + return ( + + ); + }, + }, + { + path: "/api", + loader() { + return json({ count: ++count }); + }, + }, + ]); + + render(); + + user.click(screen.getByText("idle 0")); + await waitFor(() => screen.findByText("idle 1")); + + user.click(screen.getByText("idle 1")); + await waitFor(() => screen.findByText("idle 2")); +}); + +test("can pass a predefined loader", () => { + async function loader(_args: LoaderFunctionArgs) { + return json({ hi: "there" }); + } + + createRemixStub([ + { + path: "/example", + loader, + }, + ]); +}); + +test("can pass context values", async () => { + let RemixStub = createRemixStub( + [ + { + path: "/", + Component() { + let data = useLoaderData() as { context: string }; + return ( +
    +
    Context: {data.context}
    + +
    + ); + }, + loader({ context }) { + return json(context); + }, + children: [ + { + path: "hello", + Component() { + let data = useLoaderData() as { context: string }; + return
    Context: {data.context}
    ; + }, + loader({ context }) { + return json(context); + }, + }, + ], + }, + ], + { context: "hello" } + ); + + render(); + + expect(await screen.findByTestId("root")).toHaveTextContent( + /context: hello/i + ); + expect(await screen.findByTestId("hello")).toHaveTextContent( + /context: hello/i + ); +}); + +test("all routes have ids", () => { + let RemixStub = createRemixStub([ + { + Component() { + return ( +
    +

    ROOT

    + +
    + ); + }, + children: [ + { + path: "/", + Component() { + let matches = useMatches(); + + return ( +
    +

    HOME

    +
    +                  {JSON.stringify(matches, null, 2)}
    +                
    +
    + ); + }, + }, + ], + }, + ]); + + render(); + + let matchesTextContent = screen.getByTestId("matches").textContent; + // eslint-disable-next-line jest-dom/prefer-in-document + expect(matchesTextContent).toBeDefined(); + let matches = JSON.parse(matchesTextContent!); + let matchesWithoutIds = matches.filter((match: any) => match.id == null); + + expect(matchesWithoutIds).toHaveLength(0); +}); diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx new file mode 100644 index 0000000000..0c19d2fb1a --- /dev/null +++ b/packages/remix-testing/create-remix-stub.tsx @@ -0,0 +1,217 @@ +import * as React from "react"; +import { + UNSAFE_convertRoutesToDataRoutes, + type HydrationState, + type InitialEntry, + type Router, + type ActionFunctionArgs as RRActionFunctionArgs, + type LoaderFunctionArgs as RRLoaderFunctionArgs, +} from "@remix-run/router"; +import { UNSAFE_RemixContext as RemixContext } from "@remix-run/react"; +import type { + UNSAFE_FutureConfig as FutureConfig, + UNSAFE_AssetsManifest as AssetsManifest, + UNSAFE_EntryRoute as EntryRoute, + UNSAFE_RouteModules as RouteModules, + UNSAFE_RemixContextObject as RemixContextObject, + MetaFunction, +} from "@remix-run/react"; +import type { + DataRouteObject, + IndexRouteObject, + NonIndexRouteObject, +} from "react-router-dom"; +import { createMemoryRouter, Outlet, RouterProvider } from "react-router-dom"; +import type { + ActionFunction, + AppLoadContext, + LinksFunction, + LoaderFunction, +} from "@remix-run/server-runtime"; + +interface StubIndexRouteObject + extends Omit< + IndexRouteObject, + "loader" | "action" | "element" | "errorElement" | "children" + > { + loader?: LoaderFunction; + action?: ActionFunction; + children?: StubRouteObject[]; + meta?: MetaFunction; + links?: LinksFunction; +} + +interface StubNonIndexRouteObject + extends Omit< + NonIndexRouteObject, + "loader" | "action" | "element" | "errorElement" | "children" + > { + loader?: LoaderFunction; + action?: ActionFunction; + children?: StubRouteObject[]; + meta?: MetaFunction; + links?: LinksFunction; +} + +type StubRouteObject = StubIndexRouteObject | StubNonIndexRouteObject; + +export interface RemixStubProps { + /** + * The initial entries in the history stack. This allows you to start a test with + * multiple locations already in the history stack (for testing a back navigation, etc.) + * The test will default to the last entry in initialEntries if no initialIndex is provided. + * e.g. initialEntries={["/home", "/about", "/contact"]} + */ + initialEntries?: InitialEntry[]; + + /** + * The initial index in the history stack to render. This allows you to start a test at a specific entry. + * It defaults to the last entry in initialEntries. + * e.g. + * initialEntries: ["/", "/events/123"] + * initialIndex: 1 // start at "/events/123" + */ + initialIndex?: number; + + /** + * Used to set the route's initial loader and action data. + * e.g. hydrationData={{ + * loaderData: { "/contact": { locale: "en-US" } }, + * actionData: { "/login": { errors: { email: "invalid email" } }} + * }} + */ + hydrationData?: HydrationState; + + /** + * Future flags mimicking the settings in remix.config.js + */ + future?: Partial; +} + +export function createRemixStub( + routes: StubRouteObject[], + context: AppLoadContext = {} +) { + return function RemixStub({ + initialEntries, + initialIndex, + hydrationData, + future, + }: RemixStubProps) { + let routerRef = React.useRef(); + let remixContextRef = React.useRef(); + + if (routerRef.current == null) { + remixContextRef.current = { + future: { + v3_fetcherPersist: future?.v3_fetcherPersist === true, + v3_relativeSplatPath: future?.v3_relativeSplatPath === true, + unstable_singleFetch: future?.unstable_singleFetch === true, + }, + manifest: { + routes: {}, + entry: { imports: [], module: "" }, + url: "", + version: "", + }, + routeModules: {}, + isSpaMode: false, + }; + + // Update the routes to include context in the loader/action and populate + // the manifest and routeModules during the walk + let patched = processRoutes( + // @ts-expect-error loader/action context types don't match :/ + UNSAFE_convertRoutesToDataRoutes(routes, (r) => r), + context, + remixContextRef.current.manifest, + remixContextRef.current.routeModules + ); + routerRef.current = createMemoryRouter(patched, { + initialEntries, + initialIndex, + hydrationData, + }); + } + + return ( + + + + ); + }; +} + +function processRoutes( + routes: StubRouteObject[], + context: AppLoadContext, + manifest: AssetsManifest, + routeModules: RouteModules, + parentId?: string +): DataRouteObject[] { + return routes.map((route) => { + if (!route.id) { + throw new Error( + "Expected a route.id in @remix-run/testing processRoutes() function" + ); + } + + // Patch in the Remix context to loaders/actions + let { loader, action } = route; + let newRoute: DataRouteObject = { + id: route.id, + path: route.path, + index: route.index, + Component: route.Component, + ErrorBoundary: route.ErrorBoundary, + action: action + ? (args: RRActionFunctionArgs) => action!({ ...args, context }) + : undefined, + loader: loader + ? (args: RRLoaderFunctionArgs) => loader!({ ...args, context }) + : undefined, + handle: route.handle, + shouldRevalidate: route.shouldRevalidate, + }; + + // Add the EntryRoute to the manifest + let entryRoute: EntryRoute = { + id: route.id, + path: route.path, + index: route.index, + parentId, + hasAction: route.action != null, + hasLoader: route.loader != null, + // When testing routes, you should just be stubbing loader/action, not + // trying to re-implement the full loader/clientLoader/SSR/hydration flow. + // That is better tested via E2E tests. + hasClientAction: false, + hasClientLoader: false, + hasErrorBoundary: route.ErrorBoundary != null, + module: "build/stub-path-to-module.js", // any need for this? + }; + manifest.routes[newRoute.id] = entryRoute; + + // Add the route to routeModules + routeModules[route.id] = { + default: route.Component || Outlet, + ErrorBoundary: route.ErrorBoundary || undefined, + handle: route.handle, + links: route.links, + meta: route.meta, + shouldRevalidate: route.shouldRevalidate, + }; + + if (route.children) { + newRoute.children = processRoutes( + route.children, + context, + manifest, + routeModules, + newRoute.id + ); + } + + return newRoute; + }); +} diff --git a/packages/remix-testing/index.ts b/packages/remix-testing/index.ts new file mode 100644 index 0000000000..39eb89457b --- /dev/null +++ b/packages/remix-testing/index.ts @@ -0,0 +1,2 @@ +export type { RemixStubProps } from "./create-remix-stub"; +export { createRemixStub } from "./create-remix-stub"; diff --git a/packages/remix-testing/jest.config.js b/packages/remix-testing/jest.config.js new file mode 100644 index 0000000000..483aa32c42 --- /dev/null +++ b/packages/remix-testing/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "testing", + setupFiles: [], + testEnvironment: "jsdom", + setupFilesAfterEnv: ["@testing-library/jest-dom", "./jest.setup.js"], +}; diff --git a/packages/remix-testing/jest.setup.js b/packages/remix-testing/jest.setup.js new file mode 100644 index 0000000000..87f5cefb48 --- /dev/null +++ b/packages/remix-testing/jest.setup.js @@ -0,0 +1,8 @@ +const JSDOMFormData = global.FormData; +global.TextDecoder = require("util").TextDecoder; +global.TextEncoder = require("util").TextEncoder; +global.ReadableStream = require("stream/web").ReadableStream; +global.WritableStream = require("stream/web").WritableStream; + +require("@remix-run/node").installGlobals(); +global.FormData = JSDOMFormData; diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json new file mode 100644 index 0000000000..4c561cf211 --- /dev/null +++ b/packages/remix-testing/package.json @@ -0,0 +1,56 @@ +{ + "name": "@remix-run/testing", + "version": "2.9.0-pre.0", + "description": "Testing utilities for Remix apps", + "homepage": "https://remix.run", + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-testing" + }, + "license": "MIT", + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "module": "./dist/esm/index.js", + "scripts": { + "tsc": "tsc" + }, + "dependencies": { + "@remix-run/node": "workspace:*", + "@remix-run/react": "workspace:*", + "@remix-run/router": "1.16.0-pre.0", + "react-router-dom": "6.23.0-pre.0" + }, + "devDependencies": { + "@remix-run/server-runtime": "workspace:*", + "@remix-run/testing": "workspace:*", + "@types/node": "^18.17.1", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "jest-environment-jsdom": "^29.7.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "react": "^18.0.0", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ] +} diff --git a/packages/remix-testing/rollup.config.js b/packages/remix-testing/rollup.config.js new file mode 100644 index 0000000000..9ca30d172a --- /dev/null +++ b/packages/remix-testing/rollup.config.js @@ -0,0 +1,72 @@ +const path = require("node:path"); +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + copyToPlaygrounds, + createBanner, + getOutputDir, + isBareModuleId, +} = require("../../rollup.utils"); +const { name: packageName, version } = require("./package.json"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + let sourceDir = path.join("packages", "remix-testing"); + let outputDir = getOutputDir(packageName); + let outputDist = path.join(outputDir, "dist"); + + let sharedPlugins = [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts", ".tsx"], + }), + nodeResolve({ extensions: [".ts", ".tsx"] }), + copyToPlaygrounds(), + ]; + + /** @type {import("rollup").RollupOptions} */ + let remixTestingCJS = { + external(id) { + return isBareModuleId(id); + }, + input: path.join(sourceDir, "index.ts"), + output: { + banner: createBanner(packageName, version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "auto", + }, + plugins: [ + ...sharedPlugins, + copy({ + targets: [ + { src: "LICENSE.md", dest: [outputDir, sourceDir] }, + { src: path.join(sourceDir, "package.json"), dest: outputDir }, + { src: path.join(sourceDir, "README.md"), dest: outputDir }, + ], + }), + ], + }; + + // The browser build of remix-testing is ESM so we can treeshake it. + /** @type {import("rollup").RollupOptions} */ + let remixTestingESM = { + external(id) { + return isBareModuleId(id); + }, + input: path.join(sourceDir, "index.ts"), + output: { + banner: createBanner(packageName, version), + dir: path.join(outputDist, "esm"), + format: "esm", + preserveModules: true, + }, + plugins: [...sharedPlugins], + }; + + return [remixTestingCJS, remixTestingESM]; +}; diff --git a/packages/remix-testing/tsconfig.json b/packages/remix-testing/tsconfig.json new file mode 100644 index 0000000000..65fc76e33d --- /dev/null +++ b/packages/remix-testing/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "__tests__", "node_modules"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "target": "ES2022", + "module": "ES2022", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "jsx": "react", + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "../../build/node_modules/@remix-run/testing/dist" + } +}