diff --git a/.changeset/chilled-masks-search.md b/.changeset/chilled-masks-search.md new file mode 100644 index 0000000000..2dfd04cf76 --- /dev/null +++ b/.changeset/chilled-masks-search.md @@ -0,0 +1,12 @@ +--- +"react-router-dom": major +"react-router": major +--- + +Remove the original `defer` implementation in favor of using raw promises via single fetch and `turbo-stream`. This removes these exports from React Router: + +- `defer` +- `AbortedDeferredError` +- `type TypedDeferredData` +- `UNSAFE_DeferredData` +- `UNSAFE_DEFERRED_SYMBOL`, diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index fd0fd9fd6f..41b6556849 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -318,13 +318,13 @@ test.describe("Client Data", () => { }), "app/routes/parent.child.tsx": js` import * as React from 'react'; - import { defer, json } from "react-router" + import { json } from "react-router" import { Await, useLoaderData } from "react-router" export function loader() { - return defer({ + return { message: 'Child Server Loader', lazy: new Promise(r => setTimeout(() => r("Child Deferred Data"), 1000)), - }); + }; } export async function clientLoader({ serverLoader }) { let data = await serverLoader(); diff --git a/integration/defer-loader-test.ts b/integration/defer-loader-test.ts index d5858b0481..e8464e2105 100644 --- a/integration/defer-loader-test.ts +++ b/integration/defer-loader-test.ts @@ -28,23 +28,23 @@ test.describe("deferred loaders", () => { `, "app/routes/redirect.tsx": js` - import { defer } from "react-router"; - export function loader() { - return defer({food: "pizza"}, { status: 301, headers: { Location: "/?redirected" } }); + export function loader({ response }) { + response.status = 301; + response.headers.set("Location", "/?redirected"); + return { food: "pizza" }; } export default function Redirect() {return null;} `, "app/routes/direct-promise-access.tsx": js` import * as React from "react"; - import { defer } from "react-router"; import { useLoaderData, Link, Await } from "react-router"; export function loader() { - return defer({ + return { bar: new Promise(async (resolve, reject) => { resolve("hamburger"); }), - }); + }; } let count = 0; export default function Index() { diff --git a/integration/defer-test.ts b/integration/defer-test.ts index 9d16c38dff..4c8dfc792e 100644 --- a/integration/defer-test.ts +++ b/integration/defer-test.ts @@ -15,8 +15,6 @@ const DEFERRED_ID = "DEFERRED_ID"; const RESOLVED_DEFERRED_ID = "RESOLVED_DEFERRED_ID"; const FALLBACK_ID = "FALLBACK_ID"; const ERROR_ID = "ERROR_ID"; -const UNDEFINED_ERROR_ID = "UNDEFINED_ERROR_ID"; -const NEVER_SHOW_ID = "NEVER_SHOW_ID"; const ERROR_BOUNDARY_ID = "ERROR_BOUNDARY_ID"; const MANUAL_RESOLVED_ID = "MANUAL_RESOLVED_ID"; const MANUAL_FALLBACK_ID = "MANUAL_FALLBACK_ID"; @@ -75,7 +73,6 @@ test.describe("non-aborted", () => { } `, "app/root.tsx": js` - import { defer } from "react-router"; import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router"; import Counter from "~/components/counter"; import Interactive from "~/components/interactive"; @@ -84,7 +81,7 @@ test.describe("non-aborted", () => { return [{ title: "New Remix App" }]; }; - export const loader = () => defer({ + export const loader = () => ({ id: "${ROOT_ID}", }); @@ -116,14 +113,13 @@ test.describe("non-aborted", () => { `, "app/routes/_index.tsx": js` - import { defer } from "react-router"; import { Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { id: "${INDEX_ID}", - }); + }; } export default function Index() { @@ -148,15 +144,14 @@ test.describe("non-aborted", () => { "app/routes/deferred-noscript-resolved.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), - }); + }; } export default function Deferred() { @@ -183,19 +178,18 @@ test.describe("non-aborted", () => { "app/routes/deferred-noscript-unresolved.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: new Promise( (resolve) => setTimeout(() => { resolve("${RESOLVED_DEFERRED_ID}"); }, 10) ), - }); + }; } export default function Deferred() { @@ -222,16 +216,15 @@ test.describe("non-aborted", () => { "app/routes/deferred-script-resolved.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), deferredUndefined: Promise.resolve(undefined), - }); + }; } export default function Deferred() { @@ -258,12 +251,11 @@ test.describe("non-aborted", () => { "app/routes/deferred-script-unresolved.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: new Promise( (resolve) => setTimeout(() => { @@ -275,7 +267,7 @@ test.describe("non-aborted", () => { resolve(undefined); }, 10) ), - }); + }; } export default function Deferred() { @@ -302,15 +294,14 @@ test.describe("non-aborted", () => { "app/routes/deferred-script-rejected.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), - }); + }; } export default function Deferred() { @@ -343,24 +334,18 @@ test.describe("non-aborted", () => { "app/routes/deferred-script-unrejected.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: new Promise( (_, reject) => setTimeout(() => { reject(new Error("${RESOLVED_DEFERRED_ID}")); }, 10) ), - resolvedUndefined: new Promise( - (resolve) => setTimeout(() => { - resolve(undefined); - }, 10) - ), - }); + }; } export default function Deferred() { @@ -386,22 +371,6 @@ test.describe("non-aborted", () => { )} /> - - - error - - - } - children={(resolvedDeferredId) => ( -
- {"${NEVER_SHOW_ID}"} -
- )} - /> -
); } @@ -409,15 +378,14 @@ test.describe("non-aborted", () => { "app/routes/deferred-script-rejected-no-error-element.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), - }); + }; } export default function Deferred() { @@ -453,19 +421,18 @@ test.describe("non-aborted", () => { "app/routes/deferred-script-unrejected-no-error-element.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: new Promise( (_, reject) => setTimeout(() => { reject(new Error("${RESOLVED_DEFERRED_ID}")); }, 10) ), - }); + }; } export default function Deferred() { @@ -501,7 +468,6 @@ test.describe("non-aborted", () => { "app/routes/deferred-manual-resolve.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; @@ -516,7 +482,7 @@ test.describe("non-aborted", () => { global.__deferredManualResolveCache.deferreds[id] = { resolve, reject }; }); - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: new Promise( (resolve) => setTimeout(() => { @@ -525,7 +491,7 @@ test.describe("non-aborted", () => { ), id, manualValue: promise, - }); + }; } export default function Deferred() { @@ -566,23 +532,6 @@ test.describe("non-aborted", () => { ); } `, - - "app/routes/headers.tsx": js` - import { defer } from "react-router"; - export function loader() { - return defer({}, { headers: { "x-custom-header": "value from loader" } }); - } - export function headers({ loaderHeaders }) { - return { - "x-custom-header": loaderHeaders.get("x-custom-header") - } - } - export default function Component() { - return ( -
Headers
- ) - } - `, }, }); @@ -620,17 +569,18 @@ test.describe("non-aborted", () => { await assertConsole(); }); - test("resolved promises render in initial payload", async ({ page }) => { + test("resolved promises do not 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(counterHtml(ROOT_ID, 0)); expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).not.toContain(FALLBACK_ID); - expect(criticalHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).not.toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).not.toBe(""); - expect(deferredHTML).not.toContain('

{ let criticalHTML = html.slice(0, html.indexOf("") + 7); expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).not.toContain(FALLBACK_ID); - expect(criticalHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).not.toBe(""); - expect(deferredHTML).not.toContain('

{ let criticalHTML = html.slice(0, html.indexOf("") + 7); expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); - expect(criticalHTML).not.toContain(FALLBACK_ID); - expect(criticalHTML).toContain(counterHtml(ERROR_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); let deferredHTML = html.slice(html.indexOf("") + 7); - expect(deferredHTML).not.toBe(""); - expect(deferredHTML).not.toContain('

{ await page.waitForSelector(`#${ROOT_ID}`); await page.waitForSelector(`#${DEFERRED_ID}`); await page.waitForSelector(`#${ERROR_ID}`); - await page.waitForSelector(`#${UNDEFINED_ERROR_ID}`); await ensureInteractivity(page, ROOT_ID); await ensureInteractivity(page, DEFERRED_ID); await ensureInteractivity(page, ERROR_ID); - await ensureInteractivity(page, UNDEFINED_ERROR_ID); await assertConsole(); }); @@ -926,7 +870,6 @@ test.describe("non-aborted", () => { await ensureInteractivity(page, DEFERRED_ID); await ensureInteractivity(page, ERROR_ID); - await ensureInteractivity(page, UNDEFINED_ERROR_ID); await ensureInteractivity(page, DEFERRED_ID, 2); await ensureInteractivity(page, ROOT_ID, 2); @@ -964,20 +907,6 @@ test.describe("non-aborted", () => { await ensureInteractivity(page, ERROR_BOUNDARY_ID); await ensureInteractivity(page, ROOT_ID, 2); }); - - test("returns headers on document requests", async ({ page }) => { - let response = await fixture.requestDocument("/headers"); - expect(response.headers.get("x-custom-header")).toEqual( - "value from loader" - ); - }); - - test("returns headers on data requests", async ({ page }) => { - let response = await fixture.requestSingleFetchData("/headers.data"); - expect(response.headers.get("x-custom-header")).toEqual( - "value from loader" - ); - }); }); test.describe("aborted", () => { @@ -1142,7 +1071,6 @@ test.describe("aborted", () => { } `, "app/root.tsx": js` - import { defer } from "react-router"; import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router"; import Counter from "~/components/counter"; import Interactive from "~/components/interactive"; @@ -1151,7 +1079,7 @@ test.describe("aborted", () => { return [{ title: "New Remix App" }]; }; - export const loader = () => defer({ + export const loader = () => ({ id: "${ROOT_ID}", }); @@ -1184,19 +1112,18 @@ test.describe("aborted", () => { "app/routes/deferred-server-aborted.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: new Promise( (resolve) => setTimeout(() => { resolve("${RESOLVED_DEFERRED_ID}"); }, 10000) ), - }); + }; } export default function Deferred() { @@ -1229,19 +1156,18 @@ test.describe("aborted", () => { "app/routes/deferred-server-aborted-no-error-element.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, Link, useLoaderData } from "react-router"; import Counter from "~/components/counter"; export function loader() { - return defer({ + return { deferredId: "${DEFERRED_ID}", resolvedId: new Promise( (resolve) => setTimeout(() => { resolve("${RESOLVED_DEFERRED_ID}"); }, 10000) ), - }); + }; } export default function Deferred() { diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts index 1e2d8ab388..e0614e428e 100644 --- a/integration/error-sanitization-test.ts +++ b/integration/error-sanitization-test.ts @@ -79,17 +79,17 @@ const routeFiles = { "app/routes/defer.tsx": js` import * as React from 'react'; - import { defer, Await, useAsyncError, useLoaderData, useRouteError } from "react-router"; + import { Await, useAsyncError, useLoaderData, useRouteError } from "react-router"; export function loader({ request }) { if (new URL(request.url).searchParams.has('loader')) { - return defer({ + return { lazy: Promise.reject(new Error("REJECTED")), - }) + }; } - return defer({ + return { lazy: Promise.resolve("RESOLVED"), - }) + }; } export default function Component() { diff --git a/integration/resource-routes-test.ts b/integration/resource-routes-test.ts index 58b07d5678..842edd0108 100644 --- a/integration/resource-routes-test.ts +++ b/integration/resource-routes-test.ts @@ -58,9 +58,7 @@ test.describe("loader in an app", async () => { export default () =>

You made it!
`, "app/routes/defer.tsx": js` - import { defer } from "react-router"; - - export let loader = () => defer({ data: 'whatever' }); + export let loader = () => ({ data: 'whatever' }); `, "app/routes/data[.]json.tsx": js` import { json } from "react-router"; @@ -259,8 +257,7 @@ test.describe("loader in an app", async () => { let res = await app.goto("/defer"); expect(res.status()).toBe(500); expect(await res.text()).toMatch( - "You cannot return a `defer()` response from a Resource Route. " + - 'Did you forget to export a default UI component from the "routes/defer" route?' + "Error: Expected a Response to be returned from resource route handler" ); }); }); diff --git a/integration/vite-dev-test.ts b/integration/vite-dev-test.ts index 042b2b3fac..84431d92c6 100644 --- a/integration/vite-dev-test.ts +++ b/integration/vite-dev-test.ts @@ -57,14 +57,13 @@ test.describe("Vite dev", () => { `, "app/routes/_index.tsx": js` import { Suspense } from "react"; - import { defer } from "react-router"; import { Await, useLoaderData } from "react-router"; export function loader() { let deferred = new Promise((resolve) => { setTimeout(() => resolve(true), 1000) }); - return defer({ deferred }); + return { deferred }; } export default function IndexRoute() { diff --git a/packages/react-router-dom/index.ts b/packages/react-router-dom/index.ts index 7383fec854..9252d1ef7d 100644 --- a/packages/react-router-dom/index.ts +++ b/packages/react-router-dom/index.ts @@ -72,7 +72,6 @@ export type { InitialEntry, StaticHandler, TrackedPromise, - UNSAFE_DeferredData, FormEncType, FormMethod, GetScrollRestorationKeyFunction, @@ -107,7 +106,6 @@ export type { } from "react-router"; export { - AbortedDeferredError, Await, MemoryRouter, Navigate, @@ -121,7 +119,6 @@ export { createPath, createRoutesFromChildren, createRoutesFromChildren as createRoutesFromElements, - defer, generatePath, isRouteErrorResponse, json, @@ -155,7 +152,6 @@ export { useRoutes, getStaticContextFromError, stripBasename, - UNSAFE_DEFERRED_SYMBOL, UNSAFE_convertRoutesToDataRoutes, createBrowserRouter, createHashRouter, diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index adf92675a2..c1d807d97e 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -16,7 +16,6 @@ import { Routes, createMemoryRouter, createRoutesFromElements, - defer, redirect, useActionData, useAsyncError, @@ -1131,7 +1130,7 @@ describe("createMemoryRouter", () => { return ++count; }, Component() { - let loaderCount = useLoaderData(); + let loaderCount = useLoaderData() as number; let revalidator = useRevalidator(); sequence.push(`render ${loaderCount}`); return ( @@ -2308,12 +2307,14 @@ describe("createMemoryRouter", () => { expect(getHtml(container)).toMatch("Yes"); }); - it("handles a `null` render-error from a defer() call", async () => { + it("handles a `null` render-error from a promise", async () => { let router = createMemoryRouter([ { path: "/", loader() { - return defer({ lazy: Promise.reject(null) }); + let promise = Promise.reject(null); + promise.catch(() => {}); + return { lazy: promise }; }, Component() { let data = useLoaderData() as { lazy: Promise }; @@ -2781,12 +2782,10 @@ describe("createMemoryRouter", () => { `); let barValueDfd = createDeferred(); - barDefer.resolve( - defer({ - critical: "CRITICAL", - lazy: barValueDfd.promise, - }) - ); + barDefer.resolve({ + critical: "CRITICAL", + lazy: barValueDfd.promise, + }); await waitFor(() => screen.getByText("idle")); expect(getHtml(container)).toMatchInlineSnapshot(` "
{ `); let barValueDfd = createDeferred(); - barDefer.resolve( - defer({ - critical: "CRITICAL", - lazy: barValueDfd.promise, - }) - ); + barDefer.resolve({ + critical: "CRITICAL", + lazy: barValueDfd.promise, + }); await waitFor(() => screen.getByText("idle")); expect(getHtml(container)).toMatchInlineSnapshot(` "
{ `); let barValueDfd = createDeferred(); - barDefer.resolve( - defer({ - critical: "CRITICAL", - lazy: barValueDfd.promise, - }) - ); + barDefer.resolve({ + critical: "CRITICAL", + lazy: barValueDfd.promise, + }); await waitFor(() => screen.getByText("idle")); expect(getHtml(container)).toMatchInlineSnapshot(` "
{ `); let barValueDfd = createDeferred(); - barDefer.resolve( - defer({ - critical: "CRITICAL", - lazy: barValueDfd.promise, - }) - ); + barDefer.resolve({ + critical: "CRITICAL", + lazy: barValueDfd.promise, + }); await waitFor(() => screen.getByText("idle")); expect(getHtml(container)).toMatchInlineSnapshot(` "
{ `); let barValueDfd = createDeferred(); - barDefer.resolve( - defer({ - critical: "CRITICAL", - lazy: barValueDfd.promise, - }) - ); + barDefer.resolve({ + critical: "CRITICAL", + lazy: barValueDfd.promise, + }); await waitFor(() => screen.getByText("idle")); expect(getHtml(container)).toMatchInlineSnapshot(` "
{ `); let barValueDfd = createDeferred(); - barDefer.resolve( - defer({ - critical: "CRITICAL", - lazy: barValueDfd.promise, - }) - ); + barDefer.resolve({ + critical: "CRITICAL", + lazy: barValueDfd.promise, + }); await waitFor(() => screen.getByText("idle")); expect(getHtml(container)).toMatchInlineSnapshot(` "
{ `); let barValueDfd = createDeferred(); - barDefer.resolve( - defer({ - critical: "CRITICAL", - lazy: barValueDfd.promise, - }) - ); + barDefer.resolve({ + critical: "CRITICAL", + lazy: barValueDfd.promise, + }); await waitFor(() => screen.getByText("idle")); expect(getHtml(container)).toMatchInlineSnapshot(` "
{ expect(getAwaitRenderCount()).toBe(0); let barValueDfd = createDeferred(); - barDefer.resolve( - defer({ - critical: "CRITICAL", - lazy: barValueDfd.promise, - }) - ); + barDefer.resolve({ + critical: "CRITICAL", + lazy: barValueDfd.promise, + }); await waitFor(() => screen.getByText("idle")); expect(getHtml(container)).toMatchInlineSnapshot(` "
{

" `); - // 2 more renders by now - once for the navigation and once for the - // promise abort rejection - expect(getAwaitRenderCount()).toBe(3); + expect(getAwaitRenderCount()).toBe(2); // complete /baz navigation bazDefer.resolve(null); @@ -3292,7 +3275,7 @@ describe("createMemoryRouter", () => {
" `); - expect(getAwaitRenderCount()).toBe(3); + expect(getAwaitRenderCount()).toBe(2); }); it("should permit direct access to resolved values", async () => { diff --git a/packages/react-router/__tests__/dom/data-browser-router-test.tsx b/packages/react-router/__tests__/dom/data-browser-router-test.tsx index 9d4c30991c..b0ed946b1b 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -11,6 +11,7 @@ import { JSDOM } from "jsdom"; import * as React from "react"; import type { RouteObject } from "../../index"; import { + Await, UNSAFE_DataRouterStateContext as DataRouterStateContext, Form, Link, @@ -20,11 +21,11 @@ import { createBrowserRouter, createHashRouter, createRoutesFromElements, - defer, isRouteErrorResponse, matchRoutes, redirect, useActionData, + useAsyncError, useFetcher, useFetchers, useLoaderData, @@ -4009,7 +4010,7 @@ function testDomRouter( path="/" element={} errorElement={} - loader={() => defer({ value: dfd.promise })} + loader={() => ({ value: dfd.promise })} /> ), { @@ -4023,17 +4024,26 @@ function testDomRouter( let fetcher = useFetcher(); return ( <> -

- {fetcher.state} - {fetcher.data ? JSON.stringify(fetcher.data.value) : null} -

+

{fetcher.state}

+ {fetcher.data ? ( + Loading 2...

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

{val}

} +
+
+ ) : ( +

Loading 1...

+ )} ); } function ErrorElement() { - let error = useRouteError() as Error; + let error = useAsyncError() as Error; return

{error.message}

; } @@ -4042,6 +4052,9 @@ function testDomRouter(

idle

+

+ Loading 1... +

@@ -4054,6 +4067,24 @@ function testDomRouter(

loading

+

+ Loading 1... +

+ +
" + `); + + await waitFor(() => screen.getByText("Loading 2...")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ idle +

+

+ Loading 2... +

@@ -4064,9 +4095,15 @@ function testDomRouter( await waitFor(() => screen.getByText("Kaboom!")); expect(getHtml(container)).toMatchInlineSnapshot(` "
+

+ idle +

Kaboom!

+
" `); }); diff --git a/packages/react-router/__tests__/router/defer-test.ts b/packages/react-router/__tests__/router/defer-test.ts deleted file mode 100644 index e32a5a5725..0000000000 --- a/packages/react-router/__tests__/router/defer-test.ts +++ /dev/null @@ -1,1714 +0,0 @@ -import { createMemoryHistory } from "../../lib/router"; -import { createRouter } from "../../lib/router"; -import { - AbortedDeferredError, - UNSAFE_ErrorResponseImpl as ErrorResponseImpl, - defer, -} from "../../lib/router"; -import { deferredData, trackedPromise } from "./utils/custom-matchers"; -import { - cleanup, - createDeferred, - getFetcherData, - setup, -} from "./utils/data-router-setup"; -import { createFormData, tick } from "./utils/utils"; - -interface CustomMatchers { - trackedPromise(data?: any, error?: any, aborted?: boolean): R; - deferredData( - done: boolean, - status?: number, - headers?: Record - ): R; -} - -declare global { - namespace jest { - interface Expect extends CustomMatchers {} - interface Matchers extends CustomMatchers {} - interface InverseAsymmetricMatchers extends CustomMatchers {} - } -} - -expect.extend({ - deferredData, - trackedPromise, -}); - -describe("deferred data", () => { - // Detect any failures inside the router navigate code - afterEach(() => { - cleanup(); - }); - - it("should not track deferred responses on naked objects", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - }, - { - id: "lazy", - path: "lazy", - loader: true, - }, - ], - initialEntries: ["/"], - }); - - let A = await t.navigate("/lazy"); - - let dfd = createDeferred(); - await A.loaders.lazy.resolve({ - critical: "1", - lazy: dfd.promise, - }); - expect(t.router.state.loaderData).toEqual({ - lazy: { - critical: "1", - lazy: expect.any(Promise), - }, - }); - expect(t.router.state.loaderData.lazy.lazy._tracked).toBeUndefined(); - }); - - it("should support returning deferred responses", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - }, - { - id: "lazy", - path: "lazy", - loader: true, - }, - ], - initialEntries: ["/"], - }); - - let A = await t.navigate("/lazy"); - - let dfd1 = createDeferred(); - let dfd2 = createDeferred(); - let dfd3 = createDeferred(); - dfd1.resolve("Immediate data"); - await A.loaders.lazy.resolve( - defer({ - critical1: "1", - critical2: "2", - lazy1: dfd1.promise, - lazy2: dfd2.promise, - lazy3: dfd3.promise, - }) - ); - expect(t.router.state.loaderData).toEqual({ - lazy: { - critical1: "1", - critical2: "2", - lazy1: expect.trackedPromise("Immediate data"), - lazy2: expect.trackedPromise(), - lazy3: expect.trackedPromise(), - }, - }); - - await dfd2.resolve("2"); - expect(t.router.state.loaderData).toEqual({ - lazy: { - critical1: "1", - critical2: "2", - lazy1: expect.trackedPromise("Immediate data"), - lazy2: expect.trackedPromise("2"), - lazy3: expect.trackedPromise(), - }, - }); - - await dfd3.resolve("3"); - expect(t.router.state.loaderData).toEqual({ - lazy: { - critical1: "1", - critical2: "2", - lazy1: expect.trackedPromise("Immediate data"), - lazy2: expect.trackedPromise("2"), - lazy3: expect.trackedPromise("3"), - }, - }); - - // Should proxy values through - let data = t.router.state.loaderData.lazy; - await expect(data.lazy1).resolves.toBe("Immediate data"); - await expect(data.lazy2).resolves.toBe("2"); - await expect(data.lazy3).resolves.toBe("3"); - }); - - it("should cancel outstanding deferreds on a new navigation", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "lazy", - path: "lazy", - loader: true, - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - let A = await t.navigate("/lazy"); - let dfd1 = createDeferred(); - let dfd2 = createDeferred(); - await A.loaders.lazy.resolve( - defer({ - critical1: "1", - critical2: "2", - lazy1: dfd1.promise, - lazy2: dfd2.promise, - }) - ); - - // Interrupt pending deferred's from /lazy navigation - let navPromise = t.navigate("/"); - - // Cancelled promises should reject immediately - let data = t.router.state.loaderData.lazy; - await expect(data.lazy1).rejects.toBeInstanceOf(AbortedDeferredError); - await expect(data.lazy2).rejects.toBeInstanceOf(AbortedDeferredError); - await expect(data.lazy1).rejects.toThrow("Deferred data aborted"); - await expect(data.lazy2).rejects.toThrow("Deferred data aborted"); - - let B = await navPromise; - - // During navigation - deferreds remain as promises - expect(t.router.state.loaderData).toEqual({ - lazy: { - critical1: "1", - critical2: "2", - lazy1: expect.trackedPromise(null, null, true), - lazy2: expect.trackedPromise(null, null, true), - }, - }); - - // But they are frozen - no re-paints on resolve/reject! - await dfd1.resolve("a"); - await dfd2.reject(new Error("b")); - expect(t.router.state.loaderData).toEqual({ - lazy: { - critical1: "1", - critical2: "2", - lazy1: expect.trackedPromise(null, null, true), - lazy2: expect.trackedPromise(null, null, true), - }, - }); - - await B.loaders.index.resolve("INDEX*"); - expect(t.router.state.loaderData).toEqual({ - index: "INDEX*", - }); - }); - - it("should not cancel outstanding deferreds on reused routes", async () => { - let t = setup({ - routes: [ - { - id: "root", - path: "/", - loader: true, - }, - { - id: "parent", - path: "parent", - loader: true, - children: [ - { - id: "a", - path: "a", - loader: true, - }, - { - id: "b", - path: "b", - loader: true, - }, - ], - }, - ], - hydrationData: { loaderData: { root: "ROOT" } }, - initialEntries: ["/"], - }); - - let A = await t.navigate("/parent/a"); - let parentDfd = createDeferred(); - await A.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT", - lazy: parentDfd.promise, - }) - ); - let aDfd = createDeferred(); - await A.loaders.a.resolve( - defer({ - critical: "CRITICAL A", - lazy: aDfd.promise, - }) - ); - - // Navigate such that we reuse the parent route - let B = await t.navigate("/parent/b"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(), - }, - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(), - }, - }); - - // This should reflect in loaderData - await parentDfd.resolve("LAZY PARENT"); - // This should not - await aDfd.resolve("LAZY A"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise("LAZY PARENT"), - }, - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(null, null, true), // No re-paint! - }, - }); - - // Complete the navigation - await B.loaders.b.resolve("B DATA"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise("LAZY PARENT"), - }, - b: "B DATA", - }); - }); - - it("should handle promise rejections", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - }, - { - id: "lazy", - path: "lazy", - loader: true, - }, - ], - initialEntries: ["/"], - }); - - let A = await t.navigate("/lazy"); - - let dfd = createDeferred(); - await A.loaders.lazy.resolve( - defer({ - critical: "1", - lazy: dfd.promise, - }) - ); - - await dfd.reject(new Error("Kaboom!")); - expect(t.router.state.loaderData).toEqual({ - lazy: { - critical: "1", - lazy: expect.trackedPromise(undefined, new Error("Kaboom!")), - }, - }); - - // should proxy the error through - let data = t.router.state.loaderData.lazy; - await expect(data.lazy).rejects.toEqual(new Error("Kaboom!")); - }); - - it("should cancel all outstanding deferreds on router.revalidate()", async () => { - let shouldRevalidateSpy = jest.fn(() => false); - let t = setup({ - routes: [ - { - id: "root", - path: "/", - loader: true, - }, - { - id: "parent", - path: "parent", - loader: true, - shouldRevalidate: shouldRevalidateSpy, - children: [ - { - id: "index", - index: true, - loader: true, - }, - ], - }, - ], - hydrationData: { loaderData: { root: "ROOT" } }, - initialEntries: ["/"], - }); - - let A = await t.navigate("/parent"); - let parentDfd = createDeferred(); - await A.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT", - lazy: parentDfd.promise, - }) - ); - let indexDfd = createDeferred(); - await A.loaders.index.resolve( - defer({ - critical: "CRITICAL INDEX", - lazy: indexDfd.promise, - }) - ); - - // Trigger a revalidation which should cancel outstanding deferreds - let R = await t.revalidate(); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(), - }, - index: { - critical: "CRITICAL INDEX", - lazy: expect.trackedPromise(), - }, - }); - - // Neither should reflect in loaderData - await parentDfd.resolve("Nope!"); - await indexDfd.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(null, null, true), - }, - index: { - critical: "CRITICAL INDEX", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - // Complete the revalidation - let parentDfd2 = createDeferred(); - await R.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT 2", - lazy: parentDfd2.promise, - }) - ); - let indexDfd2 = createDeferred(); - await R.loaders.index.resolve( - defer({ - critical: "CRITICAL INDEX 2", - lazy: indexDfd2.promise, - }) - ); - - // Revalidations await all deferreds, so we're still in a loading - // state with the prior loaderData here - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.revalidation).toBe("loading"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(null, null, true), - }, - index: { - critical: "CRITICAL INDEX", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - await indexDfd2.resolve("LAZY INDEX 2"); - // Not done yet! - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.revalidation).toBe("loading"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(null, null, true), - }, - index: { - critical: "CRITICAL INDEX", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - await parentDfd2.resolve("LAZY PARENT 2"); - // Done now that all deferreds have resolved - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.revalidation).toBe("idle"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT 2", - lazy: expect.trackedPromise("LAZY PARENT 2"), - }, - index: { - critical: "CRITICAL INDEX 2", - lazy: expect.trackedPromise("LAZY INDEX 2"), - }, - }); - - expect(shouldRevalidateSpy).not.toHaveBeenCalled(); - }); - - it("cancels correctly on revalidations chains", async () => { - let shouldRevalidateSpy = jest.fn(() => false); - let t = setup({ - routes: [ - { - id: "root", - path: "/", - }, - { - id: "foo", - path: "foo", - loader: true, - shouldRevalidate: shouldRevalidateSpy, - }, - ], - }); - - let A = await t.navigate("/foo"); - let dfda = createDeferred(); - await A.loaders.foo.resolve( - defer({ - critical: "CRITICAL A", - lazy: dfda.promise, - }) - ); - expect(t.router.state.loaderData).toEqual({ - foo: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(), - }, - }); - - let B = await t.revalidate(); - let dfdb = createDeferred(); - // This B data will _never_ make it through - since we will await all of - // it and we'll revalidate before it resolves - await B.loaders.foo.resolve( - defer({ - critical: "CRITICAL B", - lazy: dfdb.promise, - }) - ); - // The initial revalidation cancelled the navigation deferred - await dfda.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({ - foo: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - let C = await t.revalidate(); - let dfdc = createDeferred(); - await C.loaders.foo.resolve( - defer({ - critical: "CRITICAL C", - lazy: dfdc.promise, - }) - ); - // The second revalidation should have cancelled the first revalidation - // deferred - await dfdb.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({ - foo: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - // Resolve the final revalidation which should make it into loaderData - await dfdc.resolve("Yep!"); - expect(t.router.state.loaderData).toEqual({ - foo: { - critical: "CRITICAL C", - lazy: expect.trackedPromise("Yep!"), - }, - }); - - expect(shouldRevalidateSpy).not.toHaveBeenCalled(); - }); - - it("cancels correctly on revalidations interrupted by navigations", async () => { - let t = setup({ - routes: [ - { - id: "root", - path: "/", - }, - { - id: "foo", - path: "foo", - loader: true, - }, - { - id: "bar", - path: "bar", - loader: true, - }, - ], - }); - - let A = await t.navigate("/foo"); - let dfda = createDeferred(); - await A.loaders.foo.resolve( - defer({ - critical: "CRITICAL A", - lazy: dfda.promise, - }) - ); - await dfda.resolve("LAZY A"); - expect(t.router.state.loaderData).toEqual({ - foo: { - critical: "CRITICAL A", - lazy: expect.trackedPromise("LAZY A"), - }, - }); - - let B = await t.revalidate(); - let dfdb = createDeferred(); - await B.loaders.foo.resolve( - defer({ - critical: "CRITICAL B", - lazy: dfdb.promise, - }) - ); - // B not reflected because its got existing loaderData - expect(t.router.state.loaderData).toEqual({ - foo: { - critical: "CRITICAL A", - lazy: expect.trackedPromise("LAZY A"), - }, - }); - - let C = await t.navigate("/bar"); - let dfdc = createDeferred(); - await C.loaders.bar.resolve( - defer({ - critical: "CRITICAL C", - lazy: dfdc.promise, - }) - ); - // The second revalidation should have cancelled the first revalidation - // deferred - await dfdb.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({ - bar: { - critical: "CRITICAL C", - lazy: expect.trackedPromise(), - }, - }); - - await dfdc.resolve("Yep!"); - expect(t.router.state.loaderData).toEqual({ - bar: { - critical: "CRITICAL C", - lazy: expect.trackedPromise("Yep!"), - }, - }); - }); - - it("cancels pending deferreds on 404 navigations", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "lazy", - path: "lazy", - loader: true, - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - let A = await t.navigate("/lazy"); - let dfd = createDeferred(); - await A.loaders.lazy.resolve( - defer({ - critical: "CRITICAL", - lazy: dfd.promise, - }) - ); - - await t.navigate("/not-found"); - // Navigation completes immediately and deferreds are cancelled - expect(t.router.state.loaderData).toEqual({}); - - // Resolution doesn't do anything - await dfd.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({}); - }); - - it("cancels pending deferreds on errored GET submissions (w/ reused routes)", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "parent", - path: "parent", - loader: true, - hasErrorBoundary: true, - children: [ - { - id: "a", - path: "a", - loader: true, - }, - { - id: "b", - path: "b", - loader: true, - }, - ], - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - // Navigate to /parent/a and kick off a deferred's for both - let A = await t.navigate("/parent/a"); - let parentDfd = createDeferred(); - await A.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT", - lazy: parentDfd.promise, - }) - ); - let aDfd = createDeferred(); - await A.loaders.a.resolve( - defer({ - critical: "CRITICAL A", - lazy: aDfd.promise, - }) - ); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(), - }, - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(), - }, - }); - - // Perform an invalid navigation to /parent/b which will be handled - // using parent's error boundary. Parent's deferred should be left alone - // while A's should be cancelled since they will no longer be rendered - let B = await t.navigate("/parent/b"); - await B.loaders.b.reject( - new Response("broken", { status: 400, statusText: "Bad Request" }) - ); - - // Navigation completes immediately with an error at the boundary - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(), - }, - }); - expect(t.router.state.errors).toEqual({ - parent: new ErrorResponseImpl(400, "Bad Request", "broken", false), - }); - - await parentDfd.resolve("Yep!"); - await aDfd.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise("Yep!"), - }, - }); - }); - - it("cancels pending deferreds on errored GET submissions (w/o reused routes)", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "a", - path: "a", - loader: true, - children: [ - { - id: "aChild", - path: "child", - loader: true, - }, - ], - }, - { - id: "b", - path: "b", - loader: true, - children: [ - { - id: "bChild", - path: "child", - loader: true, - hasErrorBoundary: true, - }, - ], - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - // Navigate to /parent/a and kick off deferred's for both - let A = await t.navigate("/a/child"); - let aDfd = createDeferred(); - await A.loaders.a.resolve( - defer({ - critical: "CRITICAL A", - lazy: aDfd.promise, - }) - ); - let aChildDfd = createDeferred(); - await A.loaders.aChild.resolve( - defer({ - critical: "CRITICAL A CHILD", - lazy: aChildDfd.promise, - }) - ); - expect(t.router.state.loaderData).toEqual({ - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(), - }, - aChild: { - critical: "CRITICAL A CHILD", - lazy: expect.trackedPromise(), - }, - }); - - // Perform an invalid navigation to /b/child which should cancel all - // pending deferred's since nothing is reused. It should not call bChild's - // loader since it's below the boundary but should call b's loader. - let B = await t.navigate("/b/child"); - - await B.loaders.bChild.reject( - new Response("broken", { status: 400, statusText: "Bad Request" }) - ); - - // Both should be cancelled - await aDfd.resolve("Nope!"); - await aChildDfd.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({ - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(null, null, true), - }, - aChild: { - critical: "CRITICAL A CHILD", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - await B.loaders.b.resolve("B LOADER"); - expect(t.router.state.loaderData).toEqual({ - b: "B LOADER", - }); - expect(t.router.state.errors).toEqual({ - bChild: new ErrorResponseImpl(400, "Bad Request", "broken", false), - }); - }); - - it("does not cancel pending deferreds on hash change only navigations", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "lazy", - path: "lazy", - loader: true, - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - let A = await t.navigate("/lazy"); - let dfd = createDeferred(); - await A.loaders.lazy.resolve( - defer({ - critical: "CRITICAL", - lazy: dfd.promise, - }) - ); - - await t.navigate("/lazy#hash"); - expect(t.router.state.loaderData).toEqual({ - lazy: { - critical: "CRITICAL", - lazy: expect.trackedPromise(), - }, - }); - - await dfd.resolve("Yep!"); - expect(t.router.state.loaderData).toEqual({ - lazy: { - critical: "CRITICAL", - lazy: expect.trackedPromise("Yep!"), - }, - }); - }); - - it("cancels pending deferreds on action submissions", async () => { - let shouldRevalidateSpy = jest.fn(() => false); - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "parent", - path: "parent", - loader: true, - shouldRevalidate: shouldRevalidateSpy, - children: [ - { - id: "a", - path: "a", - loader: true, - }, - { - id: "b", - path: "b", - action: true, - }, - ], - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - let A = await t.navigate("/parent/a"); - let parentDfd = createDeferred(); - await A.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT", - lazy: parentDfd.promise, - }) - ); - let aDfd = createDeferred(); - await A.loaders.a.resolve( - defer({ - critical: "CRITICAL A", - lazy: aDfd.promise, - }) - ); - - // Action submission causes all to be cancelled, even reused ones, and - // ignores shouldRevalidate since the cancelled active deferred means we - // are missing data - let B = await t.navigate("/parent/b", { - formMethod: "post", - formData: createFormData({ key: "value" }), - }); - await parentDfd.resolve("Nope!"); - await aDfd.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(null, null, true), - }, - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - await B.actions.b.resolve("ACTION"); - let parentDfd2 = createDeferred(); - await B.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT 2", - lazy: parentDfd2.promise, - }) - ); - expect(t.router.state.actionData).toEqual({ - b: "ACTION", - }); - // Since we still have outstanding deferreds on the revalidation, we're - // still in the loading state and showing the old data - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(null, null, true), - }, - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - await parentDfd2.resolve("Yep!"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT 2", - lazy: expect.trackedPromise("Yep!"), - }, - }); - - expect(shouldRevalidateSpy).not.toHaveBeenCalled(); - }); - - it("does not put resolved deferred's back into a loading state during revalidation", async () => { - let shouldRevalidateSpy = jest.fn(() => false); - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "parent", - path: "parent", - loader: true, - shouldRevalidate: shouldRevalidateSpy, - children: [ - { - id: "a", - path: "a", - loader: true, - }, - { - id: "b", - path: "b", - action: true, - loader: true, - }, - ], - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - // Route to /parent/a and return and resolve deferred's for both - let A = await t.navigate("/parent/a"); - let parentDfd1 = createDeferred(); - let parentDfd2 = createDeferred(); - await A.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT", - lazy1: parentDfd1.promise, - lazy2: parentDfd2.promise, - }) - ); - let aDfd1 = createDeferred(); - let aDfd2 = createDeferred(); - await A.loaders.a.resolve( - defer({ - critical: "CRITICAL A", - lazy1: aDfd1.promise, - lazy2: aDfd2.promise, - }) - ); - - // Resolve one of the deferred for each prior to the action submission - await parentDfd1.resolve("LAZY PARENT 1"); - await aDfd1.resolve("LAZY A 1"); - - // Action submission causes all to be cancelled, even reused ones, and - // ignores shouldRevalidate since the cancelled active deferred means we - // are missing data - let B = await t.navigate("/parent/b", { - formMethod: "post", - formData: createFormData({ key: "value" }), - }); - await parentDfd2.resolve("Nope!"); - await aDfd2.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy1: expect.trackedPromise("LAZY PARENT 1"), - lazy2: expect.trackedPromise(null, null, true), - }, - a: { - critical: "CRITICAL A", - lazy1: expect.trackedPromise("LAZY A 1"), - lazy2: expect.trackedPromise(null, null, true), - }, - }); - - await B.actions.b.resolve("ACTION"); - let parentDfd1Revalidation = createDeferred(); - let parentDfd2Revalidation = createDeferred(); - await B.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT*", - lazy1: parentDfd1Revalidation.promise, - lazy2: parentDfd2Revalidation.promise, - }) - ); - await B.loaders.b.resolve("B"); - - // At this point, we resolved the action and the loaders - however the - // parent loader returned a deferred so we stay in the "loading" state - // until everything resolves - expect(t.router.state.navigation.state).toBe("loading"); - expect(t.router.state.actionData).toEqual({ - b: "ACTION", - }); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy1: expect.trackedPromise("LAZY PARENT 1"), - lazy2: expect.trackedPromise(null, null, true), - }, - a: { - critical: "CRITICAL A", - lazy1: expect.trackedPromise("LAZY A 1"), - lazy2: expect.trackedPromise(null, null, true), - }, - }); - - // Resolve the first deferred - should not complete the navigation yet - await parentDfd1Revalidation.resolve("LAZY PARENT 1*"); - expect(t.router.state.navigation.state).toBe("loading"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy1: expect.trackedPromise("LAZY PARENT 1"), - lazy2: expect.trackedPromise(null, null, true), - }, - a: { - critical: "CRITICAL A", - lazy1: expect.trackedPromise("LAZY A 1"), - lazy2: expect.trackedPromise(null, null, true), - }, - }); - - await parentDfd2Revalidation.resolve("LAZY PARENT 2*"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.actionData).toEqual({ - b: "ACTION", - }); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT*", - lazy1: expect.trackedPromise("LAZY PARENT 1*"), - lazy2: expect.trackedPromise("LAZY PARENT 2*"), - }, - b: "B", - }); - - expect(shouldRevalidateSpy).not.toHaveBeenCalled(); - }); - - it("triggers fallbacks on new dynamic route instances", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "invoice", - path: "invoices/:id", - loader: true, - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - let A = await t.navigate("/invoices/1"); - let dfd1 = createDeferred(); - await A.loaders.invoice.resolve(defer({ lazy: dfd1.promise })); - expect(t.router.state.loaderData).toEqual({ - invoice: { - lazy: expect.trackedPromise(), - }, - }); - - await dfd1.resolve("DATA 1"); - expect(t.router.state.loaderData).toEqual({ - invoice: { - lazy: expect.trackedPromise("DATA 1"), - }, - }); - - // Goes back into a loading state since this is a new instance of the - // invoice route - let B = await t.navigate("/invoices/2"); - let dfd2 = createDeferred(); - await B.loaders.invoice.resolve(defer({ lazy: dfd2.promise })); - expect(t.router.state.loaderData).toEqual({ - invoice: { - lazy: expect.trackedPromise(), - }, - }); - - await dfd2.resolve("DATA 2"); - expect(t.router.state.loaderData).toEqual({ - invoice: { - lazy: expect.trackedPromise("DATA 2"), - }, - }); - }); - - it("triggers fallbacks on new splat route instances", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "invoices", - path: "invoices", - children: [ - { - id: "invoice", - path: "*", - loader: true, - }, - ], - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - let A = await t.navigate("/invoices/1"); - let dfd1 = createDeferred(); - await A.loaders.invoice.resolve(defer({ lazy: dfd1.promise })); - expect(t.router.state.loaderData).toEqual({ - invoice: { - lazy: expect.trackedPromise(), - }, - }); - - await dfd1.resolve("DATA 1"); - expect(t.router.state.loaderData).toEqual({ - invoice: { - lazy: expect.trackedPromise("DATA 1"), - }, - }); - - // Goes back into a loading state since this is a new instance of the - // invoice route - let B = await t.navigate("/invoices/2"); - let dfd2 = createDeferred(); - await B.loaders.invoice.resolve(defer({ lazy: dfd2.promise })); - expect(t.router.state.loaderData).toEqual({ - invoice: { - lazy: expect.trackedPromise(), - }, - }); - - await dfd2.resolve("DATA 2"); - expect(t.router.state.loaderData).toEqual({ - invoice: { - lazy: expect.trackedPromise("DATA 2"), - }, - }); - }); - - it("cancels awaited reused deferreds on subsequent navigations", async () => { - let shouldRevalidateSpy = jest.fn(() => false); - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "parent", - path: "parent", - loader: true, - shouldRevalidate: shouldRevalidateSpy, - children: [ - { - id: "a", - path: "a", - loader: true, - }, - { - id: "b", - path: "b", - action: true, - loader: true, - }, - ], - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - // Route to /parent/a and return and resolve deferred's for both - let A = await t.navigate("/parent/a"); - let parentDfd = createDeferred(); // Never resolves in this test - await A.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT", - lazy: parentDfd.promise, - }) - ); - let aDfd = createDeferred(); - await A.loaders.a.resolve( - defer({ - critical: "CRITICAL A", - lazy: aDfd.promise, - }) - ); - - // Action submission to cancel deferreds - let B = await t.navigate("/parent/b", { - formMethod: "post", - formData: createFormData({ key: "value" }), - }); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(), - }, - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(), - }, - }); - - await B.actions.b.resolve("ACTION"); - let parentDfd2 = createDeferred(); // Never resolves in this test - await B.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT*", - lazy: parentDfd2.promise, - }) - ); - await B.loaders.b.resolve("B"); - - // Still in loading state due to revalidation deferred - expect(t.router.state.navigation.state).toBe("loading"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(null, null, true), - }, - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - // Navigate elsewhere - should cancel/abort revalidation deferreds - let C = await t.navigate("/"); - await C.loaders.index.resolve("INDEX*"); - expect(t.router.state.navigation.state).toBe("idle"); - expect(t.router.state.actionData).toEqual(null); - expect(t.router.state.loaderData).toEqual({ - index: "INDEX*", - }); - }); - - it("does not support deferred data on fetcher loads", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - }, - { - id: "fetch", - path: "fetch", - loader: true, - }, - ], - initialEntries: ["/"], - }); - - let key = "key"; - let A = await t.fetch("/fetch", key); - - // deferred in a fetcher awaits all data in the loading state - let dfd = createDeferred(); - await A.loaders.fetch.resolve( - defer({ - critical: "1", - lazy: dfd.promise, - }) - ); - expect(t.fetchers[key]).toMatchObject({ - state: "loading", - data: undefined, - }); - - await dfd.resolve("2"); - expect(t.fetchers[key]).toMatchObject({ - state: "idle", - data: { - critical: "1", - lazy: "2", - }, - }); - - // Trigger a revalidation for the same fetcher - let B = await t.revalidate("fetch", "fetch"); - expect(t.router.state.revalidation).toBe("loading"); - let dfd2 = createDeferred(); - await B.loaders.fetch.resolve( - defer({ - critical: "3", - lazy: dfd2.promise, - }) - ); - expect(t.fetchers[key]).toMatchObject({ - state: "idle", - data: { - critical: "1", - lazy: "2", - }, - }); - - await dfd2.resolve("4"); - expect(t.fetchers[key]).toMatchObject({ - state: "idle", - data: { - critical: "3", - lazy: "4", - }, - }); - }); - - it("triggers error boundaries if fetcher deferred data rejects", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - }, - { - id: "fetch", - path: "fetch", - loader: true, - }, - ], - initialEntries: ["/"], - }); - - let key = "key"; - let A = await t.fetch("/fetch", key); - - let dfd = createDeferred(); - await A.loaders.fetch.resolve( - defer({ - critical: "1", - lazy: dfd.promise, - }) - ); - await dfd.reject(new Error("Kaboom!")); - expect(t.router.state.errors).toMatchObject({ - index: new Error("Kaboom!"), - }); - expect(t.router.state.fetchers.get(key)).toBeUndefined(); - }); - - it("cancels pending deferreds on fetcher reloads", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - }, - { - id: "fetch", - path: "fetch", - loader: true, - }, - ], - initialEntries: ["/"], - }); - - let key = "key"; - let A = await t.fetch("/fetch", key); - - // deferred in a fetcher awaits all data in the loading state - let dfd1 = createDeferred(); - let loaderPromise1 = A.loaders.fetch.resolve( - defer({ - critical: "1", - lazy: dfd1.promise, - }) - ); - expect(t.fetchers[key]).toMatchObject({ - state: "loading", - data: undefined, - }); - - // Fetch again - let B = await t.fetch("/fetch", key); - - let dfd2 = createDeferred(); - let loaderPromise2 = B.loaders.fetch.resolve( - defer({ - critical: "3", - lazy: dfd2.promise, - }) - ); - expect(t.fetchers[key]).toMatchObject({ - state: "loading", - data: undefined, - }); - - // Resolving the second finishes us up - await dfd1.resolve("2"); - await dfd2.resolve("4"); - await loaderPromise1; - await loaderPromise2; - expect(t.fetchers[key]).toMatchObject({ - state: "idle", - data: { - critical: "3", - lazy: "4", - }, - }); - }); - - it("cancels pending deferreds on fetcher action submissions", async () => { - let t = setup({ - routes: [ - { - id: "index", - index: true, - loader: true, - }, - { - id: "parent", - path: "parent", - loader: true, - shouldRevalidate: () => false, - children: [ - { - id: "a", - path: "a", - loader: true, - }, - { - id: "b", - path: "b", - action: true, - }, - ], - }, - ], - hydrationData: { loaderData: { index: "INDEX" } }, - initialEntries: ["/"], - }); - - let A = await t.navigate("/parent/a"); - let parentDfd = createDeferred(); - await A.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT", - lazy: parentDfd.promise, - }) - ); - let aDfd = createDeferred(); - await A.loaders.a.resolve( - defer({ - critical: "CRITICAL A", - lazy: aDfd.promise, - }) - ); - - // Fetcher action submission causes all to be cancelled and - // ignores shouldRevalidate since the cancelled active deferred means we - // are missing data - let key = "key"; - let B = await t.fetch("/parent/b", key, { - formMethod: "post", - formData: createFormData({ key: "value" }), - }); - await parentDfd.resolve("Nope!"); - await aDfd.resolve("Nope!"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(null, null, true), - }, - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - await B.actions.b.resolve("ACTION"); - expect(t.fetchers[key]).toMatchObject({ - state: "loading", - data: "ACTION", - }); - - await B.actions.b.resolve("ACTION"); - let parentDfd2 = createDeferred(); - await B.loaders.parent.resolve( - defer({ - critical: "CRITICAL PARENT 2", - lazy: parentDfd2.promise, - }) - ); - let aDfd2 = createDeferred(); - await B.loaders.a.resolve( - defer({ - critical: "CRITICAL A 2", - lazy: aDfd2.promise, - }) - ); - - // Still showing old data while we wait on revalidation deferreds to - // complete - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT", - lazy: expect.trackedPromise(null, null, true), - }, - a: { - critical: "CRITICAL A", - lazy: expect.trackedPromise(null, null, true), - }, - }); - - await parentDfd2.resolve("Yep!"); - await aDfd2.resolve("Yep!"); - expect(t.router.state.loaderData).toEqual({ - parent: { - critical: "CRITICAL PARENT 2", - lazy: expect.trackedPromise("Yep!"), - }, - a: { - critical: "CRITICAL A 2", - lazy: expect.trackedPromise("Yep!"), - }, - }); - expect(t.fetchers[key]).toMatchObject({ - state: "idle", - data: "ACTION", - }); - }); - - it("differentiates between navigation and fetcher deferreds on cancellations", async () => { - let dfds: Array> = []; - let signals: Array = []; - let router = createRouter({ - history: createMemoryHistory({ initialEntries: ["/"] }), - routes: [ - { - id: "root", - path: "/", - loader: ({ request }) => { - let dfd = createDeferred(); - dfds.push(dfd); - signals.push(request.signal); - return defer({ value: dfd.promise }); - }, - }, - ], - hydrationData: { - loaderData: { - root: { value: -1 }, - }, - }, - }); - let fetcherData = getFetcherData(router); - - // navigate to root, kicking off a reload of the root loader - let key = "key"; - router.navigate("/"); - router.fetch(key, "root", "/"); - await tick(); - expect(router.state.navigation.state).toBe("loading"); - expect(router.state.loaderData).toEqual({ - root: { value: -1 }, - }); - expect(router.getFetcher(key).state).toBe("loading"); - expect(fetcherData.get(key)).toBe(undefined); - - // Interrupt with a revalidation - router.revalidate(); - - // Original deferreds should do nothing on resolution - dfds[0].resolve(0); - dfds[1].resolve(1); - await tick(); - expect(router.state.navigation.state).toBe("loading"); - expect(router.state.loaderData).toEqual({ - root: { value: -1 }, - }); - expect(router.getFetcher(key).state).toBe("loading"); - expect(fetcherData.get(key)).toBe(undefined); - - // New deferreds should complete the revalidation - dfds[2].resolve(2); - dfds[3].resolve(3); - await tick(); - expect(router.state.navigation.state).toBe("idle"); - expect(router.state.loaderData).toEqual({ - root: { value: expect.trackedPromise(2) }, - }); - expect(router.getFetcher(key).state).toBe("idle"); - expect(fetcherData.get(key)).toEqual({ value: 3 }); - - // Assert that both the route loader and fetcher loader were aborted - expect(signals[0].aborted).toBe(true); // initial route - expect(signals[1].aborted).toBe(true); // initial fetcher - expect(signals[2].aborted).toBe(false); // revalidating route - expect(signals[3].aborted).toBe(false); // revalidating fetcher - - expect(router._internalActiveDeferreds.size).toBe(0); - expect(router._internalFetchControllers.size).toBe(0); - router.dispose(); - }); -}); diff --git a/packages/react-router/__tests__/router/route-fallback-test.ts b/packages/react-router/__tests__/router/route-fallback-test.ts index 05ac55824b..9c0cde5ba2 100644 --- a/packages/react-router/__tests__/router/route-fallback-test.ts +++ b/packages/react-router/__tests__/router/route-fallback-test.ts @@ -5,13 +5,8 @@ import { createRouter, } from "../../lib/router"; -import { - deferredData, - trackedPromise, - urlMatch, -} from "./utils/custom-matchers"; +import { urlMatch } from "./utils/custom-matchers"; import { createDeferred } from "./utils/data-router-setup"; -import { tick } from "./utils/utils"; interface CustomMatchers { urlMatch(url: string); @@ -32,8 +27,6 @@ declare global { } expect.extend({ - deferredData, - trackedPromise, urlMatch, }); diff --git a/packages/react-router/__tests__/router/router-test.ts b/packages/react-router/__tests__/router/router-test.ts index 3722a15488..f86e42e1f5 100644 --- a/packages/react-router/__tests__/router/router-test.ts +++ b/packages/react-router/__tests__/router/router-test.ts @@ -10,11 +10,7 @@ import type { } from "../../lib/router"; import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from "../../lib/router"; -import { - deferredData, - trackedPromise, - urlMatch, -} from "./utils/custom-matchers"; +import { urlMatch } from "./utils/custom-matchers"; import { cleanup, createDeferred, @@ -25,13 +21,7 @@ import { import { createFormData, tick } from "./utils/utils"; interface CustomMatchers { - urlMatch(url: string); - trackedPromise(data?: any, error?: any, aborted?: boolean): R; - deferredData( - done: boolean, - status?: number, - headers?: Record - ): R; + urlMatch(url: string): R; } declare global { @@ -43,8 +33,6 @@ declare global { } expect.extend({ - deferredData, - trackedPromise, urlMatch, }); diff --git a/packages/react-router/__tests__/router/ssr-test.ts b/packages/react-router/__tests__/router/ssr-test.ts index aaa5d192f7..50505c1e46 100644 --- a/packages/react-router/__tests__/router/ssr-test.ts +++ b/packages/react-router/__tests__/router/ssr-test.ts @@ -5,18 +5,15 @@ import urlDataStrategy from "./utils/urlDataStrategy"; import type { StaticHandler, StaticHandlerContext } from "../../lib/router"; import { - UNSAFE_DEFERRED_SYMBOL, createStaticHandler, getStaticContextFromError, } from "../../lib/router"; import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl, - defer, isRouteErrorResponse, json, redirect, } from "../../lib/router"; -import { deferredData, trackedPromise } from "./utils/custom-matchers"; import { createDeferred } from "./utils/data-router-setup"; import { createRequest, @@ -25,26 +22,11 @@ import { sleep, } from "./utils/utils"; -interface CustomMatchers { - trackedPromise(data?: any, error?: any, aborted?: boolean): R; - deferredData( - done: boolean, - status?: number, - headers?: Record - ): R; -} - -declare global { - namespace jest { - interface Expect extends CustomMatchers {} - interface Matchers extends CustomMatchers {} - interface InverseAsymmetricMatchers extends CustomMatchers {} - } -} - -expect.extend({ - deferredData, - trackedPromise, +process.on("unhandledRejection", (e) => { + console.error("unhandledRejection", e); +}); +process.on("uncaughtException", (e) => { + console.error("uncaughtException", e); }); describe("ssr", () => { @@ -83,38 +65,32 @@ describe("ssr", () => { path: "deferred", loader: ({ request }) => { if (new URL(request.url).searchParams.has("reject")) { - return defer({ + let promise = new Promise((_, r) => + setTimeout(() => r("broken!"), 10) + ); + promise.catch(() => {}); + return { critical: "loader", - lazy: new Promise((_, r) => - setTimeout(() => r(new Error("broken!")), 10) - ), - }); + lazy: promise, + }; } if (new URL(request.url).searchParams.has("undefined")) { - return defer({ + return { critical: "loader", lazy: new Promise((r) => setTimeout(() => r(undefined), 10)), - }); + }; } if (new URL(request.url).searchParams.has("status")) { - return defer( - { - critical: "loader", - lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)), - }, - { status: 201, headers: { "X-Custom": "yes" } } - ); + return { + critical: "loader", + lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)), + }; } - return defer({ + return { critical: "loader", lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)), - }); + }; }, - action: () => - defer({ - critical: "critical", - lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)), - }), }, { id: "error", @@ -275,19 +251,18 @@ describe("ssr", () => { it("should support document load navigations returning deferred", async () => { let { query } = createStaticHandler(SSR_ROUTES); - let context = await query(createRequest("/parent/deferred")); + let context = (await query( + createRequest("/parent/deferred") + )) as StaticHandlerContext; expect(context).toMatchObject({ actionData: null, loaderData: { parent: "PARENT LOADER", deferred: { critical: "loader", - lazy: expect.trackedPromise(), + lazy: expect.any(Promise), }, }, - activeDeferreds: { - deferred: expect.deferredData(false), - }, errors: null, location: { pathname: "/parent/deferred" }, matches: [{ route: { id: "parent" } }, { route: { id: "deferred" } }], @@ -295,16 +270,7 @@ describe("ssr", () => { await new Promise((r) => setTimeout(r, 10)); - expect(context).toMatchObject({ - loaderData: { - deferred: { - lazy: expect.trackedPromise("lazy"), - }, - }, - activeDeferreds: { - deferred: expect.deferredData(true), - }, - }); + await expect(context.loaderData.deferred.lazy).resolves.toBe("lazy"); }); it("should support route.lazy", async () => { @@ -1031,29 +997,24 @@ describe("ssr", () => { parent: "PARENT LOADER", deferred: { critical: "loader", - lazy: expect.trackedPromise(), + lazy: expect.any(Promise), }, }, - activeDeferreds: { - deferred: expect.deferredData(false), - }, }); await new Promise((r) => setTimeout(r, 10)); + await expect(context.loaderData.deferred.lazy).resolves.toBe("lazy"); expect(context).toMatchObject({ loaderData: { parent: "PARENT LOADER", deferred: { critical: "loader", - lazy: expect.trackedPromise("lazy"), + lazy: expect.any(Promise), }, }, - activeDeferreds: { - deferred: expect.deferredData(true), - }, }); }); - it("should return rejected DeferredData on symbol", async () => { + it("should return rejected promises", async () => { let context = (await query( createRequest("/parent/deferred?reject") )) as StaticHandlerContext; @@ -1062,29 +1023,15 @@ describe("ssr", () => { parent: "PARENT LOADER", deferred: { critical: "loader", - lazy: expect.trackedPromise(), + lazy: expect.any(Promise), }, }, - activeDeferreds: { - deferred: expect.deferredData(false), - }, }); await new Promise((r) => setTimeout(r, 10)); - expect(context).toMatchObject({ - loaderData: { - parent: "PARENT LOADER", - deferred: { - critical: "loader", - lazy: expect.trackedPromise(undefined, new Error("broken!")), - }, - }, - activeDeferreds: { - deferred: expect.deferredData(true), - }, - }); + await expect(context.loaderData.deferred.lazy).rejects.toBe("broken!"); }); - it("should return rejected DeferredData on symbol for resolved undefined", async () => { + it("should return resolved undefined", async () => { let context = (await query( createRequest("/parent/deferred?undefined") )) as StaticHandlerContext; @@ -1093,90 +1040,12 @@ describe("ssr", () => { parent: "PARENT LOADER", deferred: { critical: "loader", - lazy: expect.trackedPromise(), + lazy: expect.any(Promise), }, }, - activeDeferreds: { - deferred: expect.deferredData(false), - }, }); await new Promise((r) => setTimeout(r, 10)); - expect(context).toMatchObject({ - loaderData: { - parent: "PARENT LOADER", - deferred: { - critical: "loader", - lazy: expect.trackedPromise( - null, - new Error( - `Deferred data for key "lazy" resolved/rejected with \`undefined\`, you must resolve/reject with a value or \`null\`.` - ) - ), - }, - }, - activeDeferreds: { - deferred: expect.deferredData(true), - }, - }); - }); - - it("should return DeferredData on symbol with status + headers", async () => { - let context = (await query( - createRequest("/parent/deferred?status") - )) as StaticHandlerContext; - expect(context).toMatchObject({ - loaderData: { - parent: "PARENT LOADER", - deferred: { - critical: "loader", - lazy: expect.trackedPromise(), - }, - }, - activeDeferreds: { - deferred: expect.deferredData(false, 201, { - "x-custom": "yes", - }), - }, - }); - await new Promise((r) => setTimeout(r, 10)); - expect(context).toMatchObject({ - loaderData: { - parent: "PARENT LOADER", - deferred: { - critical: "loader", - lazy: expect.trackedPromise("lazy"), - }, - }, - activeDeferreds: { - deferred: expect.deferredData(true, 201, { - "x-custom": "yes", - }), - }, - statusCode: 201, - loaderHeaders: { - deferred: new Headers({ "x-custom": "yes" }), - }, - }); - }); - - it("does not support deferred on submissions", async () => { - let context = (await query( - createSubmitRequest("/parent/deferred") - )) as StaticHandlerContext; - expect(context.actionData).toEqual(null); - expect(context.loaderData).toEqual({ - parent: null, - deferred: null, - }); - expect(context.activeDeferreds).toEqual(null); - expect(context.errors).toEqual({ - parent: new ErrorResponseImpl( - 400, - "Bad Request", - new Error("defer() is not supported in actions"), - true - ), - }); + await expect(context.loaderData.deferred.lazy).resolves.toBeUndefined(); }); }); @@ -2263,98 +2132,6 @@ describe("ssr", () => { expect(arg(actionStub).context.sessionId).toBe("12345"); }); - describe("deferred", () => { - let { queryRoute } = createStaticHandler(SSR_ROUTES); - - it("should return DeferredData on symbol", async () => { - let result = await queryRoute(createRequest("/parent/deferred")); - expect(result).toMatchObject({ - critical: "loader", - lazy: expect.trackedPromise(), - }); - expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false); - await new Promise((r) => setTimeout(r, 10)); - expect(result).toMatchObject({ - critical: "loader", - lazy: expect.trackedPromise("lazy"), - }); - expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true); - }); - - it("should return rejected DeferredData on symbol", async () => { - let result = await queryRoute(createRequest("/parent/deferred?reject")); - expect(result).toMatchObject({ - critical: "loader", - lazy: expect.trackedPromise(), - }); - expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false); - await new Promise((r) => setTimeout(r, 10)); - expect(result).toMatchObject({ - critical: "loader", - lazy: expect.trackedPromise(null, new Error("broken!")), - }); - expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true); - }); - - it("should return rejected DeferredData on symbol for resolved undefined", async () => { - let result = await queryRoute( - createRequest("/parent/deferred?undefined") - ); - expect(result).toMatchObject({ - critical: "loader", - lazy: expect.trackedPromise(), - }); - expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false); - await new Promise((r) => setTimeout(r, 10)); - expect(result).toMatchObject({ - critical: "loader", - lazy: expect.trackedPromise( - null, - new Error( - `Deferred data for key "lazy" resolved/rejected with \`undefined\`, you must resolve/reject with a value or \`null\`.` - ) - ), - }); - expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true); - }); - - it("should return DeferredData on symbol with status + headers", async () => { - let result = await queryRoute(createRequest("/parent/deferred?status")); - expect(result).toMatchObject({ - critical: "loader", - lazy: expect.trackedPromise(), - }); - expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false, 201, { - "x-custom": "yes", - }); - await new Promise((r) => setTimeout(r, 10)); - expect(result).toMatchObject({ - critical: "loader", - lazy: expect.trackedPromise("lazy"), - }); - expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true, 201, { - "x-custom": "yes", - }); - }); - - it("does not support deferred on submissions", async () => { - try { - await queryRoute(createSubmitRequest("/parent/deferred")); - expect(false).toBe(true); - } catch (e) { - // eslint-disable-next-line jest/no-conditional-expect - expect(e).toEqual( - new ErrorResponseImpl( - 400, - "Bad Request", - new Error("defer() is not supported in actions"), - true - ) - ); - } - }); - }); - describe("Errors with Status Codes", () => { /* eslint-disable jest/no-conditional-expect */ let { queryRoute } = createStaticHandler([ diff --git a/packages/react-router/__tests__/router/utils/custom-matchers.ts b/packages/react-router/__tests__/router/utils/custom-matchers.ts index 4149752d10..ea0811c295 100644 --- a/packages/react-router/__tests__/router/utils/custom-matchers.ts +++ b/packages/react-router/__tests__/router/utils/custom-matchers.ts @@ -1,6 +1,3 @@ -import type { DeferredData, TrackedPromise } from "../../../lib/router/utils"; -import { AbortedDeferredError } from "../../../lib/router/utils"; - interface CustomMatchers { URL(url: string); trackedPromise(data?: any, error?: any, aborted?: boolean): R; @@ -26,78 +23,3 @@ export function urlMatch(received, url) { pass: received instanceof URL && received.toString() === url, }; } - -// Custom matcher for asserting deferred promise results for static handler -// - expect(val).deferredData(false) => Unresolved promise -// - expect(val).deferredData(false) => Resolved promise -// - expect(val).deferredData(false, 201, { 'x-custom': 'yes' }) -// => Unresolved promise with status + headers -// - expect(val).deferredData(true, 201, { 'x-custom': 'yes' }) -// => Resolved promise with status + headers -export function deferredData(received, done, status = 200, headers = {}) { - let deferredData = received as DeferredData; - - return { - message: () => - `expected done=${String(done)}/status=${status}/headers=${JSON.stringify( - headers - )}, ` + - `instead got done=${String(deferredData.done)}/status=${ - deferredData.init!.status || 200 - }/headers=${JSON.stringify( - Object.fromEntries(new Headers(deferredData.init!.headers).entries()) - )}`, - pass: - deferredData.done === done && - (deferredData.init!.status || 200) === status && - JSON.stringify( - Object.fromEntries(new Headers(deferredData.init!.headers).entries()) - ) === JSON.stringify(headers), - }; -} - -// Custom matcher for asserting deferred promise results inside of `toEqual()` -// - expect.trackedPromise() => pending promise -// - expect.trackedPromise(value) => promise resolved with `value` -// - expect.trackedPromise(null, error) => promise rejected with `error` -// - expect.trackedPromise(null, null, true) => promise aborted -export function trackedPromise(received, data, error, aborted = false) { - let promise = received as TrackedPromise; - let isTrackedPromise = - promise instanceof Promise && promise._tracked === true; - - if (data != null) { - let dataMatches = promise._data === data; - return { - message: () => `expected ${received} to be a resolved deferred`, - pass: isTrackedPromise && dataMatches, - }; - } - - if (error != null) { - let errorMatches = - error instanceof Error - ? promise._error.toString() === error.toString() - : promise._error === error; - return { - message: () => `expected ${received} to be a rejected deferred`, - pass: isTrackedPromise && errorMatches, - }; - } - - if (aborted) { - let errorMatches = promise._error instanceof AbortedDeferredError; - return { - message: () => `expected ${received} to be an aborted deferred`, - pass: isTrackedPromise && errorMatches, - }; - } - - return { - message: () => `expected ${received} to be a pending deferred`, - pass: - isTrackedPromise && - promise._data === undefined && - promise._error === undefined, - }; -} diff --git a/packages/react-router/__tests__/router/utils/data-router-setup.ts b/packages/react-router/__tests__/router/utils/data-router-setup.ts index a9cdce13bd..7ef127d7d0 100644 --- a/packages/react-router/__tests__/router/utils/data-router-setup.ts +++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts @@ -779,7 +779,6 @@ export function cleanup(_router?: Router) { // Cleanup any routers created using setup() if (router) { expect(router._internalFetchControllers.size).toBe(0); - expect(router._internalActiveDeferreds.size).toBe(0); } router?.dispose(); currentRouter = null; diff --git a/packages/react-router/__tests__/utils/MemoryNavigate.tsx b/packages/react-router/__tests__/utils/MemoryNavigate.tsx index 7042674ff7..e21dca7749 100644 --- a/packages/react-router/__tests__/utils/MemoryNavigate.tsx +++ b/packages/react-router/__tests__/utils/MemoryNavigate.tsx @@ -1,4 +1,4 @@ -import type { FormMethod } from "react-router"; +import type { HTMLFormMethod } from "../../lib/router"; import { joinPaths } from "../../lib/router"; import * as React from "react"; import { UNSAFE_DataRouterContext } from "../../index"; @@ -10,7 +10,7 @@ export default function MemoryNavigate({ children, }: { to: string; - formMethod?: FormMethod; + formMethod?: HTMLFormMethod; formData?: FormData; children: React.ReactNode; }) { diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 2b58c74452..5145e30c15 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -30,10 +30,8 @@ import type { unstable_HandlerResult, } from "./lib/router"; import { - AbortedDeferredError, Action as NavigationType, createPath, - defer, generatePath, isRouteErrorResponse, json, @@ -177,7 +175,6 @@ export type { unstable_PatchRoutesOnMissFunction, }; export { - AbortedDeferredError, Await, MemoryRouter, Navigate, @@ -190,7 +187,6 @@ export { createPath, createRoutesFromChildren, createRoutesFromChildren as createRoutesFromElements, - defer, generatePath, isRouteErrorResponse, json, @@ -242,12 +238,10 @@ export type { TrackedPromise, FetcherStates, UpperCaseFormMethod, - UNSAFE_DeferredData, } from "./lib/router"; export { getStaticContextFromError, stripBasename, - UNSAFE_DEFERRED_SYMBOL, UNSAFE_convertRoutesToDataRoutes, } from "./lib/router"; @@ -361,7 +355,6 @@ export { } from "./lib/server-runtime/formData"; // TODO: (v7) Clean up code paths for these exports // export { -// defer, // json, // redirect, // redirectDocument, @@ -432,10 +425,7 @@ export type { PageLinkDescriptor, } from "./lib/server-runtime/links"; -export type { - TypedDeferredData, - TypedResponse, -} from "./lib/server-runtime/responses"; +export type { TypedResponse } from "./lib/server-runtime/responses"; export type { // TODO: (v7) Clean up code paths for these exports diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 0e9830c581..0a066f7712 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -13,7 +13,6 @@ import type { unstable_AgnosticPatchRoutesOnMissFunction, } from "./router"; import { - AbortedDeferredError, Action as NavigationType, createMemoryHistory, createRouter, @@ -746,14 +745,6 @@ class AwaitErrorBoundary extends React.Component< ); } - if ( - status === AwaitRenderStatus.error && - promise._error instanceof AbortedDeferredError - ) { - // Freeze the UI by throwing a never resolved promise - throw neverSettledPromise; - } - if (status === AwaitRenderStatus.error && !errorElement) { // No errorElement, throw to the nearest route-level error boundary throw promise._error; diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx index 000f951d91..b4f85bc004 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -368,7 +368,6 @@ export function createStaticRouter( throw msg("patchRoutes"); }, _internalFetchControllers: new Map(), - _internalActiveDeferreds: new Map(), _internalSetRoutes() { throw msg("_internalSetRoutes"); }, diff --git a/packages/react-router/lib/router/index.ts b/packages/react-router/lib/router/index.ts index 650125c08a..a65b90be9d 100644 --- a/packages/react-router/lib/router/index.ts +++ b/packages/react-router/lib/router/index.ts @@ -38,8 +38,6 @@ export type { } from "./utils"; export { - AbortedDeferredError, - defer, generatePath, getToPathname, isRouteErrorResponse, @@ -90,7 +88,6 @@ export * from "./router"; /** @internal */ export type { RouteManifest as UNSAFE_RouteManifest } from "./utils"; export { - DeferredData as UNSAFE_DeferredData, ErrorResponseImpl as UNSAFE_ErrorResponseImpl, convertRoutesToDataRoutes as UNSAFE_convertRoutesToDataRoutes, convertRouteMatchToUiMatch as UNSAFE_convertRouteMatchToUiMatch, diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 974e1d9b93..3f23200b07 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -15,8 +15,6 @@ import type { DataResult, DataStrategyFunction, DataStrategyFunctionArgs, - DeferredData, - DeferredResult, DetectErrorBoundaryFunction, ErrorResult, FormEncType, @@ -269,14 +267,6 @@ export interface Router { * Internal fetch AbortControllers accessed by unit tests */ _internalFetchControllers: Map; - - /** - * @private - * PRIVATE - DO NOT USE - * - * Internal pending DeferredData instances accessed by unit tests - */ - _internalActiveDeferreds: Map; } /** @@ -400,7 +390,6 @@ export interface StaticHandlerContext { statusCode: number; loaderHeaders: Record; actionHeaders: Record; - activeDeferreds: Record | null; _deepestRenderedBoundaryId?: string | null; } @@ -973,10 +962,6 @@ export function createRouter(init: RouterInit): Router { // - X-Remix-Revalidate (from redirect) let isRevalidationRequired = false; - // Use this internal array to capture routes that require revalidation due - // to a cancelled deferred on action submission - let cancelledDeferredRoutes: string[] = []; - // Use this internal array to capture fetcher loads that were cancelled by an // action navigation and require revalidation let cancelledFetcherLoads: string[] = []; @@ -1008,12 +993,6 @@ export function createRouter(init: RouterInit): Router { // they return to idle let deletedFetchers = new Set(); - // Store DeferredData instances for active route matches. When a - // route loader returns defer() we stick one in here. Then, when a nested - // promise resolves we update loaderData. If a new navigation starts we - // cancel active deferreds for eliminated routes. - let activeDeferreds = new Map(); - // Store blocker functions in a separate Map outside of router state since // we don't need to update UI state if they change let blockerFunctions = new Map(); @@ -1332,7 +1311,6 @@ export function createRouter(init: RouterInit): Router { isRevalidationRequired = false; pendingRevalidationDfd?.resolve(); pendingRevalidationDfd = null; - cancelledDeferredRoutes = []; cancelledFetcherLoads = []; } @@ -1793,10 +1771,6 @@ export function createRouter(init: RouterInit): Router { return { shortCircuited: true }; } - if (isDeferredResult(result)) { - throw getInternalRouterError(400, { type: "defer-action" }); - } - if (isErrorResult(result)) { // Store off the pending error - we use it to determine which loaders // to call and will commit it when we complete the navigation @@ -1923,7 +1897,6 @@ export function createRouter(init: RouterInit): Router { initialHydration === true, future.unstable_skipActionErrorRevalidation, isRevalidationRequired, - cancelledDeferredRoutes, cancelledFetcherLoads, deletedFetchers, fetchLoadMatches, @@ -1933,15 +1906,6 @@ export function createRouter(init: RouterInit): Router { pendingActionResult ); - // Cancel pending deferreds for no-longer-matched routes or routes we're - // about to reload. Note that if this is an action reload we would have - // already cancelled all pending deferreds so this would be a no-op - cancelActiveDeferreds( - (routeId) => - !(matches && matches.some((m) => m.route.id === routeId)) || - (matchesToLoad && matchesToLoad.some((m) => m.route.id === routeId)) - ); - pendingNavigationLoadId = ++incrementingLoadId; // Short circuit if we have no loaders to run @@ -2052,22 +2016,9 @@ export function createRouter(init: RouterInit): Router { loaderResults, pendingActionResult, revalidatingFetchers, - fetcherResults, - activeDeferreds + fetcherResults ); - // Wire up subscribers to update loaderData as promises settle - activeDeferreds.forEach((deferredData, routeId) => { - deferredData.subscribe((aborted) => { - // Note: No need to updateState here since the TrackedPromise on - // loaderData is stable across resolve/reject - // Remove this instance if we were aborted or if promises have settled - if (aborted || deferredData.done) { - activeDeferreds.delete(routeId); - } - }); - }); - // With "partial hydration", preserve SSR errors for routes that don't re-run if (initialHydration && state.errors) { Object.entries(state.errors) @@ -2335,10 +2286,6 @@ export function createRouter(init: RouterInit): Router { } } - if (isDeferredResult(actionResult)) { - throw getInternalRouterError(400, { type: "defer-action" }); - } - // Start the data load for current matches, or the next location if we're // in the middle of a navigation let nextLocation = state.navigation.location || state.location; @@ -2370,7 +2317,6 @@ export function createRouter(init: RouterInit): Router { false, future.unstable_skipActionErrorRevalidation, isRevalidationRequired, - cancelledDeferredRoutes, cancelledFetcherLoads, deletedFetchers, fetchLoadMatches, @@ -2454,8 +2400,7 @@ export function createRouter(init: RouterInit): Router { loaderResults, undefined, revalidatingFetchers, - fetcherResults, - activeDeferreds + fetcherResults ); // Since we let revalidations complete even if the submitting fetcher was @@ -2568,16 +2513,6 @@ export function createRouter(init: RouterInit): Router { ); let result = results[0]; - // Deferred isn't supported for fetcher loads, await everything and treat it - // as a normal load. resolveDeferredData will return undefined if this - // fetcher gets aborted, so we just leave result untouched and short circuit - // below if that happens - if (isDeferredResult(result)) { - result = - (await resolveDeferredData(result, fetchRequest.signal, true)) || - result; - } - // We can delete this so long as we weren't aborted by our our own fetcher // re-load which would have put _new_ controller is in fetchControllers if (fetchControllers.get(key) === abortController) { @@ -2615,8 +2550,6 @@ export function createRouter(init: RouterInit): Router { return; } - invariant(!isDeferredResult(result), "Unhandled fetcher deferred data"); - // Put the fetcher back into an idle state updateFetcherState(key, getDoneFetcher(result.data)); } @@ -2830,24 +2763,6 @@ export function createRouter(init: RouterInit): Router { }), ]); - await Promise.all([ - resolveDeferredResults( - currentMatches, - matchesToLoad, - loaderResults, - loaderResults.map(() => request.signal), - false, - state.loaderData - ), - resolveDeferredResults( - currentMatches, - fetchersToLoad.map((f) => f.match), - fetcherResults, - fetchersToLoad.map((f) => (f.controller ? f.controller.signal : null)), - true - ), - ]); - return { loaderResults, fetcherResults, @@ -2858,10 +2773,6 @@ export function createRouter(init: RouterInit): Router { // Every interruption triggers a revalidation isRevalidationRequired = true; - // Cancel pending route-level deferreds and mark cancelled routes for - // revalidation - cancelledDeferredRoutes.push(...cancelActiveDeferreds()); - // Abort in-flight fetcher loads fetchLoadMatches.forEach((_, key) => { if (fetchControllers.has(key)) { @@ -3065,9 +2976,6 @@ export function createRouter(init: RouterInit): Router { let routesToUse = inFlightDataRoutes || dataRoutes; let { matches, route } = getShortCircuitMatches(routesToUse); - // Cancel all pending deferred on 404s since we don't keep any routes - cancelActiveDeferreds(); - return { notFoundMatches: matches, route, error }; } @@ -3089,23 +2997,6 @@ export function createRouter(init: RouterInit): Router { return { notFoundMatches: matches, route, error }; } - function cancelActiveDeferreds( - predicate?: (routeId: string) => boolean - ): string[] { - let cancelledRouteIds: string[] = []; - activeDeferreds.forEach((dfd, routeId) => { - if (!predicate || predicate(routeId)) { - // Cancel the deferred - but do not remove from activeDeferreds here - - // we rely on the subscribers to do that so our tests can assert proper - // cleanup via _internalActiveDeferreds - dfd.cancel(); - cancelledRouteIds.push(routeId); - activeDeferreds.delete(routeId); - } - }); - return cancelledRouteIds; - } - // Opt in to capturing and reporting scroll positions during navigations, // used by the component function enableScrollRestoration( @@ -3380,7 +3271,6 @@ export function createRouter(init: RouterInit): Router { deleteBlocker, patchRoutes, _internalFetchControllers: fetchControllers, - _internalActiveDeferreds: activeDeferreds, // TODO: Remove setRoutes, it's temporary to avoid dealing with // updating the tree while validating the update algorithm. _internalSetRoutes, @@ -3394,8 +3284,6 @@ export function createRouter(init: RouterInit): Router { //#region createStaticHandler //////////////////////////////////////////////////////////////////////////////// -export const UNSAFE_DEFERRED_SYMBOL = Symbol("deferred"); - export interface CreateStaticHandlerOptions { basename?: string; /** @@ -3501,7 +3389,6 @@ export function createStaticHandler( statusCode: error.status, loaderHeaders: {}, actionHeaders: {}, - activeDeferreds: null, }; } else if (!matches) { let error = getInternalRouterError(404, { pathname: location.pathname }); @@ -3519,7 +3406,6 @@ export function createStaticHandler( statusCode: error.status, loaderHeaders: {}, actionHeaders: {}, - activeDeferreds: null, }; } @@ -3635,11 +3521,7 @@ export function createStaticHandler( } if (result.loaderData) { - let data = Object.values(result.loaderData)[0]; - if (result.activeDeferreds?.[match.route.id]) { - data[UNSAFE_DEFERRED_SYMBOL] = result.activeDeferreds[match.route.id]; - } - return data; + return Object.values(result.loaderData)[0]; } return undefined; @@ -3761,17 +3643,6 @@ export function createStaticHandler( }); } - if (isDeferredResult(result)) { - let error = getInternalRouterError(400, { type: "defer-action" }); - if (isRouteRequest) { - throw error; - } - result = { - type: ResultType.error, - error, - }; - } - if (isRouteRequest) { // Note: This should only be non-Response values if we get here, since // isRouteRequest should throw any Response received in callLoaderOrAction @@ -3789,7 +3660,6 @@ export function createStaticHandler( statusCode: 200, loaderHeaders: {}, actionHeaders: {}, - activeDeferreds: null, }; } @@ -3910,7 +3780,6 @@ export function createStaticHandler( : null, statusCode: 200, loaderHeaders: {}, - activeDeferreds: null, }; } @@ -3929,13 +3798,11 @@ export function createStaticHandler( } // Process and commit output from loaders - let activeDeferreds = new Map(); let context = processRouteLoaderData( matches, matchesToLoad, results, pendingActionResult, - activeDeferreds, skipLoaderErrorBubbling ); @@ -3952,10 +3819,6 @@ export function createStaticHandler( return { ...context, matches, - activeDeferreds: - activeDeferreds.size > 0 - ? Object.fromEntries(activeDeferreds.entries()) - : null, }; } @@ -4300,7 +4163,6 @@ function getMatchesToLoad( isInitialLoad: boolean, skipActionErrorRevalidation: boolean, isRevalidationRequired: boolean, - cancelledDeferredRoutes: string[], cancelledFetcherLoads: string[], deletedFetchers: Set, fetchLoadMatches: Map, @@ -4357,11 +4219,8 @@ function getMatchesToLoad( ); } - // Always call the loader on new route instances and pending defer cancellations - if ( - isNewLoader(state.loaderData, state.matches[index], match) || - cancelledDeferredRoutes.some((id) => id === match.route.id) - ) { + // Always call the loader on new route instances + if (isNewLoader(state.loaderData, state.matches[index], match)) { return true; } @@ -4491,7 +4350,7 @@ function isNewLoader( match.route.id !== currentMatch.route.id; // Handle the case that we don't have data for a re-used route, potentially - // from a prior error or from a cancelled pending deferred + // from a prior error let isMissingData = currentLoaderData[match.route.id] === undefined; // Always load if this is a net-new route or we don't yet have data @@ -4936,15 +4795,6 @@ async function convertHandlerResultToDataResult( }; } - if (isDeferredData(result)) { - return { - type: ResultType.deferred, - deferredData: result, - statusCode: result.init?.status, - headers: result.init?.headers && new Headers(result.init.headers), - }; - } - return { type: ResultType.data, data: result, statusCode: status }; } @@ -5064,7 +4914,6 @@ function processRouteLoaderData( matchesToLoad: AgnosticDataRouteMatch[], results: DataResult[], pendingActionResult: PendingActionResult | undefined, - activeDeferreds: Map, skipLoaderErrorBubbling: boolean ): { loaderData: RouterState["loaderData"]; @@ -5129,31 +4978,14 @@ function processRouteLoaderData( loaderHeaders[id] = result.headers; } } else { - if (isDeferredResult(result)) { - activeDeferreds.set(id, result.deferredData); - loaderData[id] = result.deferredData.data; - // Error status codes always override success status codes, but if all - // loaders are successful we take the deepest status code. - if ( - result.statusCode != null && - result.statusCode !== 200 && - !foundError - ) { - statusCode = result.statusCode; - } - if (result.headers) { - loaderHeaders[id] = result.headers; - } - } else { - loaderData[id] = result.data; - // Error status codes always override success status codes, but if all - // loaders are successful we take the deepest status code. - if (result.statusCode && result.statusCode !== 200 && !foundError) { - statusCode = result.statusCode; - } - if (result.headers) { - loaderHeaders[id] = result.headers; - } + loaderData[id] = result.data; + // Error status codes always override success status codes, but if all + // loaders are successful we take the deepest status code. + if (result.statusCode && result.statusCode !== 200 && !foundError) { + statusCode = result.statusCode; + } + if (result.headers) { + loaderHeaders[id] = result.headers; } } }); @@ -5181,8 +5013,7 @@ function processLoaderData( results: DataResult[], pendingActionResult: PendingActionResult | undefined, revalidatingFetchers: RevalidatingFetcher[], - fetcherResults: DataResult[], - activeDeferreds: Map + fetcherResults: DataResult[] ): { loaderData: RouterState["loaderData"]; errors?: RouterState["errors"]; @@ -5192,7 +5023,6 @@ function processLoaderData( matchesToLoad, results, pendingActionResult, - activeDeferreds, false // This method is only called client side so we always want to bubble ); @@ -5222,10 +5052,6 @@ function processLoaderData( // Should never get here, redirects should get processed above, but we // keep this to type narrow to a success result in the else invariant(false, "Unhandled fetcher revalidation redirect"); - } else if (isDeferredResult(result)) { - // Should never get here, deferred data should be awaited for fetchers - // in resolveDeferredResults - invariant(false, "Unhandled fetcher deferred data"); } else { let doneFetcher = getDoneFetcher(result.data); state.fetchers.set(key, doneFetcher); @@ -5337,7 +5163,7 @@ function getInternalRouterError( pathname?: string; routeId?: string; method?: string; - type?: "defer-action" | "invalid-body" | "route-discovery"; + type?: "invalid-body" | "route-discovery"; message?: string; } = {} ) { @@ -5355,8 +5181,6 @@ function getInternalRouterError( `You made a ${method} request to "${pathname}" but ` + `did not provide a \`loader\` for route "${routeId}", ` + `so there is no way to handle the request.`; - } else if (type === "defer-action") { - errorMessage = "defer() is not supported in actions"; } else if (type === "invalid-body") { errorMessage = "Unable to encode submission body"; } @@ -5443,11 +5267,6 @@ function isRedirectHandlerResult(result: HandlerResult) { isResponse(result.result) && redirectStatusCodes.has(result.result.status) ); } - -function isDeferredResult(result: DataResult): result is DeferredResult { - return result.type === ResultType.deferred; -} - function isErrorResult(result: DataResult): result is ErrorResult { return result.type === ResultType.error; } @@ -5456,18 +5275,6 @@ function isRedirectResult(result?: DataResult): result is RedirectResult { return (result && result.type) === ResultType.redirect; } -export function isDeferredData(value: any): value is DeferredData { - let deferred: DeferredData = value; - return ( - deferred && - typeof deferred === "object" && - typeof deferred.data === "object" && - typeof deferred.subscribe === "function" && - typeof deferred.cancel === "function" && - typeof deferred.resolveData === "function" - ); -} - function isResponse(value: any): value is Response { return ( value != null && @@ -5496,81 +5303,6 @@ function isMutationMethod(method: string): method is MutationFormMethod { return validMutationMethods.has(method.toUpperCase() as MutationFormMethod); } -async function resolveDeferredResults( - currentMatches: AgnosticDataRouteMatch[], - matchesToLoad: (AgnosticDataRouteMatch | null)[], - results: DataResult[], - signals: (AbortSignal | null)[], - isFetcher: boolean, - currentLoaderData?: RouteData -) { - for (let index = 0; index < results.length; index++) { - let result = results[index]; - let match = matchesToLoad[index]; - // If we don't have a match, then we can have a deferred result to do - // anything with. This is for revalidating fetchers where the route was - // removed during HMR - if (!match) { - continue; - } - - let currentMatch = currentMatches.find( - (m) => m.route.id === match!.route.id - ); - let isRevalidatingLoader = - currentMatch != null && - !isNewRouteInstance(currentMatch, match) && - (currentLoaderData && currentLoaderData[match.route.id]) !== undefined; - - if (isDeferredResult(result) && (isFetcher || isRevalidatingLoader)) { - // Note: we do not have to touch activeDeferreds here since we race them - // against the signal in resolveDeferredData and they'll get aborted - // there if needed - let signal = signals[index]; - invariant( - signal, - "Expected an AbortSignal for revalidating fetcher deferred result" - ); - await resolveDeferredData(result, signal, isFetcher).then((result) => { - if (result) { - results[index] = result || results[index]; - } - }); - } - } -} - -async function resolveDeferredData( - result: DeferredResult, - signal: AbortSignal, - unwrap = false -): Promise { - let aborted = await result.deferredData.resolveData(signal); - if (aborted) { - return; - } - - if (unwrap) { - try { - return { - type: ResultType.data, - data: result.deferredData.unwrappedData, - }; - } catch (e) { - // Handle any TrackedPromise._error values encountered while unwrapping - return { - type: ResultType.error, - error: e, - }; - } - } - - return { - type: ResultType.data, - data: result.deferredData.data, - }; -} - function hasNakedIndexQuery(search: string): boolean { return new URLSearchParams(search).getAll("index").some((v) => v === ""); } @@ -5787,7 +5519,7 @@ function persistAppliedTransitions( } } -export function createDeferred() { +function createDeferred() { let resolve: (val?: any) => Promise; let reject: (error?: Error) => Promise; let promise = new Promise((res, rej) => { diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 8547af9d81..1523965efb 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -10,7 +10,6 @@ export interface RouteData { export enum ResultType { data = "data", - deferred = "deferred", redirect = "redirect", error = "error", } @@ -25,16 +24,6 @@ export interface SuccessResult { headers?: Headers; } -/** - * Successful defer() result from a loader or action - */ -export interface DeferredResult { - type: ResultType.deferred; - deferredData: DeferredData; - statusCode?: number; - headers?: Headers; -} - /** * Redirect result from a loader or action */ @@ -57,18 +46,14 @@ export interface ErrorResult { /** * Result from a loader or action - potentially successful or unsuccessful */ -export type DataResult = - | SuccessResult - | DeferredResult - | RedirectResult - | ErrorResult; +export type DataResult = SuccessResult | RedirectResult | ErrorResult; /** * Result from a loader or action called via dataStrategy */ export interface HandlerResult { type: "data" | "error"; - result: unknown; // data, Error, Response, DeferredData + result: unknown; // data, Error, Response status?: number; } @@ -1371,210 +1356,6 @@ export interface TrackedPromise extends Promise { _error?: any; } -export class AbortedDeferredError extends Error {} - -export class DeferredData { - private pendingKeysSet: Set = new Set(); - private controller: AbortController; - private abortPromise: Promise; - private unlistenAbortSignal: () => void; - private subscribers: Set<(aborted: boolean, settledKey?: string) => void> = - new Set(); - data: Record; - init?: ResponseInit; - deferredKeys: string[] = []; - - constructor(data: Record, responseInit?: ResponseInit) { - invariant( - data && typeof data === "object" && !Array.isArray(data), - "defer() only accepts plain objects" - ); - - // Set up an AbortController + Promise we can race against to exit early - // cancellation - let reject: (e: AbortedDeferredError) => void; - this.abortPromise = new Promise((_, r) => (reject = r)); - this.controller = new AbortController(); - let onAbort = () => - reject(new AbortedDeferredError("Deferred data aborted")); - this.unlistenAbortSignal = () => - this.controller.signal.removeEventListener("abort", onAbort); - this.controller.signal.addEventListener("abort", onAbort); - - this.data = Object.entries(data).reduce( - (acc, [key, value]) => - Object.assign(acc, { - [key]: this.trackPromise(key, value), - }), - {} - ); - - if (this.done) { - // All incoming values were resolved - this.unlistenAbortSignal(); - } - - this.init = responseInit; - } - - private trackPromise( - key: string, - value: Promise | unknown - ): TrackedPromise | unknown { - if (!(value instanceof Promise)) { - return value; - } - - this.deferredKeys.push(key); - this.pendingKeysSet.add(key); - - // We store a little wrapper promise that will be extended with - // _data/_error props upon resolve/reject - let promise: TrackedPromise = Promise.race([value, this.abortPromise]).then( - (data) => this.onSettle(promise, key, undefined, data as unknown), - (error) => this.onSettle(promise, key, error as unknown) - ); - - // Register rejection listeners to avoid uncaught promise rejections on - // errors or aborted deferred values - promise.catch(() => {}); - - Object.defineProperty(promise, "_tracked", { get: () => true }); - return promise; - } - - private onSettle( - promise: TrackedPromise, - key: string, - error: unknown, - data?: unknown - ): unknown { - if ( - this.controller.signal.aborted && - error instanceof AbortedDeferredError - ) { - this.unlistenAbortSignal(); - Object.defineProperty(promise, "_error", { get: () => error }); - return Promise.reject(error); - } - - this.pendingKeysSet.delete(key); - - if (this.done) { - // Nothing left to abort! - this.unlistenAbortSignal(); - } - - // If the promise was resolved/rejected with undefined, we'll throw an error as you - // should always resolve with a value or null - if (error === undefined && data === undefined) { - let undefinedError = new Error( - `Deferred data for key "${key}" resolved/rejected with \`undefined\`, ` + - `you must resolve/reject with a value or \`null\`.` - ); - Object.defineProperty(promise, "_error", { get: () => undefinedError }); - this.emit(false, key); - return Promise.reject(undefinedError); - } - - if (data === undefined) { - Object.defineProperty(promise, "_error", { get: () => error }); - this.emit(false, key); - return Promise.reject(error); - } - - Object.defineProperty(promise, "_data", { get: () => data }); - this.emit(false, key); - return data; - } - - private emit(aborted: boolean, settledKey?: string) { - this.subscribers.forEach((subscriber) => subscriber(aborted, settledKey)); - } - - subscribe(fn: (aborted: boolean, settledKey?: string) => void) { - this.subscribers.add(fn); - return () => this.subscribers.delete(fn); - } - - cancel() { - this.controller.abort(); - this.pendingKeysSet.forEach((v, k) => this.pendingKeysSet.delete(k)); - this.emit(true); - } - - async resolveData(signal: AbortSignal) { - let aborted = false; - if (!this.done) { - let onAbort = () => this.cancel(); - signal.addEventListener("abort", onAbort); - aborted = await new Promise((resolve) => { - this.subscribe((aborted) => { - signal.removeEventListener("abort", onAbort); - if (aborted || this.done) { - resolve(aborted); - } - }); - }); - } - return aborted; - } - - get done() { - return this.pendingKeysSet.size === 0; - } - - get unwrappedData() { - invariant( - this.data !== null && this.done, - "Can only unwrap data on initialized and settled deferreds" - ); - - return Object.entries(this.data).reduce( - (acc, [key, value]) => - Object.assign(acc, { - [key]: unwrapTrackedPromise(value), - }), - {} - ); - } - - get pendingKeys() { - return Array.from(this.pendingKeysSet); - } -} - -function isTrackedPromise(value: any): value is TrackedPromise { - return ( - value instanceof Promise && (value as TrackedPromise)._tracked === true - ); -} - -function unwrapTrackedPromise(value: any) { - if (!isTrackedPromise(value)) { - return value; - } - - if (value._error) { - throw value._error; - } - return value._data; -} - -export type DeferFunction = ( - data: Record, - init?: number | ResponseInit -) => DeferredData; - -/** - * @category Utils - */ -export const defer: DeferFunction = (data, init = {}) => { - let responseInit = typeof init === "number" ? { status: init } : init; - - return new DeferredData(data, responseInit); -}; - export type RedirectFunction = ( url: string, init?: number | ResponseInit diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts index 5ade012474..eae2e19b47 100644 --- a/packages/react-router/lib/server-runtime/data.ts +++ b/packages/react-router/lib/server-runtime/data.ts @@ -1,4 +1,3 @@ -import { redirect, isDeferredData, isRedirectStatusCode } from "./responses"; import type { ActionFunction, ActionFunctionArgs, @@ -83,16 +82,6 @@ export async function callRouteLoader({ ); } - if (isDeferredData(result)) { - if (result.init && isRedirectStatusCode(result.init.status || 200)) { - return redirect( - new Headers(result.init.headers).get("Location")!, - result.init - ); - } - return result; - } - return result; } diff --git a/packages/react-router/lib/server-runtime/responses.ts b/packages/react-router/lib/server-runtime/responses.ts index b9b17ff5f1..06e168b30a 100644 --- a/packages/react-router/lib/server-runtime/responses.ts +++ b/packages/react-router/lib/server-runtime/responses.ts @@ -1,28 +1,8 @@ import { - type UNSAFE_DeferredData as DeferredData, - type TrackedPromise, - defer as routerDefer, json as routerJson, redirect as routerRedirect, redirectDocument as routerRedirectDocument, } from "../router"; -import { serializeError } from "./errors"; -import type { ServerMode } from "./mode"; - -declare const typedDeferredDataBrand: unique symbol; - -export type TypedDeferredData> = Pick< - DeferredData, - "init" -> & { - data: Data; - readonly [typedDeferredDataBrand]: "TypedDeferredData"; -}; - -export type DeferFunction = >( - data: Data, - init?: number | ResponseInit -) => TypedDeferredData; export type JsonFunction = ( data: Data, @@ -45,15 +25,6 @@ export const json: JsonFunction = (data, init = {}) => { return routerJson(data, init); }; -/** - * This is a shortcut for creating Remix deferred responses - * - * @see https://remix.run/utils/defer - */ -export const defer: DeferFunction = (data, init = {}) => { - return routerDefer(data, init) as unknown as TypedDeferredData; -}; - export type RedirectFunction = ( url: string, init?: number | ResponseInit @@ -80,18 +51,6 @@ export const redirectDocument: RedirectFunction = (url, init = 302) => { return routerRedirectDocument(url, init) as TypedResponse; }; -export function isDeferredData(value: any): value is DeferredData { - let deferred: DeferredData = value; - return ( - deferred && - typeof deferred === "object" && - typeof deferred.data === "object" && - typeof deferred.subscribe === "function" && - typeof deferred.cancel === "function" && - typeof deferred.resolveData === "function" - ); -} - export function isResponse(value: any): value is Response { return ( value != null && @@ -109,102 +68,3 @@ export function isRedirectStatusCode(statusCode: number): boolean { export function isRedirectResponse(response: Response): boolean { return isRedirectStatusCode(response.status); } - -function isTrackedPromise(value: any): value is TrackedPromise { - return ( - value != null && typeof value.then === "function" && value._tracked === true - ); -} - -// TODO: Figure out why ReadableStream types are borked sooooooo badly -// in this file. Probably related to our TS configurations and configs -// bleeding into each other. -const DEFERRED_VALUE_PLACEHOLDER_PREFIX = "__deferred_promise:"; -export function createDeferredReadableStream( - deferredData: DeferredData, - signal: AbortSignal, - serverMode: ServerMode -): any { - let encoder = new TextEncoder(); - let stream = new ReadableStream({ - async start(controller: any) { - let criticalData: any = {}; - - let preresolvedKeys: string[] = []; - for (let [key, value] of Object.entries(deferredData.data)) { - if (isTrackedPromise(value)) { - criticalData[key] = `${DEFERRED_VALUE_PLACEHOLDER_PREFIX}${key}`; - if ( - typeof value._data !== "undefined" || - typeof value._error !== "undefined" - ) { - preresolvedKeys.push(key); - } - } else { - criticalData[key] = value; - } - } - - // Send the critical data - controller.enqueue(encoder.encode(JSON.stringify(criticalData) + "\n\n")); - - for (let preresolvedKey of preresolvedKeys) { - enqueueTrackedPromise( - controller, - encoder, - preresolvedKey, - deferredData.data[preresolvedKey] as TrackedPromise, - serverMode - ); - } - - let unsubscribe = deferredData.subscribe((aborted, settledKey) => { - if (settledKey) { - enqueueTrackedPromise( - controller, - encoder, - settledKey, - deferredData.data[settledKey] as TrackedPromise, - serverMode - ); - } - }); - await deferredData.resolveData(signal); - unsubscribe(); - controller.close(); - }, - }); - - return stream; -} - -function enqueueTrackedPromise( - controller: any, - encoder: TextEncoder, - settledKey: string, - promise: TrackedPromise, - serverMode: ServerMode -) { - if ("_error" in promise) { - controller.enqueue( - encoder.encode( - "error:" + - JSON.stringify({ - [settledKey]: - promise._error instanceof Error - ? serializeError(promise._error, serverMode) - : promise._error, - }) + - "\n\n" - ) - ); - } else { - controller.enqueue( - encoder.encode( - "data:" + - JSON.stringify({ [settledKey]: promise._data ?? null }) + - "\n\n" - ) - ); - } -} diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index e99492fd32..2a8b2c7531 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -1,6 +1,5 @@ import type { ErrorResponse, StaticHandler } from "../router"; import { - UNSAFE_DEFERRED_SYMBOL as DEFERRED_SYMBOL, getStaticContextFromError, isRouteErrorResponse, createStaticHandler, @@ -306,8 +305,6 @@ async function handleSingleFetchRequest( resultHeaders.set("X-Remix-Response", "yes"); resultHeaders.set("Content-Type", "text/x-turbo"); - // Note: Deferred data is already just Promises, so we don't have to mess - // `activeDeferreds` or anything :) return new Response( encodeViaTurboStream( result, @@ -511,14 +508,6 @@ async function handleResourceRequest( }), }); - if (typeof response === "object" && response !== null) { - invariant( - !(DEFERRED_SYMBOL in response), - `You cannot return a \`defer()\` response from a Resource Route. Did you ` + - `forget to export a default UI component from the "${routeId}" route?` - ); - } - let stub = responseStubs[routeId]; if (isResponseStub(response) || response == null) { // If the stub or null was returned, then there is no body so we just diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index e07d83c91f..a48e20be2e 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -17,7 +17,7 @@ import { import type { AppLoadContext } from "./data"; import { sanitizeError, sanitizeErrors } from "./errors"; import { ServerMode } from "./mode"; -import { isDeferredData, isRedirectStatusCode, isResponse } from "./responses"; +import { isRedirectStatusCode, isResponse } from "./responses"; const ResponseStubActionSymbol = Symbol("ResponseStubAction"); @@ -98,12 +98,6 @@ export function getSingleFetchDataStrategy( result.result.headers, responseStub ); - } else if (isDeferredData(result.result) && result.result.init) { - proxyResponseToResponseStub( - result.result.init.status, - new Headers(result.result.init.headers), - responseStub - ); } return result;