Repository: remix-run/remix
Pull request #3531 (2022-06-21 16:35:31)

Brophdawg11/deferred cancellation
<!--

👋 Hey, thanks for your interest in contributing to Remix!

If this is a simple docs change, go ahead and delete all this.

**Please ask first before starting work on any significant new features.**

It's never a fun experience to have your pull request declined after investing a
lot of time and effort into a new feature. To avoid this from happening, we
request that contributors create a
[Feature Request discussion](https://github.com/remix-run/remix/discussions/new?category=ideas)
to first discuss any significant new features.

https://github.com/remix-run/remix/blob/main/CONTRIBUTING.md

Please fill in or delete each item below:

-->

Closes: #

- [ ] Docs
- [ ] Tests

Testing Strategy:

<!--
Please explain how you tested this. For example:

> This test covers this code: <link to test>

Or

> I opened up my windows machine and ran this script:
>
> ```
> npx create-remix@0.0.0-experimental-7e420ee3 --template remix my-test
> cd my-test
> npm run dev
> ```
-->



Patch:
diff --git a/packages/remix-node/globals.ts b/packages/remix-node/globals.ts
index 2732cbe17..dea24e81c 100644
--- a/packages/remix-node/globals.ts
+++ b/packages/remix-node/globals.ts
@@ -1,3 +1,4 @@
+import { TextEncoder, TextDecoder } from "util";
 import {
   ReadableStream as NodeReadableStream,
   WritableStream as NodeWritableStream,
@@ -35,6 +36,8 @@ declare global {
 
       ReadableStream: typeof ReadableStream;
       WritableStream: typeof WritableStream;
+      TextEncoder: typeof TextEncoder;
+      TextDecoder: typeof TextDecoder;
     }
   }
 }
@@ -54,4 +57,7 @@ export function installGlobals() {
 
   global.ReadableStream = NodeReadableStream;
   global.WritableStream = NodeWritableStream;
+
+  global.TextEncoder = TextEncoder as unknown as global.TextEncoder;
+  global.TextDecoder = TextDecoder as unknown as global.TextDecoder;
 }
