diff --git a/.changeset/old-cameras-lay.md b/.changeset/old-cameras-lay.md new file mode 100644 index 00000000000..9c321b36ed7 --- /dev/null +++ b/.changeset/old-cameras-lay.md @@ -0,0 +1,22 @@ +--- +"remix": patch +"@remix-run/cloudflare": patch +"@remix-run/deno": patch +"@remix-run/node": patch +"@remix-run/react": patch +"@remix-run/serve": patch +"@remix-run/server-runtime": patch +"@remix-run/testing": patch +--- + +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 loaders by passing promises instead of resolved values. This has been refered to as "promise over the wire". + +Informational Resources: +- https://gist.github.com/jacob-ebey/9bde9546c1aafaa6bc8c242054b1be26 +- https://github.com/remix-run/remix/blob/main/decisions/0004-streaming-apis.md + +Documentation Resources (better docs specific to remix are in the works): +- https://reactrouter.com/en/main/utils/defer +- https://reactrouter.com/en/main/components/await +- https://reactrouter.com/en/main/hooks/use-async-value +- https://reactrouter.com/en/main/hooks/use-async-error diff --git a/integration/defer-loader-test.ts b/integration/defer-loader-test.ts new file mode 100644 index 00000000000..a8a292f9f34 --- /dev/null +++ b/integration/defer-loader-test.ts @@ -0,0 +1,93 @@ +import { test, expect } from "@playwright/test"; + +import { createAppFixture, createFixture, js } from "./helpers/create-fixture"; +import type { Fixture, AppFixture } from "./helpers/create-fixture"; +import { PlaywrightFixture } from "./helpers/playwright-fixture"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/index.jsx": js` + import { useLoaderData, Link } from "@remix-run/react"; + export default function Index() { + return ( +
+ Redirect + Direct Promise Access +
+ ) + } + `, + + "app/routes/redirect.jsx": 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.jsx": 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 00000000000..bbb85be363d --- /dev/null +++ b/integration/defer-test.ts @@ -0,0 +1,1272 @@ +import { test, expect } from "@playwright/test"; +import type { ConsoleMessage, Page } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture"; +import type { Fixture, AppFixture } from "./helpers/create-fixture"; +import { createAppFixture, createFixture, js } from "./helpers/create-fixture"; + +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 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.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/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 = () => ({ + charset: "utf-8", + title: "New Remix App", + viewport: "width=device-width,initial-scale=1", + }); + + 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}"), + }); + } + + 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) + ), + }); + } + + 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) + ), + }); + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

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

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + `, + + "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)}
+ +
+ )} + /> +
+ + ); + } + `, + }, + }); + + // This creates an interactive app using puppeteer. + 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 ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, 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"); + + 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"); + + 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, 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.describe("aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + 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/entry.server.tsx": js` + import { PassThrough } from "stream"; + import type { EntryContext } from "@remix-run/node"; + import { Response } 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 + ) { + 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(); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(body, { + 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(); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(body, { + 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 = () => ({ + charset: "utf-8", + title: "New Remix App", + viewport: "width=device-width,initial-scale=1", + }); + + 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 puppeteer. + 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/helpers/node-template/app/entry.server.tsx b/integration/helpers/node-template/app/entry.server.tsx index 1da4459a9f7..6a43239c7cf 100644 --- a/integration/helpers/node-template/app/entry.server.tsx +++ b/integration/helpers/node-template/app/entry.server.tsx @@ -38,7 +38,11 @@ function handleBotRequest( let didError = false; let { pipe, abort } = renderToPipeableStream( - , + , { onAllReady() { let body = new PassThrough(); @@ -79,7 +83,11 @@ function handleBrowserRequest( let didError = false; let { pipe, abort } = renderToPipeableStream( - , + , { onShellReady() { let body = new PassThrough(); diff --git a/packages/remix-cloudflare/index.ts b/packages/remix-cloudflare/index.ts index 0daeb97dbcf..9f00786cc33 100644 --- a/packages/remix-cloudflare/index.ts +++ b/packages/remix-cloudflare/index.ts @@ -12,6 +12,7 @@ export { export { createRequestHandler, createSession, + defer, isCookie, isSession, json, @@ -62,6 +63,7 @@ export type { SessionIdStorageStrategy, SessionStorage, SignFunction, + TypedDeferredData, TypedResponse, UnsignFunction, UploadHandler, diff --git a/packages/remix-deno/index.ts b/packages/remix-deno/index.ts index b6b6c77b5d9..1e5e24779fd 100644 --- a/packages/remix-deno/index.ts +++ b/packages/remix-deno/index.ts @@ -15,6 +15,7 @@ export { export { createSession, + defer, isCookie, isSession, json, @@ -63,6 +64,7 @@ export type { SessionIdStorageStrategy, SessionStorage, SignFunction, + TypedDeferredData, TypedResponse, UnsignFunction, UploadHandler, diff --git a/packages/remix-node/index.ts b/packages/remix-node/index.ts index 7454b9995c2..73b4711371a 100644 --- a/packages/remix-node/index.ts +++ b/packages/remix-node/index.ts @@ -38,6 +38,7 @@ export { export { createRequestHandler, createSession, + defer, isCookie, isSession, json, @@ -89,6 +90,7 @@ export type { SessionIdStorageStrategy, SessionStorage, SignFunction, + TypedDeferredData, TypedResponse, UnsignFunction, UploadHandler, diff --git a/packages/remix-react/__tests__/hook-types-test.tsx b/packages/remix-react/__tests__/hook-types-test.tsx index 9c2f6821370..57ef9c3ec02 100644 --- a/packages/remix-react/__tests__/hook-types-test.tsx +++ b/packages/remix-react/__tests__/hook-types-test.tsx @@ -1,4 +1,7 @@ -import type { TypedResponse } from "@remix-run/server-runtime"; +import type { + TypedDeferredData, + TypedResponse, +} from "@remix-run/server-runtime"; import type { useLoaderData } from "../components"; @@ -115,3 +118,147 @@ describe("type serializer", () => { isEqual(true); }); }); + +describe("deferred type serializer", () => { + it("supports synchronous loader", () => { + type Loader = ( + args: any + ) => TypedDeferredData<{ hello: string; lazy: Promise }>; + type response = LoaderData; + isEqual }>(true); + }); + + it("supports asynchronous loader", () => { + type Loader = ( + args: any + ) => Promise }>>; + type response = LoaderData; + isEqual }>(true); + }); + + it("supports synchronous loader with deferred object result", () => { + type Loader = ( + args: any + ) => TypedDeferredData<{ hello: string; lazy: Promise<{ a: number }> }>; + type response = LoaderData; + isEqual }>(true); + }); + + it("supports asynchronous loader with deferred object result", () => { + type Loader = ( + args: any + ) => Promise< + TypedDeferredData<{ hello: string; lazy: Promise<{ a: number }> }> + >; + type response = LoaderData; + isEqual }>(true); + }); + + it("converts Date to string", () => { + type Loader = ( + args: any + ) => Promise< + TypedDeferredData<{ hello: Date; lazy: Promise<{ a: Date }> }> + >; + type response = LoaderData; + isEqual }>(true); + }); + + it("supports custom toJSON", () => { + type AppData = { toJSON(): { data: string[] } }; + type Loader = ( + args: any + ) => Promise< + TypedDeferredData<{ hello: AppData; lazy: Promise<{ a: AppData }> }> + >; + type response = LoaderData; + isEqual< + response, + { hello: { data: string[] }; lazy: Promise<{ a: { data: string[] } }> } + >(true); + }); + + it("supports recursion", () => { + type AppData = { dob: Date; parent: AppData }; + type SerializedAppData = { dob: string; parent: SerializedAppData }; + type Loader = ( + args: any + ) => Promise }>>; + type response = LoaderData; + isEqual< + response, + { hello: SerializedAppData; lazy: Promise } + >(true); + }); + + it("supports tuples and arrays", () => { + type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] }; + type SerializedAppData = { + arr: string[]; + tuple: [string, number, string]; + empty: []; + }; + type Loader = ( + args: any + ) => Promise }>>; + type response = LoaderData; + isEqual< + response, + { hello: SerializedAppData; lazy: Promise } + >(true); + }); + + it("transforms unserializables to null in arrays", () => { + type AppData = [Function, symbol, undefined]; + type SerializedAppData = [null, null, null]; + type Loader = ( + args: any + ) => Promise }>>; + type response = LoaderData; + isEqual< + response, + { hello: SerializedAppData; lazy: Promise } + >(true); + }); + + it("transforms unserializables to never in objects", () => { + type AppData = { arg1: Function; arg2: symbol; arg3: undefined }; + type Loader = ( + args: any + ) => Promise }>>; + type response = LoaderData; + isEqual }>(true); + }); + + it("supports class instances", () => { + class Test { + arg: string; + speak: () => string; + } + type Loader = ( + args: any + ) => Promise }>>; + type response = LoaderData; + isEqual< + response, + { hello: { arg: string }; lazy: Promise<{ arg: string }> } + >(true); + }); + + it("makes keys optional if the value is undefined", () => { + type AppData = { + arg1: string; + arg2: number | undefined; + arg3: undefined; + }; + type SerializedAppData = { arg1: string; arg2?: number }; + type Loader = ( + args: any + ) => Promise }>>; + type response = LoaderData; + isEqual< + response, + { hello: SerializedAppData; lazy: Promise } + >(true); + }); +}); diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 38110fe4cb0..d101cc924b9 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -20,6 +20,8 @@ declare global { var __remixContext: { state: HydrationState; future: FutureConfig; + // The number of active deferred keys rendered on the server + a?: number; }; var __remixRouteModules: RouteModules; var __remixManifest: EntryContext["manifest"]; diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 4c7c983f183..d3617cbee42 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -6,8 +6,10 @@ import type { import * as React from "react"; import type { AgnosticDataRouteMatch, + UNSAFE_DeferredData as DeferredData, ErrorResponse, Navigation, + TrackedPromise, } from "@remix-run/router"; import type { LinkProps, @@ -20,12 +22,14 @@ import type { SubmitFunction, } from "react-router-dom"; import { + Await as AwaitRR, Link as RouterLink, NavLink as RouterNavLink, UNSAFE_DataRouterContext as DataRouterContext, UNSAFE_DataRouterStateContext as DataRouterStateContext, isRouteErrorResponse, matchRoutes, + useAsyncError, useFetcher as useFetcherRR, useFetchers as useFetchersRR, useActionData as useActionDataRR, @@ -56,7 +60,7 @@ import { isPageLinkDescriptor, } from "./links"; import type { HtmlLinkDescriptor, PrefetchPageDescriptor } from "./links"; -import { createHtml } from "./markup"; +import { createHtml, escapeHtml } from "./markup"; import type { RouteMatchWithMeta, V1_HtmlMetaDescriptor, @@ -727,6 +731,16 @@ export function Meta() { return future?.v2_meta ? : ; } +export interface AwaitProps { + children: React.ReactNode | ((value: Awaited) => React.ReactElement); + errorElement?: React.ReactNode; + resolve: Resolve; +} + +export function Await(props: AwaitProps) { + return ; +} + /** * Tracks whether Remix has finished hydrating or not, so scripts can be skipped * during client-side updates. @@ -756,8 +770,8 @@ export type ScriptProps = Omit< * @see https://remix.run/components/scripts */ export function Scripts(props: ScriptProps) { - let { manifest, serverHandoffString } = useRemixContext(); - let { router } = useDataRouterContext(); + let { manifest, serverHandoffString, abortDelay } = useRemixContext(); + let { router, static: isStatic, staticContext } = useDataRouterContext(); let { matches } = useDataRouterStateContext(); let navigation = useNavigation(); @@ -765,23 +779,133 @@ export function Scripts(props: ScriptProps) { isHydrated = true; }, []); + let deferredScripts: any[] = []; let initialScripts = React.useMemo(() => { - let contextScript = serverHandoffString + let contextScript = staticContext ? `window.__remixContext = ${serverHandoffString};` - : ""; - - let routeModulesScript = `${matches - .map( - (match, index) => - `import ${JSON.stringify(manifest.url)}; + : " "; + + let activeDeferreds = staticContext?.activeDeferreds; + // This sets up the __remixContext with utility functions used by the + // deferred scripts. + // - __remixContext.p is a function that takes a resolved value or error and returns a promise. + // This is used for transmitting pre-resolved promises from the server to the client. + // - __remixContext.n is a function that takes a routeID and key to returns a promise for later + // resolution by the subsequently streamed chunks. + // - __remixContext.r is a function that takes a routeID, key and value or error and resolves + // the promise created by __remixContext.n. + // - __remixContext.t is a a map or routeId to keys to an object containing `e` and `r` methods + // to resolve or reject the promise created by __remixContext.n. + // - __remixContext.a is the active number of deferred scripts that should be rendered to match + // the SSR tree for hydration on the client. + contextScript += !activeDeferreds + ? "" + : [ + "__remixContext.p = function(v,e,p,x) {", + " if (typeof e !== 'undefined') {", + " x=new Error(e.message);", + process.env.NODE_ENV === "development" ? `x.stack=e.stack;` : "", + " p=Promise.reject(x);", + " } else {", + " p=Promise.resolve(v);", + " }", + " return p;", + "};", + "__remixContext.n = function(i,k) {", + " __remixContext.t = __remixContext.t || {};", + " __remixContext.t[i] = __remixContext.t[i] || {};", + " let p = new Promise((r, e) => {__remixContext.t[i][k] = {r:(v)=>{r(v);},e:(v)=>{e(v);}};});", + typeof abortDelay === "number" + ? `setTimeout(() => {if(typeof p._error !== "undefined" || typeof p._data !== "undefined"){return;} __remixContext.t[i][k].e(new Error("Server timeout."))}, ${abortDelay});` + : "", + " return p;", + "};", + "__remixContext.r = function(i,k,v,e,p,x) {", + " p = __remixContext.t[i][k];", + " if (typeof e !== 'undefined') {", + " x=new Error(e.message);", + process.env.NODE_ENV === "development" ? `x.stack=e.stack;` : "", + " p.e(x);", + " } else {", + " p.r(v);", + " }", + "};", + ].join("\n") + + Object.entries(activeDeferreds) + .map(([routeId, deferredData]) => { + let pendingKeys = new Set(deferredData.pendingKeys); + let promiseKeyValues = deferredData.deferredKeys + .map((key) => { + if (pendingKeys.has(key)) { + deferredScripts.push( + + ); + + return `${JSON.stringify( + key + )}:__remixContext.n(${JSON.stringify( + routeId + )}, ${JSON.stringify(key)})`; + } else { + let trackedPromise = deferredData.data[key] as TrackedPromise; + if (typeof trackedPromise._error !== "undefined") { + let toSerialize: { message: string; stack?: string } = { + message: trackedPromise._error.message, + stack: undefined, + }; + if (process.env.NODE_ENV === "development") { + toSerialize.stack = trackedPromise._error.stack; + } + return `${JSON.stringify( + key + )}:__remixContext.p(!1, ${escapeHtml( + JSON.stringify(toSerialize) + )})`; + } else { + if (typeof trackedPromise._data === "undefined") { + throw new Error( + `The deferred data for ${key} was not resolved, did you forget to return data from a deferred promise?` + ); + } + return `${JSON.stringify( + key + )}:__remixContext.p(${escapeHtml( + JSON.stringify(trackedPromise._data) + )})`; + } + } + }) + .join(",\n"); + return `Object.assign(__remixContext.state.loaderData[${JSON.stringify( + routeId + )}], {${promiseKeyValues}});`; + }) + .join("\n") + + (deferredScripts.length > 0 + ? `__remixContext.a=${deferredScripts.length};` + : ""); + + let routeModulesScript = !isStatic + ? " " + : `${matches + .map( + (match, index) => + `import ${JSON.stringify(manifest.url)}; import * as route${index} from ${JSON.stringify( - manifest.routes[match.route.id].module - )};` - ) - .join("\n")} + manifest.routes[match.route.id].module + )};` + ) + .join("\n")} window.__remixRouteModules = {${matches - .map((match, index) => `${JSON.stringify(match.route.id)}:route${index}`) - .join(",")}}; + .map( + (match, index) => `${JSON.stringify(match.route.id)}:route${index}` + ) + .join(",")}}; import(${JSON.stringify(manifest.entry.module)});`; @@ -808,6 +932,12 @@ import(${JSON.stringify(manifest.entry.module)});`; // eslint-disable-next-line }, []); + if (!isStatic && typeof __remixContext === "object" && __remixContext.a) { + for (let i = 0; i < __remixContext.a; i++) { + deferredScripts.push(); + } + } + // avoid waterfall when importing the next route module let nextMatches = React.useMemo(() => { if (navigation.location) { @@ -853,11 +983,105 @@ import(${JSON.stringify(manifest.entry.module)});`; crossOrigin={props.crossOrigin} /> ))} - {isHydrated ? null : initialScripts} + {!isHydrated && initialScripts} + {!isHydrated && deferredScripts} ); } +function DeferredHydrationScript({ + dataKey, + deferredData, + routeId, +}: { + dataKey?: string; + deferredData?: DeferredData; + routeId?: string; +}) { + if (typeof document === "undefined" && deferredData && dataKey && routeId) { + invariant( + deferredData.pendingKeys.includes(dataKey), + `Deferred data for route ${routeId} with key ${dataKey} was not pending but tried to render a script for it.` + ); + } + + return ( + + ) + } + > + {typeof document === "undefined" && deferredData && dataKey && routeId ? ( + + } + children={(data) => ( +