diff --git a/.changeset/single-fetch.md b/.changeset/single-fetch.md
new file mode 100644
index 00000000000..57ab4f0222c
--- /dev/null
+++ b/.changeset/single-fetch.md
@@ -0,0 +1,15 @@
+---
+"@remix-run/dev": minor
+"@remix-run/react": minor
+"@remix-run/server-runtime": minor
+"@remix-run/testing": minor
+---
+
+New `future.unstable_singleFetch` flag
+
+- Naked objects returned from loaders/actions are no longer automatically converted to JSON responses. They'll be streamed as-is via `turbo-stream` so `Date`'s will become `Date` through `useLoaderData()`
+- You can return naked objects with `Promise`'s without needing to use `defer()` - including nested `Promise`'s
+ - If you need to return a custom status code or custom response headers, you can still use the `defer` utility
+- `
{JSON.stringify(matches)}+
${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 ( + <> +{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()}
+{useLoaderData()}
+ + > + ) + } + + export function ErrorBoundary() { + let error = useRouteError() + return{error.status} {error.data}
; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("non-matching urls on document requests", async () => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + let res = await fixture.requestDocument(NOT_FOUND_HREF); + expect(res.status).toBe(404); + let html = await res.text(); + expect(html).toMatch(ROOT_BOUNDARY_TEXT); + + // There should be no loader data on the root route + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {} }, + ]).replace(/"/g, """); + expect(html).toContain(`${expected}`); + + console.error = oldConsoleError; + }); + + test("non-matching urls on client transitions", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NOT_FOUND_HREF, { wait: false }); + await page.waitForSelector("#root-boundary"); + + // Root loader data sticks around from previous load + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {}, data: { data: "ROOT LOADER" } }, + ]); + expect(await app.getHtml("#matches")).toContain(expected); + }); + + test("own boundary, action, document request", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from other route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("own boundary, action, client transition from itself", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(HAS_BOUNDARY_ACTION); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("bubbles to parent in action document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("bubbles to parent in action script transitions from self", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_BOUNDARY_ACTION); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("own boundary, loader, document request", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, loader, client transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LOADER); + await page.waitForSelector("#boundary-loader"); + }); + + test("bubbles to parent in loader document requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector("#root-boundary"); + }); + + test("uses correct catch boundary on server action errors", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/action/child-catch`); + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-data")).toMatch("CHILD"); + await page.click("button[type=submit]"); + await page.waitForSelector("#child-catch"); + // Preserves parent loader data + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-catch")).toMatch("400"); + expect(await app.getHtml("#child-catch")).toMatch("Caught!"); + }); + + test("prefers parent catch when child loader also bubbles, document request", async () => { + let res = await fixture.requestDocument(`${HAS_BOUNDARY_LOADER}/child`); + expect(res.status).toBe(401); + let text = await res.text(); + expect(text).toMatch(OWN_BOUNDARY_TEXT); + expect(text).toMatch('
401'); + }); + + test("prefers parent catch when child loader also bubbles, client transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(`${HAS_BOUNDARY_LOADER}/child`); + await page.waitForSelector("#boundary-loader"); + expect(await app.getHtml("#boundary-loader")).toMatch(OWN_BOUNDARY_TEXT); + expect(await app.getHtml("#status")).toMatch("401"); + }); + }); +}); diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 5a1046d59ed..2269fd41fcb 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -6,7 +6,7 @@ import { createFixture, js, } from "./helpers/create-fixture.js"; -import type { AppFixture } from "./helpers/create-fixture.js"; +import type { AppFixture, FixtureInit } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; function getFiles({ @@ -145,10 +145,14 @@ test.describe("Client Data", () => { appFixture.close(); }); + function createTestFixture(init: FixtureInit, serverMode?: ServerMode) { + return createFixture(init, serverMode); + } + test.describe("clientLoader - critical route module", () => { test("no client loaders or fallbacks", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -168,7 +172,7 @@ test.describe("Client Data", () => { test("parent.clientLoader/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -188,7 +192,7 @@ test.describe("Client Data", () => { test("parent.clientLoader.hydrate/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: true, @@ -214,7 +218,7 @@ test.describe("Client Data", () => { test("parent.clientLoader/child.clientLoader.hydrate", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -242,7 +246,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: true, @@ -269,7 +273,7 @@ test.describe("Client Data", () => { }); test("handles synchronous client loaders", async ({ page }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -308,7 +312,7 @@ test.describe("Client Data", () => { }); test("handles deferred data through client loaders", async ({ page }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -378,7 +382,7 @@ test.describe("Client Data", () => { test("allows hydration execution without rendering a fallback", async ({ page, }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -407,7 +411,7 @@ test.describe("Client Data", () => { test("HydrateFallback is not rendered if clientLoader.hydrate is not set (w/server loader)", async ({ page, }) => { - let fixture = await createFixture({ + let fixture = await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -461,7 +465,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -504,7 +508,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -547,7 +551,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -590,7 +594,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -655,7 +659,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -732,7 +736,7 @@ test.describe("Client Data", () => { let _consoleError = console.error; console.error = () => {}; appFixture = await createAppFixture( - await createFixture( + await createTestFixture( { files: { ...getFiles({ @@ -827,13 +831,15 @@ test.describe("Client Data", () => { ); let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); + await app.goto("/parent/child", false); let html = await app.getHtml("main"); expect(html).toMatch("Parent Server Loader"); expect(html).toMatch("Child Server Error"); expect(html).not.toMatch("Should not see me"); // Ensure we hydrate and remain on the boundary - await new Promise((r) => setTimeout(r, 100)); + await page.waitForSelector( + ":has-text('Parent Server Loader (mutated by client)')" + ); html = await app.getHtml("main"); expect(html).toMatch("Parent Server Loader (mutated by client)"); expect(html).toMatch("Child Server Error"); @@ -845,7 +851,7 @@ test.describe("Client Data", () => { test.describe("clientLoader - lazy route module", () => { test("no client loaders or fallbacks", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -867,7 +873,7 @@ test.describe("Client Data", () => { test("parent.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -888,7 +894,7 @@ test.describe("Client Data", () => { test("child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -909,7 +915,7 @@ test.describe("Client Data", () => { test("parent.clientLoader/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -932,7 +938,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -976,7 +982,7 @@ test.describe("Client Data", () => { test.describe("clientAction - critical route module", () => { test("child.clientAction", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1010,7 +1016,7 @@ test.describe("Client Data", () => { test("child.clientAction/parent.childLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1052,7 +1058,7 @@ test.describe("Client Data", () => { test("child.clientAction/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1096,7 +1102,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1140,7 +1146,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -1185,7 +1191,7 @@ test.describe("Client Data", () => { test.describe("clientAction - lazy route module", () => { test("child.clientAction", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1221,7 +1227,7 @@ test.describe("Client Data", () => { test("child.clientAction/parent.childLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1265,7 +1271,7 @@ test.describe("Client Data", () => { test("child.clientAction/child.clientLoader", async ({ page }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: false, parentClientLoaderHydrate: false, @@ -1311,7 +1317,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: getFiles({ parentClientLoader: true, parentClientLoaderHydrate: false, @@ -1357,7 +1363,7 @@ test.describe("Client Data", () => { page, }) => { appFixture = await createAppFixture( - await createFixture({ + await createTestFixture({ files: { ...getFiles({ parentClientLoader: false, @@ -1401,3 +1407,1243 @@ test.describe("Client Data", () => { }); }); }); + +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("Client Data", () => { + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture.close(); + }); + + function createTestFixture(init: FixtureInit, serverMode?: ServerMode) { + return createFixture( + { + ...init, + config: { + future: { + unstable_singleFetch: true, + }, + }, + }, + serverMode + ); + } + + test.describe("clientLoader - critical route module", () => { + test("no client loaders or fallbacks", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + // Full SSR - normal Remix behavior due to lack of clientLoader + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + // Full SSR - normal Remix behavior due to lack of HydrateFallback components + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader.hydrate/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: true, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Fallback"); + expect(html).not.toMatch("Parent Server Loader"); + expect(html).not.toMatch("Child Server Loader"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).not.toMatch("Parent Fallback"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader/child.clientLoader.hydrate", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: true, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Fallback"); + expect(html).not.toMatch("Child Server Loader"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).not.toMatch("Child Fallback"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + }); + + test("parent.clientLoader.hydrate/child.clientLoader.hydrate", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: true, + childClientLoader: true, + childClientLoaderHydrate: true, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Fallback"); + expect(html).not.toMatch("Parent Server Loader"); + expect(html).not.toMatch("Child Fallback"); + expect(html).not.toMatch("Child Server Loader"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).not.toMatch("Parent Fallback"); + expect(html).not.toMatch("Child Fallback"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + }); + + test("handles synchronous client loaders", async ({ page }) => { + let fixture = await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + parentAdditions: js` + export function clientLoader() { + return { message: "Parent Client Loader" }; + } + clientLoader.hydrate=true + export function HydrateFallback() { + return
Parent Fallback
+ } + `, + childAdditions: js` + export function clientLoader() { + return { message: "Child Client Loader" }; + } + clientLoader.hydrate=true + `, + }), + }); + + // Ensure we SSR the fallbacks + let doc = await fixture.requestDocument("/parent/child"); + let html = await doc.text(); + expect(html).toMatch("Parent Fallback"); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Client Loader"); + expect(html).toMatch("Child Client Loader"); + }); + + test("handles deferred data through client loaders", async ({ page }) => { + let fixture = await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { defer, json } from '@remix-run/node' + import { Await, useLoaderData } from '@remix-run/react' + export function loader() { + return defer({ + message: 'Child Server Loader', + lazy: new Promise(r => setTimeout(() => r("Child Deferred Data"), 1000)), + }); + } + export async function clientLoader({ serverLoader }) { + let data = await serverLoader(); + return { + ...data, + message: data.message + " (mutated by client)", + }; + } + clientLoader.hydrate = true; + export function HydrateFallback() { + returnChild Fallback
+ } + export default function Component() { + let data = useLoaderData(); + return ( + <> +{data.message}
+{value}
} +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() { + returnChild 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() { + returnChild
; + } + export function HydrateFallback() { + returnLoading...
; + } + 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() { + returnLoading...
+ } + `, + }, + }) + ); + 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() { + returnLoading...
+ } + `, + }, + }) + ); + 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{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() { + returnChild
; + } + export function HydrateFallback() { + returnLoading...
; + } + export function ErrorBoundary() { + let error = useRouteError(); + return{error.status} {error.data}
; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverLoader() on a route that does " + + 'not have a server loader (routeId: "routes/parent.child")' + ); + }); + }); + + test.describe("clientAction - critical route module", () => { + test("child.clientAction", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture( + { + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }, + ServerMode.Development + ), + ServerMode.Development + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Parent Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); // still revalidating + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("throws a 400 if you call serverAction without a server action", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { Form, useRouteError } from '@remix-run/react'; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( + + ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return{error.status} {error.data}
; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverAction() on a route that does " + + 'not have a server action (routeId: "routes/parent.child")' + ); + }); + }); + + test.describe("clientAction - lazy route module", () => { + test("child.clientAction", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Parent Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: getFiles({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); // still revalidating + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("throws a 400 if you call serverAction without a server action", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createTestFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { Form, useRouteError } from '@remix-run/react'; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( + + ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return{error.status} {error.data}
; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.goto("/parent/child"); + await page.waitForSelector("form"); + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverAction() on a route that does " + + 'not have a server action (routeId: "routes/parent.child")' + ); + }); + }); + }); +}); diff --git a/integration/defer-loader-test.ts b/integration/defer-loader-test.ts index 141c507eeb9..0923b99447f 100644 --- a/integration/defer-loader-test.ts +++ b/integration/defer-loader-test.ts @@ -11,10 +11,11 @@ import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; let fixture: Fixture; let appFixture: AppFixture; -test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/_index.tsx": js` +test.describe("deferred loaders", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` import { useLoaderData, Link } from "@remix-run/react"; export default function Index() { return ( @@ -26,7 +27,7 @@ test.beforeAll(async () => { } `, - "app/routes/redirect.tsx": js` + "app/routes/redirect.tsx": js` import { defer } from "@remix-run/node"; export function loader() { return defer({food: "pizza"}, { status: 301, headers: { Location: "/?redirected" } }); @@ -34,7 +35,7 @@ test.beforeAll(async () => { export default function Redirect() {return null;} `, - "app/routes/direct-promise-access.tsx": js` + "app/routes/direct-promise-access.tsx": js` import * as React from "react"; import { defer } from "@remix-run/node"; import { useLoaderData, Link, Await } from "@remix-run/react"; @@ -66,32 +67,133 @@ test.beforeAll(async () => { ) } `, - }, + }, + }); + + appFixture = await createAppFixture(fixture); }); - appFixture = await createAppFixture(fixture); -}); + test.afterAll(async () => appFixture.close()); -test.afterAll(async () => appFixture.close()); + test("deferred response can redirect on document request", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + await page.waitForURL(/\?redirected/); + }); -test("deferred response can redirect on document request", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/redirect"); - await page.waitForURL(/\?redirected/); -}); + test("deferred response can redirect on transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/redirect"); + await page.waitForURL(/\?redirected/); + }); -test("deferred response can redirect on transition", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/redirect"); - await page.waitForURL(/\?redirected/); + test("can directly access result from deferred promise on document request", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/direct-promise-access"); + let element = await page.waitForSelector("[data-done]"); + expect(await element.innerText()).toMatch("hamburger 1"); + }); }); -test("can directly access result from deferred promise on document request", async ({ - page, -}) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/direct-promise-access"); - let element = await page.waitForSelector("[data-done]"); - expect(await element.innerText()).toMatch("hamburger 1"); +// Duplicate suite of the tests above running with single fetch enabled +// TODO(v3): remove the above suite of tests and just keep these +test.describe("single fetch", () => { + test.describe("deferred loaders", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { + unstable_singleFetch: true, + }, + }, + files: { + "app/routes/_index.tsx": js` + import { useLoaderData, Link } from "@remix-run/react"; + export default function Index() { + return ( +{count}
+interactive
+{id}
+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}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{resolvedDeferredId}
+{deferredId}
+{id}
+{JSON.stringify(value)}
+ ${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("