diff --git a/packages/remix-react/transition.ts b/packages/remix-react/transition.ts
index 9fc74c398..eabfb3ddb 100644
--- a/packages/remix-react/transition.ts
+++ b/packages/remix-react/transition.ts
@@ -574,7 +574,13 @@ export function createTransitionManager(init: TransitionManagerInit) {
   let { routes } = init;
 
   let pendingNavigationController: AbortController | undefined;
-  let pendingNavigationDeferredControllers: AbortController[] = [];
+  // Map of routeId->Abortcontroller for pending deferred returned from loaders
+  let pendingNavigationDeferredControllers: Map<string, AbortController> =
+    new Map();
+  // Set of routeId's for which we cancelled pending deferred's and need to
+  // track until the end of the navigation so we can clear their
+  // deferredLoaderData
+  let cancelledDeferredRouteIds: Set<string> = new Set();
   let fetchControllers = new Map<string, AbortController>();
   let incrementingLoadId = 0;
   let navigationLoadId = -1;
@@ -611,16 +617,33 @@ export function createTransitionManager(init: TransitionManagerInit) {
   };
 
   function update(updates: Partial<TransitionManagerState>) {
+    let overrides: Partial<TransitionManagerState> = {};
+
     if (updates.transition) {
       console.debug(
         `[transition] transition set to ${updates.transition.state}/${updates.transition.type}`
       );
       if (updates.transition === IDLE_TRANSITION) {
         pendingNavigationController = undefined;
+        cancelledDeferredRouteIds.forEach((routeId) => {
+          pendingNavigationDeferredControllers.delete(routeId);
+        });
+
+        // We need to only preserve deferredData that wasn't cancelled, so do a
+        // manual merge here checking for each routeId
+        overrides.deferredLoaderData = [
+          ...Object.entries(state.deferredLoaderData),
+          ...Object.entries(updates.deferredLoaderData || {}),
+        ]
+          .filter(([routeId]) => !cancelledDeferredRouteIds.has(routeId))
+          .reduce<Record<string, any>>(
+            (acc, [routeId, data]) => Object.assign(acc, { [routeId]: data }),
+            {}
+          );
       }
     }
 
-    state = Object.assign({}, state, updates);
+    state = Object.assign({}, state, updates, overrides);
     init.onChange(state);
   }
 
@@ -873,6 +896,8 @@ export function createTransitionManager(init: TransitionManagerInit) {
       state.transition.location || state.location,
       matchesToLoad,
       controller.signal,
+      pendingNavigationDeferredControllers,
+      cancelledDeferredRouteIds,
       maybeActionErrorResult,
       maybeActionCatchResult,
       submission,
@@ -1165,6 +1190,11 @@ export function createTransitionManager(init: TransitionManagerInit) {
     matches: RouteMatch<ClientRoute>[]
   ) {
     abortNormalNavigation();
+    abortPendingDeferredControllers(
+      pendingNavigationDeferredControllers,
+      cancelledDeferredRouteIds
+    );
+
     let transition: TransitionStates["Loading"] = {
       state: "loading",
       type: "normalLoad",
@@ -1422,6 +1452,8 @@ export function createTransitionManager(init: TransitionManagerInit) {
       location,
       matches,
       controller.signal,
+      pendingNavigationDeferredControllers,
+      cancelledDeferredRouteIds,
       maybeActionErrorResult,
       maybeActionCatchResult,
       submission,
@@ -1493,7 +1525,8 @@ export function createTransitionManager(init: TransitionManagerInit) {
     let [deferredLoaderData, monitorDeferred] = makeDeferredLoaderData(
       getState,
       update,
-      results
+      results,
+      pendingNavigationDeferredControllers
     );
 
     update({
@@ -1511,12 +1544,7 @@ export function createTransitionManager(init: TransitionManagerInit) {
       fetchers: abortedIds ? new Map(state.fetchers) : state.fetchers,
     });
 
-    let deferredController = new AbortController();
-    if (controller.signal.aborted) {
-      deferredController.abort();
-    }
-    pendingNavigationDeferredControllers.push(deferredController);
-    monitorDeferred(deferredController.signal);
+    monitorDeferred();
   }
 
   function abortNormalNavigation() {
@@ -1524,11 +1552,6 @@ export function createTransitionManager(init: TransitionManagerInit) {
       console.debug(`[transition] aborting pending navigation`);
       pendingNavigationController.abort();
     }
-    if (pendingNavigationDeferredControllers.length > 0) {
-      let toAbort = pendingNavigationDeferredControllers;
-      pendingNavigationDeferredControllers = [];
-      toAbort.forEach((controller) => controller.abort());
-    }
   }
 
   function abortFetcher(key: string) {
@@ -1573,6 +1596,8 @@ async function callLoaders(
   location: Location,
   matches: ClientMatch[],
   signal: AbortSignal,
+  pendingNavigationDeferredControllers: Map<string, AbortController>,
+  cancelledDeferredRouteIds: Set<string>,
   actionErrorResult?: DataErrorResult,
   actionCatchResult?: DataCatchResult,
   submission?: Submission,
@@ -1593,6 +1618,16 @@ async function callLoaders(
     catchBoundaryId
   );
 
+  // We can't abort these up front like we do for normal navigations because
+  // they're route-specific so we need to wait until we know matchesToLoad so
+  // we can only cancel non-reused routes
+  abortPendingDeferredControllers(
+    pendingNavigationDeferredControllers,
+    cancelledDeferredRouteIds,
+    matches,
+    matchesToLoad
+  );
+
   return Promise.all(
     matchesToLoad.map((match) => callLoader(match, url, signal))
   );
@@ -1719,6 +1754,11 @@ function filterMatchesToLoad(
     return matches.filter((match) => !!match.route.loader);
   }
 
+  // TODO: (deferred) - need to add handling here that if a prior action
+  // cancelled a pending deferred, we need to skip shouldReload and force
+  // revalidation for the route so it's not stuck in a pending state should
+  // the user opt out via shouldReload.
+
   if (fetcher?.type === "actionReload") {
     return matches.filter(filterByRouteProps);
   } else if (
@@ -1904,8 +1944,9 @@ function makeLoaderData(
 function makeDeferredLoaderData(
   getState: () => TransitionManagerState,
   update: (updates: Partial<TransitionManagerState>) => void,
-  results: DataResult[]
-): [DeferredRouteData, (signal: AbortSignal) => void] {
+  results: DataResult[],
+  pendingNavigationDeferredControllers: Map<string, AbortController>
+): [DeferredRouteData, () => void] {
   let state = getState();
   let deferredLoaderData = { ...state.deferredLoaderData };
   for (let { match, deferred } of results) {
@@ -1915,17 +1956,26 @@ function makeDeferredLoaderData(
       deferredLoaderData[match.route.id],
       deferred
     );
+    let deferredController = new AbortController();
+    pendingNavigationDeferredControllers.set(
+      match.route.id,
+      deferredController
+    );
   }
 
   return [
     deferredLoaderData,
-    (signal) => {
+    () => {
       for (let { match, deferred } of results) {
         if (!deferred) continue;
         for (let [key, promise] of Object.entries(deferred)) {
+          let signal = pendingNavigationDeferredControllers.get(
+            match.route.id
+          )?.signal;
+          if (!signal || signal.aborted) continue;
           deferredLoaderData[match.route.id][key] = promise;
           promise.then((value) => {
-            if (signal.aborted) return;
+            if (signal!.aborted) return;
 
             let state = { ...getState() };
             let {
@@ -1949,7 +1999,7 @@ function makeDeferredLoaderData(
               },
             };
 
-            if (signal.aborted) return;
+            if (signal!.aborted) return;
             update({
               deferredLoaderData: newDeferredLoaderData,
               loaderData: newLoaderData,
@@ -1961,6 +2011,27 @@ function makeDeferredLoaderData(
   ];
 }
 
+function abortPendingDeferredControllers(
+  pendingNavigationDeferredControllers: Map<string, AbortController>,
+  cancelledDeferredRouteIds: Set<string>,
+  matches: ClientMatch[] = [],
+  matchesToLoad: ClientMatch[] = []
+): void {
+  for (let [routeId, controller] of pendingNavigationDeferredControllers) {
+    // Can cancel if this route is no longer matched
+    let isRouteMatched = matches.some((m) => m.route.id === routeId);
+    // Or if this route is about to be reloaded
+    let isRouteLoading = matchesToLoad.some((m) => m.route.id === routeId);
+    // TODO: add boundary handling when we do actions - cancel below boundary
+    let foundBoundaryId = false;
+    if (!isRouteMatched || isRouteLoading || foundBoundaryId) {
+      console.log("Aborting controller for routeId", routeId);
+      controller.abort();
+      cancelledDeferredRouteIds.add(routeId);
+    }
+  }
+}
+
 function isCatchResult(result: DataResult): result is DataCatchResult {
   return result.value instanceof CatchValue;
 }


Test patch:
diff --git a/packages/remix-react/__tests__/setup.ts b/packages/remix-react/__tests__/setup.ts
index fba7a393e..2978abeee 100644
--- a/packages/remix-react/__tests__/setup.ts
+++ b/packages/remix-react/__tests__/setup.ts
@@ -1,9 +1,13 @@
-// @ts-nocheck
-import * as AbortController from "abort-controller";
-import * as nodeFetch from "@remix-run/node/fetch";
+import { installGlobals } from "@remix-run/node";
 
-global.AbortController = AbortController.AbortController;
-global.Headers = nodeFetch.Headers;
-global.Response = nodeFetch.Response;
-global.Request = nodeFetch.Request;
-global.fetch = nodeFetch;
+installGlobals();
+
+// // @ts-nocheck
+// import * as AbortController from "abort-controller";
+// import * as nodeFetch from "@remix-run/node/fetch";
+
+// global.AbortController = AbortController.AbortController;
+// global.Headers = nodeFetch.Headers;
+// global.Response = nodeFetch.Response;
+// global.Request = nodeFetch.Request;
+// global.fetch = nodeFetch;
diff --git a/packages/remix-react/__tests__/transition-test.tsx b/packages/remix-react/__tests__/transition-test.tsx
index 47629f296..464530bdd 100644
--- a/packages/remix-react/__tests__/transition-test.tsx
+++ b/packages/remix-react/__tests__/transition-test.tsx
@@ -1,3 +1,4 @@
+import { TextEncoder } from "util";
 import { Action, parsePath } from "history";
 import type { Location, State } from "history";
 
@@ -5,6 +6,7 @@ import type { Submission, TransitionManagerInit } from "../transition";
 import {
   CatchValue,
   createTransitionManager,
+  DeferredResponse,
   TransitionRedirect,
   IDLE_FETCHER,
   IDLE_TRANSITION,
@@ -15,7 +17,7 @@ describe("init", () => {
     let tm = createTransitionManager({
       routes: [
         {
-          element: {},
+          element: {} as any,
           id: "root",
           path: "/",
           ErrorBoundary: {},
@@ -24,6 +26,7 @@ describe("init", () => {
         },
       ],
       location: createLocation("/"),
+      deferredLoaderData: {},
       loaderData: { root: "LOADER DATA" },
       actionData: { root: "ACTION DATA" },
       error: new Error("lol"),
@@ -173,7 +176,7 @@ describe("normal navigation", () => {
     t.navigate.get("/#bar");
     expect(t.getState().location.hash).toBe("");
     expect(t.getState().transition.state).toBe("loading");
-    expect(t.getState().transition.location.hash).toBe("#bar");
+    expect(t.getState().transition.location!.hash).toBe("#bar");
     // await the internal forced async state
     await Promise.resolve();
     expect(t.getState().location.hash).toBe("#bar");
@@ -210,6 +213,7 @@ describe("shouldReload", () => {
       return url.searchParams.get("reload") === "1";
     });
     let tm = createTestTransitionManager("/", {
+      deferredLoaderData: {},
       loaderData: {
         "/": "ROOT",
       },
@@ -220,14 +224,14 @@ describe("shouldReload", () => {
           hasLoader: true,
           loader: rootLoader,
           shouldReload,
-          element: {},
+          element: {} as any,
           module: "",
           children: [
             {
               path: "/",
               id: "index",
               action: () => null,
-              element: {},
+              element: {} as any,
               module: "",
               hasLoader: false,
             },
@@ -237,7 +241,7 @@ describe("shouldReload", () => {
               hasLoader: true,
               loader: childLoader,
               action: () => null,
-              element: {},
+              element: {} as any,
               module: "",
             },
           ],
@@ -282,6 +286,207 @@ describe("shouldReload", () => {
       }
     `);
   });
+
+  it("delegates to the route if it should reload or not if deferred resolved", async () => {
+    let rootLoader = jest.fn(
+      () =>
+        new DeferredResponse(
+          new Response("{}\n\n", {
+            headers: { "Content-Type": "text/remix-deferred" },
+          })
+        )
+    );
+    let childLoader = jest.fn(() => "CHILD");
+    let shouldReload = jest.fn(({ url, prevUrl, submission }) => {
+      return url.searchParams.get("reload") === "1";
+    });
+    let tm = createTestTransitionManager("/", {
+      deferredLoaderData: {},
+      loaderData: {
+        "/": {},
+      },
+      routes: [
+        {
+          path: "",
+          id: "root",
+          hasLoader: true,
+          loader: rootLoader,
+          shouldReload,
+          element: {} as any,
+          module: "",
+          children: [
+            {
+              path: "/",
+              id: "index",
+              action: () => null,
+              element: {} as any,
+              module: "",
+              hasLoader: false,
+            },
+            {
+              path: "/child",
+              id: "child",
+              hasLoader: true,
+              loader: childLoader,
+              action: () => null,
+              element: {} as any,
+              module: "",
+            },
+          ],
+        },
+      ],
+    });
+
+    await tm.send({
+      type: "navigation",
+      location: createLocation("/child?reload=1"),
+      action: Action.Push,
+    });
+    expect(rootLoader.mock.calls.length).toBe(1);
+
+    await tm.send({
+      type: "navigation",
+      location: createLocation("/child?reload=0"),
+      action: Action.Push,
+    });
+    expect(rootLoader.mock.calls.length).toBe(1);
+
+    await tm.send({
+      type: "navigation",
+      location: createLocation("/child"),
+      submission: createActionSubmission("/child"),
+      action: Action.Push,
+    });
+
+    expect(rootLoader.mock.calls.length).toBe(2);
+    let args = shouldReload.mock.calls[2][0];
+    expect(args).toMatchInlineSnapshot(`
+      Object {
+        "params": Object {},
+        "prevUrl": "http://localhost/child?reload=0",
+        "submission": Object {
+          "action": "/child",
+          "encType": "application/x-www-form-urlencoded",
+          "formData": FormData {},
+          "key": "1",
+          "method": "POST",
+        },
+        "url": "http://localhost/child",
+      }
+    `);
+  });
+
+  it("does not delegate to the route if it should reload or not if deferred didn't resolve", async () => {
+    let resolveBodyController;
+    let bodyControllerPromise = new Promise<
+      ReadableStreamController<Uint8Array>
+    >((resolve) => {
+      resolveBodyController = resolve;
+    });
+    let encoder = new TextEncoder();
+    let body = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.enqueue(
+          encoder.encode(`{"foo":"__deferred_promise:foo"}\n\n`)
+        );
+        resolveBodyController(controller);
+      },
+    });
+    let resolveRootLoader = async () => {
+      let controller = await bodyControllerPromise;
+      controller.enqueue(encoder.encode(`data:{ "foo": "bar" }\n\n`));
+      controller.close();
+    };
+
+    let rootLoader = jest.fn(
+      () =>
+        new DeferredResponse(
+          new Response(body, {
+            headers: { "Content-Type": "text/remix-deferred" },
+          })
+        )
+    );
+    let childLoader = jest.fn(() => "CHILD");
+    let shouldReload = jest.fn(({ url, prevUrl, submission }) => {
+      return url.searchParams.get("reload") === "1";
+    });
+    let tm = createTestTransitionManager("/", {
+      deferredLoaderData: {},
+      loaderData: {
+        "/": "ROOT",
+      },
+      routes: [
+        {
+          path: "",
+          id: "root",
+          hasLoader: true,
+          loader: rootLoader,
+          shouldReload,
+          element: {} as any,
+          module: "",
+          children: [
+            {
+              path: "/",
+              id: "index",
+              action: () => null,
+              element: {} as any,
+              module: "",
+              hasLoader: false,
+            },
+            {
+              path: "/child",
+              id: "child",
+              hasLoader: true,
+              loader: childLoader,
+              action: () => null,
+              element: {} as any,
+              module: "",
+            },
+          ],
+        },
+      ],
+    });
+
+    console.log(tm.getState());
+
+    await tm.send({
+      type: "navigation",
+      location: createLocation("/child?reload=1"),
+      action: Action.Push,
+    });
+    expect(rootLoader.mock.calls.length).toBe(1);
+    console.log(tm.getState());
+
+    await tm.send({
+      type: "navigation",
+      location: createLocation("/child?reload=0"),
+      action: Action.Push,
+    });
+    expect(rootLoader.mock.calls.length).toBe(2);
+
+    await tm.send({
+      type: "navigation",
+      location: createLocation("/child"),
+      submission: createActionSubmission("/child"),
+      action: Action.Push,
+    });
+
+    let args = shouldReload.mock.calls[2][0];
+    expect(args).toMatchInlineSnapshot(`
+      Object {
+        "params": Object {},
+        "prevUrl": "http://localhost/child?reload=0",
+        "submission": Object {
+          "action": "/child",
+          "encType": "application/x-www-form-urlencoded",
+          "formData": FormData {},
+          "key": "1",
+          "method": "POST",
+        },
+        "url": "http://localhost/child",
+      }
+    `);
+  });
 });
 
 describe("no route match", () => {
@@ -391,14 +596,14 @@ describe("errors on navigation", () => {
           {
             path: "/",
             id: "parent",
-            element: {},
+            element: {} as any,
             module: "",
             hasLoader: false,
             children: [
               {
                 path: "/child",
                 id: "child",
-                element: {},
+                element: {} as any,
                 module: "",
                 ErrorBoundary: FakeComponent,
                 hasLoader: true,
@@ -415,7 +620,7 @@ describe("errors on navigation", () => {
       });
       let state = tm.getState();
       expect(state.errorBoundaryId).toBe("child");
-      expect(state.error.message).toBe(ERROR_MESSAGE);
+      expect(state.error!.message).toBe(ERROR_MESSAGE);
     });
   });
 
@@ -428,7 +633,7 @@ describe("errors on navigation", () => {
       let child = {
         path: "/child",
         id: "child",
-        element: {},
+        element: {} as any,
         module: "",
         hasLoader: true,
         loader,
@@ -436,7 +641,7 @@ describe("errors on navigation", () => {
       let parent = {
         path: "/",
         id: "parent",
-        element: {},
+        element: {} as any,
         module: "",
         ErrorBoundary: FakeComponent,
         children: [child],
@@ -453,7 +658,7 @@ describe("errors on navigation", () => {
       });
       let state = tm.getState();
       expect(state.errorBoundaryId).toBe("parent");
-      expect(state.error.message).toBe(ERROR_MESSAGE);
+      expect(state.error!.message).toBe(ERROR_MESSAGE);
     });
 
     it("clears out the error on new locations", async () => {
@@ -466,21 +671,21 @@ describe("errors on navigation", () => {
           {
             path: "",
             id: "root",
-            element: {},
+            element: {} as any,
             module: "",
             hasLoader: false,
             children: [
               {
                 path: "/",
                 id: "parent",
-                element: {},
+                element: {} as any,
                 module: "",
                 hasLoader: false,
                 children: [
                   {
                     path: "/child",
                     id: "child",
-                    element: {},
+                    element: {} as any,
                     module: "",
                     ErrorBoundary: FakeComponent,
                     hasLoader: true,
@@ -522,6 +727,7 @@ describe("errors on navigation", () => {
     };
 
     let tm = createTestTransitionManager("/", {
+      deferredLoaderData: {},
       loaderData: {
         a: await loaderA(),
       },
@@ -529,7 +735,7 @@ describe("errors on navigation", () => {
         {
           path: "/",
           id: "a",
-          element: {},
+          element: {} as any,
           module: "",
           loader: loaderA,
           hasLoader: true,
@@ -537,7 +743,7 @@ describe("errors on navigation", () => {
             {
               path: "/b",
               id: "b",
-              element: {},
+              element: {} as any,
               module: "",
               loader: loaderB,
               hasLoader: true,
@@ -546,7 +752,7 @@ describe("errors on navigation", () => {
                 {
                   path: "/b/c",
                   id: "c",
-                  element: {},
+                  element: {} as any,
                   module: "",
                   hasLoader: true,
                   loader: loaderC,
@@ -618,7 +824,7 @@ describe("submission navigations", () => {
     expect(t.getState().actionData).toBeUndefined();
 
     await A.action.resolve("A");
-    expect(t.getState().actionData.foo).toBe("A");
+    expect(t.getState().actionData!.foo).toBe("A");
   });
 
   it("reloads all routes after the action", async () => {
@@ -673,7 +879,7 @@ describe("submission navigations", () => {
         {
           path: "/",
           id: "parent",
-          element: {},
+          element: {} as any,
           module: "",
           hasLoader: true,
           loader: () => "PARENT LOADER",
@@ -682,7 +888,7 @@ describe("submission navigations", () => {
             {
               path: "/child",
               id: "child",
-              element: {},
+              element: {} as any,
               module: "",
               ErrorBoundary: FakeComponent,
               hasLoader: true,
@@ -692,7 +898,7 @@ describe("submission navigations", () => {
                 {
                   index: true,
                   id: "childIndex",
-                  element: {},
+                  element: {} as any,
                   module: "",
                   ErrorBoundary: FakeComponent,
                   hasLoader: true,
@@ -740,14 +946,14 @@ describe("action errors", () => {
           {
             path: "/",
             id: "parent",
-            element: {},
+            element: {} as any,
             module: "",
             hasLoader: false,
             children: [
               {
                 path: "/child",
                 id: "child",
-                element: {},
+                element: {} as any,
                 module: "",
                 ErrorBoundary: FakeComponent,
                 hasLoader: false,
@@ -765,7 +971,7 @@ describe("action errors", () => {
       });
       let state = tm.getState();
       expect(state.errorBoundaryId).toBe("child");
-      expect(state.error.message).toBe(ERROR_MESSAGE);
+      expect(state.error!.message).toBe(ERROR_MESSAGE);
     });
 
     it("loads parent data, but not action data", async () => {
@@ -780,7 +986,7 @@ describe("action errors", () => {
           {
             path: "/",
             id: "parent",
-            element: {},
+            element: {} as any,
             module: "",
             hasLoader: true,
             loader: parentLoader,
@@ -788,7 +994,7 @@ describe("action errors", () => {
               {
                 path: "/child",
                 id: "child",
-                element: {},
+                element: {} as any,
                 module: "",
                 ErrorBoundary: FakeComponent,
                 hasLoader: false,
@@ -825,7 +1031,7 @@ describe("action errors", () => {
           {
             path: "/",
             id: "parent",
-            element: {},
+            element: {} as any,
             module: "",
             ErrorBoundary: FakeComponent,
             hasLoader: false,
@@ -833,7 +1039,7 @@ describe("action errors", () => {
               {
                 path: "/child",
                 id: "child",
-                element: {},
+                element: {} as any,
                 module: "",
                 hasLoader: false,
                 action,
@@ -850,7 +1056,7 @@ describe("action errors", () => {
       });
       let state = tm.getState();
       expect(state.errorBoundaryId).toBe("parent");
-      expect(state.error.message).toBe(ERROR_MESSAGE);
+      expect(state.error!.message).toBe(ERROR_MESSAGE);
     });
   });
 
@@ -869,7 +1075,7 @@ describe("action errors", () => {
           {
             path: "/",
             id: "root",
-            element: {},
+            element: {} as any,
             module: "",
             ErrorBoundary: FakeComponent,
             hasLoader: false,
@@ -877,7 +1083,7 @@ describe("action errors", () => {
               {
                 path: "/parent",
                 id: "parent",
-                element: {},
+                element: {} as any,
                 module: "",
                 loader: parentLoader,
                 hasLoader: true,
@@ -885,7 +1091,7 @@ describe("action errors", () => {
                   {
                     path: "/parent/child",
                     id: "child",
-                    element: {},
+                    element: {} as any,
                     module: "",
                     action,
                     hasLoader: false,
@@ -906,7 +1112,7 @@ describe("action errors", () => {
       });
       let state = tm.getState();
       expect(state.errorBoundaryId).toBe("root");
-      expect(state.error.message).toBe(ACTION_ERROR_MESSAGE);
+      expect(state.error!.message).toBe(ACTION_ERROR_MESSAGE);
     });
   });
 });
@@ -970,7 +1176,7 @@ describe("transition states", () => {
       // @ts-expect-error
       new URLSearchParams(transition.submission.formData).toString()
     ).toBe("gosh=dang");
-    expect(transition.submission.method).toBe("POST");
+    expect(transition.submission!.method).toBe("POST");
     expect(transition.location).toBe(A.location);
 
     await A.action.resolve("A");
@@ -981,7 +1187,7 @@ describe("transition states", () => {
       // @ts-expect-error
       new URLSearchParams(transition.submission.formData).toString()
     ).toBe("gosh=dang");
-    expect(transition.submission.method).toBe("POST");
+    expect(transition.submission!.method).toBe("POST");
     expect(transition.location).toBe(A.location);
 
     await A.loader.resolve("A");
@@ -1005,7 +1211,7 @@ describe("transition states", () => {
       // @ts-expect-error
       new URLSearchParams(transition.submission.formData).toString()
     ).toBe("gosh=dang");
-    expect(transition.submission.method).toBe("POST");
+    expect(transition.submission!.method).toBe("POST");
     expect(transition.location).toBe(B.location);
 
     await B.loader.resolve("B");
@@ -1026,7 +1232,7 @@ describe("transition states", () => {
       // @ts-expect-error
       new URLSearchParams(transition.submission.formData).toString()
     ).toBe("gosh=dang");
-    expect(transition.submission.method).toBe("GET");
+    expect(transition.submission!.method).toBe("GET");
     expect(transition.location).toBe(A.location);
 
     await A.loader.resolve("A");
@@ -1050,7 +1256,7 @@ describe("transition states", () => {
       // @ts-expect-error
       new URLSearchParams(transition.submission.formData).toString()
     ).toBe("gosh=dang");
-    expect(transition.submission.method).toBe("GET");
+    expect(transition.submission!.method).toBe("GET");
     expect(transition.location).toBe(B.location);
 
     await B.loader.resolve("B");
@@ -1810,6 +2016,438 @@ describe("navigating with inflight fetchers", () => {
   });
 });
 
+function setupDeferred(initialData: Record<string, string | Promise<any>>) {
+  let resolveBodyController;
+  let bodyControllerPromise = new Promise<ReadableStreamController<Uint8Array>>(
+    (resolve) => {
+      resolveBodyController = resolve;
+    }
+  );
+  let encoder = new TextEncoder();
+
+  let pendingKeys: Promise<any>[] = [];
+  let deferredData = Object.entries(initialData).reduce((acc, [k, v]) => {
+    if (v instanceof Promise) {
+      Object.assign(acc, { [k]: `__deferred_promise:${k}` });
+      let promise = v.then(
+        async (data) => {
+          let controller = await bodyControllerPromise;
+          controller.enqueue(
+            encoder.encode(`data:{ "${k}": ${JSON.stringify(data)} }\n\n`)
+          );
+        },
+        async (err) => {
+          let controller = await bodyControllerPromise;
+          let data = {
+            message: err.message,
+            stack: err.stack,
+          };
+          controller.enqueue(
+            encoder.encode(`error:{ "${k}": ${JSON.stringify(data)} }\n\n`)
+          );
+        }
+      );
+      pendingKeys.push(promise);
+    } else {
+      Object.assign(acc, { [k]: v });
+    }
+    return acc;
+  }, {});
+
+  let body = new ReadableStream<Uint8Array>({
+    async start(controller) {
+      controller.enqueue(encoder.encode(`${JSON.stringify(deferredData)}\n\n`));
+      resolveBodyController(controller);
+      await Promise.allSettled(pendingKeys);
+      controller.close();
+    },
+  });
+
+  return new DeferredResponse(
+    new Response(body, {
+      headers: { "Content-Type": "text/remix-deferred" },
+    })
+  );
+}
+
+// eslint-disable-next-line jest/no-focused-tests
+describe("deferred", () => {
+  it("should support returning deferred responses", async () => {
+    let t = setup({ url: "/" });
+
+    let A = await t.navigate.get("/foo");
+    let dfdA = defer();
+    await A.loader.resolve(
+      setupDeferred({
+        critical: "1",
+        lazy: dfdA.promise,
+      })
+    );
+    await new Promise((r) => setTimeout(r, 0));
+
+    expect(t.getState().loaderData).toEqual({
+      foo: {
+        critical: "1",
+        lazy: "__deferred_promise:lazy",
+      },
+      root: "ROOT",
+    });
+    expect(t.getState().deferredLoaderData).toEqual({
+      foo: {
+        lazy: expect.any(Promise),
+      },
+    });
+    expect(t.getState().transition.state).toBe("idle");
+
+    await dfdA.resolve("2");
+    await new Promise((r) => setTimeout(r, 0));
+
+    expect(t.getState().loaderData).toEqual({
+      foo: {
+        critical: "1",
+        lazy: "2",
+      },
+      root: "ROOT",
+    });
+    expect(t.getState().deferredLoaderData).toEqual({
+      foo: {},
+    });
+    expect(t.getState().transition.state).toBe("idle");
+
+    let B = await t.navigate.get("/bar");
+    let dfdB = defer();
+    await B.loader.resolve(
+      setupDeferred({
+        critical: "3",
+        lazy: dfdB.promise,
+      })
+    );
+    await new Promise((r) => setTimeout(r, 0));
+
+    expect(t.getState().loaderData).toEqual({
+      bar: {
+        critical: "3",
+        lazy: "__deferred_promise:lazy",
+      },
+      root: "ROOT",
+    });
+    expect(t.getState().deferredLoaderData).toEqual({
+      bar: {
+        lazy: expect.any(Promise),
+      },
+    });
+    expect(t.getState().transition.state).toBe("idle");
+
+    await dfdB.resolve("4");
+    await new Promise((r) => setTimeout(r, 0));
+
+    expect(t.getState().loaderData).toEqual({
+      bar: {
+        critical: "3",
+        lazy: "4",
+      },
+      root: "ROOT",
+    });
+    expect(t.getState().deferredLoaderData).toEqual({
+      bar: {},
+    });
+    expect(t.getState().transition.state).toBe("idle");
+  });
+
+  it("should cancel outstanding deferreds on a new navigation", async () => {
+    let t = setup({ url: "/" });
+
+    let A = await t.navigate.get("/foo");
+
+    let dfd = defer();
+    await A.loader.resolve(
+      setupDeferred({
+        critical: "1",
+        lazy: dfd.promise,
+      })
+    );
+    await new Promise((r) => setTimeout(r, 0));
+
+    let B = await t.navigate.get("/bar");
+
+    // During navigation - deferreds remain as promises
+    expect(t.getState().loaderData).toMatchInlineSnapshot(`
+      Object {
+        "foo": Object {
+          "critical": "1",
+          "lazy": "__deferred_promise:lazy",
+        },
+        "root": "ROOT",
+      }
+    `);
+    expect(t.getState().deferredLoaderData).toMatchInlineSnapshot(`
+      Object {
+        "foo": Object {
+          "lazy": Promise {},
+        },
+      }
+    `);
+
+    // But they are frozen - no re-paints on resolve/reject!
+    await dfd.resolve("2");
+    await new Promise((r) => setTimeout(r, 0));
+    expect(t.getState().loaderData).toMatchInlineSnapshot(`
+      Object {
+        "foo": Object {
+          "critical": "1",
+          "lazy": "__deferred_promise:lazy",
+        },
+        "root": "ROOT",
+      }
+    `);
+    expect(t.getState().deferredLoaderData).toMatchInlineSnapshot(`
+      Object {
+        "foo": Object {
+          "lazy": Promise {},
+        },
+      }
+    `);
+
+    await B.loader.resolve("BAR");
+    await new Promise((r) => setTimeout(r, 0));
+    expect(t.getState().loaderData).toEqual({
+      root: "ROOT",
+      bar: "BAR",
+    });
+    expect(t.getState().deferredLoaderData).toEqual({});
+  });
+
+  it("should not cancel outstanding deferreds on reused route", async () => {
+    let rootDfd = defer();
+    let t = setup({
+      url: "/",
+      rootLoaderOverride() {
+        return setupDeferred({
+          critical: "1",
+          lazy: rootDfd.promise,
+        });
+      },
+    });
+
+    let A = await t.navigate.get("/foo?key=value");
+
+    let dfd = defer();
+    await A.loader.resolve(
+      setupDeferred({
+        critical: "3",
+        lazy: dfd.promise,
+      })
+    );
+    await new Promise((r) => setTimeout(r, 0));
+
+    let B = await t.navigate.get("/bar?key=value");
+
+    // During navigation - deferreds remain as promises
+    expect(t.getState().loaderData).toMatchInlineSnapshot(`
+      Object {
+        "foo": Object {
+          "critical": "3",
+          "lazy": "__deferred_promise:lazy",
+        },
+        "root": Object {
+          "critical": "1",
+          "lazy": "__deferred_promise:lazy",
+        },
+      }
+    `);
+    expect(t.getState().deferredLoaderData).toMatchInlineSnapshot(`
+      Object {
+        "foo": Object {
+          "lazy": Promise {},
+        },
+        "root": Object {
+          "lazy": Promise {},
+        },
+      }
+    `);
+
+    // But they are frozen - no re-paints on resolve/reject!
+    await rootDfd.resolve("2");
+    await dfd.resolve("4");
+    await new Promise((r) => setTimeout(r, 0));
+    expect(t.getState().loaderData).toMatchInlineSnapshot(`
+      Object {
+        "foo": Object {
+          "critical": "3",
+          "lazy": "__deferred_promise:lazy",
+        },
+        "root": Object {
+          "critical": "1",
+          "lazy": "2",
+        },
+      }
+    `);
+    expect(t.getState().deferredLoaderData).toMatchInlineSnapshot(`
+      Object {
+        "foo": Object {
+          "lazy": Promise {},
+        },
+        "root": Object {},
+      }
+    `);
+
+    await B.loader.resolve("BAR");
+    await new Promise((r) => setTimeout(r, 0));
+    expect(t.getState().loaderData).toEqual({
+      root: {
+        critical: "1",
+        lazy: "2",
+      },
+      bar: "BAR",
+    });
+    expect(t.getState().deferredLoaderData).toMatchInlineSnapshot(`
+      Object {
+        "root": Object {},
+      }
+    `);
+  });
+
+  it("should cancel outstanding deferreds on 404 navigations", async () => {
+    let rootDfd = defer();
+    let t = setup({
+      url: "/",
+      rootLoaderOverride() {
+        return setupDeferred({
+          critical: "1",
+          lazy: rootDfd.promise,
+        });
+      },
+    });
+
+    let A = await t.navigate.get("/foo?key=value");
+
+    let dfd = defer();
+    await A.loader.resolve(
+      setupDeferred({
+        critical: "2",
+        lazy: dfd.promise,
+      })
+    );
+    await new Promise((r) => setTimeout(r, 0));
+
+    await t.navigate.get("/not/found");
+    await new Promise((r) => setTimeout(r, 0));
+
+    expect(t.getState().transition.state).toEqual("idle");
+    expect(t.getState().loaderData).toEqual({
+      root: {
+        critical: "1",
+        lazy: "__deferred_promise:lazy",
+      },
+      // This seems to be an outstanding thing in TM - on 404's the previously
+      // non-matched route data doesn't get cleaned up
+      foo: {
+        critical: "2",
+        lazy: "__deferred_promise:lazy",
+      },
+    });
+    expect(t.getState().deferredLoaderData).toEqual({});
+
+    // Resolving doesn't do anything
+    await rootDfd.resolve("Nope!");
+    await dfd.resolve("Nope!");
+    await new Promise((r) => setTimeout(r, 0));
+    expect(t.getState().transition.state).toEqual("idle");
+    expect(t.getState().loaderData).toEqual({
+      root: {
+        critical: "1",
+        lazy: "__deferred_promise:lazy",
+      },
+      foo: {
+        critical: "2",
+        lazy: "__deferred_promise:lazy",
+      },
+    });
+    expect(t.getState().deferredLoaderData).toEqual({});
+  });
+
+  it("should not cancel outstanding deferreds on hash-only navigations", async () => {
+    let t = setup({ url: "/" });
+
+    let A = await t.navigate.get("/foo");
+
+    let dfd = defer();
+    await A.loader.resolve(
+      setupDeferred({
+        critical: "1",
+        lazy: dfd.promise,
+      })
+    );
+    await new Promise((r) => setTimeout(r, 0));
+
+    let B = await t.navigate.get("/foo#hash");
+    await new Promise((r) => setTimeout(r, 0));
+
+    expect(t.getState().transition.state).toEqual("idle");
+    expect(t.getState().loaderData).toEqual({
+      root: "ROOT",
+      // This seems to be an outstanding thing in TM - on 404's the previously
+      // non-matched route data doesn't get cleaned up
+      foo: {
+        critical: "1",
+        lazy: "__deferred_promise:lazy",
+      },
+    });
+    expect(t.getState().deferredLoaderData).toEqual({
+      foo: {
+        lazy: expect.any(Promise),
+      },
+    });
+
+    await dfd.resolve("2");
+    await new Promise((r) => setTimeout(r, 0));
+    expect(t.getState().transition.state).toEqual("idle");
+    expect(t.getState().loaderData).toEqual({
+      root: "ROOT",
+      foo: {
+        critical: "1",
+        lazy: "2",
+      },
+    });
+    expect(t.getState().deferredLoaderData).toEqual({
+      foo: {},
+    });
+  });
+
+  it("should handle promise rejections", async () => {
+    let t = setup({ url: "/" });
+    let A = await t.navigate.get("/foo");
+
+    let dfd = defer();
+    await A.loader.resolve(
+      setupDeferred({
+        critical: "1",
+        lazy: dfd.promise,
+      })
+    );
+
+    let err = new Error("Kaboom!");
+    await dfd.reject(err).catch(() => null);
+    expect(t.getState().loaderData).toEqual({
+      foo: {
+        critical: "1",
+        lazy: err,
+      },
+      root: "ROOT",
+    });
+    expect(t.getState().deferredLoaderData).toMatchInlineSnapshot(`
+      Object {
+        "foo": Object {},
+      }
+    `);
+  });
+
+  it.todo("cancels pending deferreds on action submissions");
+  it.todo("does not support deferred data on fetcher loads");
+  it.todo("cancels pending deferreds on fetcher reloads");
+  it.todo("cancels pending deferreds on fetcher action submissions");
+});
+
 // describe("react-router", () => {
 //   it.todo("replaces pending locations even on a push");
 // });
@@ -1830,7 +2468,7 @@ function defer() {
       await (async () => promise)();
     };
   });
-  return { promise, resolve, reject };
+  return { promise, resolve: resolve!, reject: reject! };
 }
 
 let fakeKey = 0;
@@ -1885,6 +2523,7 @@ function createTestTransitionManager(
   let location = createLocation(pathname);
   return createTransitionManager({
     actionData: undefined,
+    deferredLoaderData: {},
     loaderData: { root: "ROOT" },
     location,
     routes: [],
@@ -1894,7 +2533,15 @@ function createTestTransitionManager(
   });
 }
 
-let setup = ({ url } = { url: "/" }) => {
+let setup = (
+  {
+    url,
+    rootLoaderOverride,
+  }: {
+    url: string;
+    rootLoaderOverride?: () => any | Promise<any>;
+  } = { url: "/" }
+) => {
   incrementingSubmissionKey = 0;
   let guid = 0;
 
@@ -1918,14 +2565,16 @@ let setup = ({ url } = { url: "/" }) => {
     lastRedirect = navigate_(createLocation(href, state));
   });
 
-  let rootLoader = jest.fn(() => "ROOT");
+  let rootLoader = rootLoaderOverride
+    ? jest.fn(rootLoaderOverride)
+    : jest.fn(() => "ROOT");
 
   let createLoader = () => {
     return jest.fn(async ({ signal }: { signal: AbortSignal }) => {
       let myId =
         nextLoaderType === "navigation" ? nextLoaderId : nextLoaderFetchId;
-      signal.onabort = loaderAbortHandlers.get(myId);
-      return loaderDeferreds.get(myId).promise.then(
+      signal.onabort = loaderAbortHandlers.get(myId) as any;
+      return loaderDeferreds.get(myId)!.promise.then(
         (val) => {
           return val;
         },
@@ -1938,8 +2587,8 @@ let setup = ({ url } = { url: "/" }) => {
     return jest.fn(async ({ signal }: { signal: AbortSignal }) => {
       let myType = nextActionType;
       let myId = myType === "navigation" ? nextActionId : nextActionFetchId;
-      signal.onabort = actionAbortHandlers.get(myId);
-      return actionDeferreds.get(myId).promise.then((val) => {
+      signal.onabort = actionAbortHandlers.get(myId) as any;
+      return actionDeferreds.get(myId)!.promise.then((val) => {
         if (myType === "navigation") {
           nextLoaderType = "navigation";
           nextLoaderId = myId;
@@ -1956,7 +2605,7 @@ let setup = ({ url } = { url: "/" }) => {
     {
       path: "",
       id: "root",
-      element: {},
+      element: {} as any,
       module: "",
       ErrorBoundary: FakeComponent,
       CatchBoundary: FakeComponent,
@@ -1969,7 +2618,7 @@ let setup = ({ url } = { url: "/" }) => {
           hasLoader: true,
           loader: createLoader(),
           action: createAction(),
-          element: {},
+          element: {} as any,
           module: "",
         },
         {
@@ -1978,7 +2627,7 @@ let setup = ({ url } = { url: "/" }) => {
           hasLoader: true,
           loader: createLoader(),
           action: createAction(),
-          element: {},
+          element: {} as any,
           module: "",
         },
         {
@@ -1987,7 +2636,7 @@ let setup = ({ url } = { url: "/" }) => {
           hasLoader: true,
           loader: createLoader(),
           action: createAction(),
-          element: {},
+          element: {} as any,
           module: "",
         },
         {
@@ -1996,7 +2645,7 @@ let setup = ({ url } = { url: "/" }) => {
           hasLoader: true,
           loader: createLoader(),
           action: createAction(),
-          element: {},
+          element: {} as any,
           module: "",
         },
         {
@@ -2005,7 +2654,7 @@ let setup = ({ url } = { url: "/" }) => {
           hasLoader: true,
           loader: createLoader(),
           action: createAction(),
-          element: {},
+          element: {} as any,
           module: "",
         },
         {
@@ -2014,7 +2663,7 @@ let setup = ({ url } = { url: "/" }) => {
           hasLoader: true,
           loader: createLoader(),
           action: createAction(),
-          element: {},
+          element: {} as any,
           module: "",
         },
       ],
@@ -2024,6 +2673,7 @@ let setup = ({ url } = { url: "/" }) => {
   let tm = createTestTransitionManager(url, {
     onChange: handleChange,
     onRedirect: handleRedirect,
+    deferredLoaderData: {},
     loaderData: { root: "ROOT" },
     routes,
   });
@@ -2054,12 +2704,12 @@ let setup = ({ url } = { url: "/" }) => {
     loaderAbortHandlers.set(id, loaderAbortHandler);
 
     async function resolveAction(val: any = null) {
-      await actionDeferreds.get(id).resolve(val);
+      await actionDeferreds.get(id)!.resolve(val);
     }
 
     async function resolveLoader(val: any) {
-      await loaderDeferreds.get(id).resolve(val);
-      await onChangeDeferreds.get(id).resolve();
+      await loaderDeferreds.get(id)!.resolve(val);
+      await onChangeDeferreds.get(id)!.resolve();
     }
 
     async function redirectAction(href: string) {
@@ -2077,7 +2727,7 @@ let setup = ({ url } = { url: "/" }) => {
       location,
       submission,
       action: action || Action.Push,
-    }).then(() => onChangeDeferreds.get(id).promise);
+    }).then(() => onChangeDeferreds.get(id)!.promise);
 
     return {
       location,
@@ -2124,35 +2774,35 @@ let setup = ({ url } = { url: "/" }) => {
     loaderAbortHandlers.set(id, loaderAbortHandler);
 
     async function resolveAction(val: any = null) {
-      await actionDeferreds.get(id).resolve(val);
+      await actionDeferreds.get(id)!.resolve(val);
     }
 
     async function resolveLoader(val: any = null) {
-      await loaderDeferreds.get(id).resolve(val);
+      await loaderDeferreds.get(id)!.resolve(val);
       await awaitChange();
     }
 
     async function throwLoaderCatch() {
       await loaderDeferreds
-        .get(id)
+        .get(id)!
         .resolve(new CatchValue(400, "Bad Request", null));
       await awaitChange();
     }
 
     async function throwLoaderError() {
-      await loaderDeferreds.get(id).resolve(new Error("Kaboom!"));
+      await loaderDeferreds.get(id)!.resolve(new Error("Kaboom!"));
       await awaitChange();
     }
 
     async function throwActionCatch() {
       await actionDeferreds
-        .get(id)
+        .get(id)!
         .resolve(new CatchValue(400, "Bad Request", null));
       await awaitChange();
     }
 
     async function throwActionError() {
-      await actionDeferreds.get(id).resolve(new Error("Kaboom!"));
+      await actionDeferreds.get(id)!.resolve(new Error("Kaboom!"));
       await awaitChange();
     }
 
@@ -2167,11 +2817,11 @@ let setup = ({ url } = { url: "/" }) => {
     }
 
     async function awaitChange() {
-      await onChangeDeferreds.get(id).resolve();
+      await onChangeDeferreds.get(id)!.resolve();
     }
 
     tm.send({ type: "fetcher", href, submission, key }).then(
-      () => onChangeDeferreds.get(id).promise
+      () => onChangeDeferreds.get(id)!.promise
     );
 
     return {