From 524aed1531ffa538f9ddb3e749d98d37aa9494cd Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 20 Nov 2025 09:52:54 -0500 Subject: [PATCH] Fix hydrate fallback rendering for SPA middleware w/o loader --- .changeset/olive-walls-rule.md | 5 ++ .../dom/data-browser-router-test.tsx | 54 +++++++++++++++++++ ...re-test.tsx => context-middleware-test.ts} | 51 ------------------ packages/react-router/lib/hooks.tsx | 8 ++- 4 files changed, 65 insertions(+), 53 deletions(-) create mode 100644 .changeset/olive-walls-rule.md rename packages/react-router/__tests__/router/{context-middleware-test.tsx => context-middleware-test.ts} (98%) diff --git a/.changeset/olive-walls-rule.md b/.changeset/olive-walls-rule.md new file mode 100644 index 0000000000..33f0e65b1f --- /dev/null +++ b/.changeset/olive-walls-rule.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Ensure `HydrateFallback` renders during SPA initialization for routes that have `middleware` but do not have a `loader` 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 6d05d6e883..8a04e646e4 100644 --- a/packages/react-router/__tests__/dom/data-browser-router-test.tsx +++ b/packages/react-router/__tests__/dom/data-browser-router-test.tsx @@ -501,6 +501,60 @@ function testDomRouter( `); }); + it("renders hydrateFallbackElement while first data fetch happens when it is only middleware", async () => { + let middlewareDfd = createDeferred(); + let router = createTestRouter( + createRoutesFromElements( + } + hydrateFallbackElement={} + > + middlewareDfd.promise]} + element={} + /> + } /> + , + ), + { + window: getWindow("/foo"), + }, + ); + let { container } = render(); + + function FallbackElement() { + return

Loading...

; + } + + function Foo() { + return

Foo

; + } + + function Bar() { + return

Bar Heading

; + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Loading... +

+
" + `); + + middlewareDfd.resolve(); + await waitFor(() => screen.getByText("Foo")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Foo +

+
" + `); + }); + it("does not render fallbackElement if no data fetch or lazy loading is required", async () => { let fooDefer = createDeferred(); let router = createTestRouter( diff --git a/packages/react-router/__tests__/router/context-middleware-test.tsx b/packages/react-router/__tests__/router/context-middleware-test.ts similarity index 98% rename from packages/react-router/__tests__/router/context-middleware-test.tsx rename to packages/react-router/__tests__/router/context-middleware-test.ts index 8475937dc1..20b8f29da8 100644 --- a/packages/react-router/__tests__/router/context-middleware-test.tsx +++ b/packages/react-router/__tests__/router/context-middleware-test.ts @@ -749,57 +749,6 @@ describe("context/middleware", () => { unsub(); }); - - it("works with createRoutesFromElements", async () => { - let context = new RouterContextProvider(); - router = createRouter({ - history: createMemoryHistory(), - getContext: () => context, - routes: createRoutesFromElements( - <> - - { - context.get(orderContext).push("parent loader"); - }} - > - { - context.get(orderContext).push("child loader"); - }} - /> - - , - ), - }); - - await router.navigate("/parent/child"); - - expect(context.get(orderContext)).toEqual([ - "a middleware - before next()", - "b middleware - before next()", - "c middleware - before next()", - "d middleware - before next()", - "parent loader", - "child loader", - "d middleware - after next()", - "c middleware - after next()", - "b middleware - after next()", - "a middleware - after next()", - ]); - }); }); describe("lazy", () => { diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 2f90ca381f..791e63f4e8 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1158,7 +1158,7 @@ export function _renderMatches( // a given HydrateFallback while we load the rest of the hydration data let renderFallback = false; let fallbackIndex = -1; - if (dataRouterState) { + if (dataRouterState && !dataRouterState.initialized) { for (let i = 0; i < renderedMatches.length; i++) { let match = renderedMatches[i]; // Track the deepest fallback up until the first route without data @@ -1168,11 +1168,15 @@ export function _renderMatches( if (match.route.id) { let { loaderData, errors } = dataRouterState; + let needsToRunSpaMiddleware = + match.route.middleware && + match.route.middleware.length > 0 && + !match.route.loader; let needsToRunLoader = match.route.loader && !loaderData.hasOwnProperty(match.route.id) && (!errors || errors[match.route.id] === undefined); - if (match.route.lazy || needsToRunLoader) { + if (match.route.lazy || needsToRunSpaMiddleware || needsToRunLoader) { // We found the first route that's not ready to render (waiting on // lazy, or has a loader that hasn't run yet). Flag that we need to // render a fallback and render up until the appropriate fallback