Repository: remix-run/react-router
Pull request #13395: Add `unstable_runClientMiddleware` API for `dataStrategy`
Author: login: brophdawg11
Labels: name: CLA Signed
Created: 2025-04-10T14:58:20Z | Merged: 2025-04-10T17:46:35Z

Add the `unstable_runClientMiddleware` API to `dataStrategy` to allow users to run middleware inside of custom `dataStrategy` implementations


Diff:
diff --git a/.changeset/silent-snakes-mix.md b/.changeset/silent-snakes-mix.md
new file mode 100644
index 0000000000..cba0468503
--- /dev/null
+++ b/.changeset/silent-snakes-mix.md
@@ -0,0 +1,5 @@
+---
+"react-router": patch
+---
+
+UNSTABLE: Add a new `unstable_runClientMiddleware` argument to `dataStrategy` to enable middleware execution in custom `dataStrategy` implementations
diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx
index 03591c834c..605e55168d 100644
--- a/packages/react-router/lib/dom/ssr/single-fetch.tsx
+++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx
@@ -1,7 +1,7 @@
 import * as React from "react";
 import { decode } from "turbo-stream";
 import type { Router as DataRouter } from "../../router/router";
-import { isResponse, runMiddlewarePipeline } from "../../router/router";
+import { isResponse } from "../../router/router";
 import type {
   DataStrategyFunction,
   DataStrategyFunctionArgs,
@@ -148,11 +148,22 @@ export function StreamTransfer({
   }
 }
 
-function handleMiddlewareError(error: unknown, routeId: string) {
-  return { [routeId]: { type: "error", result: error } };
+export function getSingleFetchDataStrategy(
+  manifest: AssetsManifest,
+  ssr: boolean,
+  basename: string | undefined,
+  getRouter: () => DataRouter
+): DataStrategyFunction {
+  let dataStrategy = getSingleFetchDataStrategyImpl(
+    manifest,
+    ssr,
+    basename,
+    getRouter
+  );
+  return async (args) => args.unstable_runClientMiddleware(dataStrategy);
 }
 
-export function getSingleFetchDataStrategy(
+export function getSingleFetchDataStrategyImpl(
   manifest: AssetsManifest,
   ssr: boolean,
   basename: string | undefined,
@@ -163,15 +174,16 @@ export function getSingleFetchDataStrategy(
 
     // Actions are simple and behave the same for navigations and fetchers
     if (request.method !== "GET") {
-      return runMiddlewarePipeline(
-        args,
-        false,
-        () => singleFetchActionStrategy(args, basename),
-        handleMiddlewareError
-      ) as Promise<Record<string, DataStrategyResult>>;
+      return singleFetchActionStrategy(args, basename);
     }
 
-    if (!ssr) {
+    let foundRevalidatingServerLoader = matches.some(
+      (m) =>
+        m.unstable_shouldCallHandler() &&
+        manifest.routes[m.route.id]?.hasLoader &&
+        !manifest.routes[m.route.id]?.hasClientLoader
+    );
+    if (!ssr && !foundRevalidatingServerLoader) {
       // If this is SPA mode, there won't be any loaders below root and we'll
       // disable single fetch.  We have to keep the `dataStrategy` defined for
       // SPA mode because we may load a SPA fallback page but then navigate into
@@ -204,46 +216,22 @@ export function getSingleFetchDataStrategy(
       //   errored otherwise
       // - So it's safe to make the call knowing there will be a `.data` file on
       //   the other end
-      let foundRevalidatingServerLoader = matches.some(
-        (m) =>
-          m.unstable_shouldCallHandler() &&
-          manifest.routes[m.route.id]?.hasLoader &&
-          !manifest.routes[m.route.id]?.hasClientLoader
-      );
-      if (!foundRevalidatingServerLoader) {
-        return runMiddlewarePipeline(
-          args,
-          false,
-          () => nonSsrStrategy(args, manifest, basename),
-          handleMiddlewareError
-        ) as Promise<Record<string, DataStrategyResult>>;
-      }
+      return nonSsrStrategy(args, manifest, basename);
     }
 
     // Fetcher loads are singular calls to one loader
     if (fetcherKey) {
-      return runMiddlewarePipeline(
-        args,
-        false,
-        () => singleFetchLoaderFetcherStrategy(request, matches, basename),
-        handleMiddlewareError
-      ) as Promise<Record<string, DataStrategyResult>>;
+      return singleFetchLoaderFetcherStrategy(request, matches, basename);
     }
 
     // Navigational loads are more complex...
-    return runMiddlewarePipeline(
+    return singleFetchLoaderNavigationStrategy(
       args,
-      false,
-      () =>
-        singleFetchLoaderNavigationStrategy(
-          args,
-          manifest,
-          ssr,
-          getRouter(),
-          basename
-        ),
-      handleMiddlewareError
-    ) as Promise<Record<string, DataStrategyResult>>;
+      manifest,
+      ssr,
+      getRouter(),
+      basename
+    );
   };
 }
 
diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts
index b622f53f5c..b5585cb978 100644
--- a/packages/react-router/lib/router/router.ts
+++ b/packages/react-router/lib/router/router.ts
@@ -2827,7 +2827,8 @@ export function createRouter(init: RouterInit): Router {
         request,
         matches,
         fetcherKey,
-        scopedContext
+        scopedContext,
+        false
       );
     } catch (e) {
       // If the outer dataStrategy method throws, just return the error for all
@@ -4218,7 +4219,8 @@ export function createStaticHandler(
       request,
       matches,
       null,
-      requestContext
+      requestContext,
+      true
     );
 
     let dataResults: Record<string, DataResult> = {};
@@ -5546,7 +5548,8 @@ async function callDataStrategyImpl(
   request: Request,
   matches: DataStrategyMatch[],
   fetcherKey: string | null,
-  scopedContext: unknown
+  scopedContext: unknown,
+  isStaticHandler: boolean
 ): Promise<Record<string, DataStrategyResult>> {
   // Ensure all middleware is loaded before we start executing routes
   if (matches.some((m) => m._lazyPromises?.middleware)) {
@@ -5556,12 +5559,52 @@ async function callDataStrategyImpl(
   // Send all matches here to allow for a middleware-type implementation.
   // handler will be a no-op for unneeded routes and we filter those results
   // back out below.
-  let results = await dataStrategyImpl({
-    matches,
+  let dataStrategyArgs = {
     request,
     params: matches[0].params,
-    fetcherKey,
     context: scopedContext,
+    matches,
+  };
+  let unstable_runClientMiddleware = isStaticHandler
+    ? () => {
+        throw new Error(
+          "You cannot call `unstable_runClientMiddleware()` from a static handler " +
+            "`dataStrategy`. Middleware is run outside of `dataStrategy` during " +
+            "SSR in order to bubble up the Response.  You can enable middleware " +
+            "via the `respond` API in `query`/`queryRoute`"
+        );
+      }
+    : (cb: DataStrategyFunction<unstable_RouterContextProvider>) => {
+        let typedDataStrategyArgs = dataStrategyArgs as (
+          | LoaderFunctionArgs<unstable_RouterContextProvider>
+          | ActionFunctionArgs<unstable_RouterContextProvider>
+        ) & {
+          matches: DataStrategyMatch[];
+        };
+        return runMiddlewarePipeline(
+          typedDataStrategyArgs,
+          false,
+          () =>
+            cb({
+              ...typedDataStrategyArgs,
+              fetcherKey,
+              unstable_runClientMiddleware: () => {
+                throw new Error(
+                  "Cannot call `unstable_runClientMiddleware()` from within an " +
+                    "`unstable_runClientMiddleware` handler"
+                );
+              },
+            }),
+          (error: unknown, routeId: string) => ({
+            [routeId]: { type: "error", result: error },
+          })
+        ) as Promise<Record<string, DataStrategyResult>>;
+      };
+
+  let results = await dataStrategyImpl({
+    ...dataStrategyArgs,
+    fetcherKey,
+    unstable_runClientMiddleware,
   });
 
   // Wait for all routes to load here but swallow the error since we want
diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts
index 8de2c979d5..cebea7406f 100644
--- a/packages/react-router/lib/router/utils.ts
+++ b/packages/react-router/lib/router/utils.ts
@@ -347,7 +347,6 @@ export interface DataStrategyMatch
   shouldLoad: boolean;
   // This can be null for actions calls and for initial hydration calls
   unstable_shouldRevalidateArgs: ShouldRevalidateFunctionArgs | null;
-  // TODO: Figure out a good name for this or use `shouldLoad` and add a future flag
   // This function will use a scoped version of `shouldRevalidateArgs` because
   // they are read-only but let the user provide an optional override value for
   // `defaultShouldRevalidate` if they choose
@@ -362,8 +361,9 @@ export interface DataStrategyMatch
 export interface DataStrategyFunctionArgs<Context = DefaultContext>
   extends DataFunctionArgs<Context> {
   matches: DataStrategyMatch[];
-  // TODO: Implement
-  // runMiddleware: () => unknown,
+  unstable_runClientMiddleware: (
+    cb: DataStrategyFunction<Context>
+  ) => Promise<Record<string, DataStrategyResult>>;
   fetcherKey: string | null;
 }
 


Reviews:
user: login: brophdawg11
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T14:58:57Z
user: login: brophdawg11
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T14:59:39Z

Review comments:
diff_hunk: @@ -163,15 +174,16 @@ export function getSingleFetchDataStrategy(
 
     // Actions are simple and behave the same for navigations and fetchers
     if (request.method !== "GET") {
-      return runMiddlewarePipeline(
-        args,
-        false,
-        () => singleFetchActionStrategy(args, basename),
-        handleMiddlewareError
-      ) as Promise<Record<string, DataStrategyResult>>;
+      return singleFetchActionStrategy(args, basename);
path: packages/react-router/lib/dom/ssr/single-fetch.tsx
user: login: brophdawg11
body: This goes back to effectively the original non-middleware implementation because we wrap middleware around the whole thing above
created_at: 2025-04-10T14:58:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,11 +148,22 @@ export function StreamTransfer({
   }
 }
 
-function handleMiddlewareError(error: unknown, routeId: string) {
-  return { [routeId]: { type: "error", result: error } };
+export function getSingleFetchDataStrategy(
+  manifest: AssetsManifest,
+  ssr: boolean,
+  basename: string | undefined,
+  getRouter: () => DataRouter
+): DataStrategyFunction {
+  let dataStrategy = getSingleFetchDataStrategyImpl(
+    manifest,
+    ssr,
+    basename,
+    getRouter
+  );
+  return async (args) => args.unstable_runMiddleware(dataStrategy);
path: packages/react-router/lib/dom/ssr/single-fetch.tsx
user: login: brophdawg11
body: Use the new `runMiddleware` API to wrap middleware around our existing `dataStrategy` - instead of calling middleware independently for each fork in the `dataStrategy`
created_at: 2025-04-10T14:59:39Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-04-10T14:58:23Z
body: ###  🦋  Changeset detected

Latest commit: 3be00d2ee49b267420a751f86caae6700ee967ec

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 11 packages</summary>

  | Name                                      | Type  |
| ----------------------------------------- | ----- |
| react-router                              | Patch |
| @react-router/architect                   | Patch |
| @react-router/cloudflare                  | Patch |
| @react-router/dev                         | Patch |
| react-router-dom                          | Patch |
| @react-router/express                     | Patch |
| @react-router/node                        | Patch |
| @react-router/serve                       | Patch |
| @react-router/fs-routes                   | Patch |
| @react-router/remix-routes-option-adapter | Patch |
| create-react-router                       | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/remix-run/react-router/new/brophdawg11/ds-run-middleware?filename=.changeset/great-cherries-reply.md&value=---%0A%22react-router%22%3A%20patch%0A---%0A%0AAdd%20%60unstable_runMiddleware%60%20API%20for%20%60dataStrategy%60%0A)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-15T14:51:44Z
body: 🤖 Hello there,

We just published version `7.5.1-pre.0` which includes this pull request. If you'd like to take it for a test run please try it out and let us know what you think!

Thanks!
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-17T15:27:50Z
body: 🤖 Hello there,

We just published version `7.5.1` which includes this pull request. If you'd like to take it for a test run please try it out and let us know what you think!

Thanks!
author_association: CONTRIBUTOR