From 358c74156fd07f3296be01f5f99217f45d050c60 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 21:06:24 +0200 Subject: [PATCH 01/10] refactor(router-core): skip beforeLoad and related store updates if options is not defined --- .../store-updates-during-navigation.test.tsx | 2 +- packages/router-core/src/router.ts | 85 ++++++++++++------- 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 4c18a56815..85f95da9f5 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -73,6 +73,6 @@ describe('Store updates during navigation', () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(after - before).toBe(19) + expect(after - before).toBe(16) }) }) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 58b996d866..62d397cd22 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2299,6 +2299,20 @@ export class RouterCore< continue } + const handleSearchAndParamSerialErrors = ( + index: number, + matchId: string, + ) => { + const { paramsError, searchError } = this.getMatch(matchId)! + if (paramsError) { + handleSerialError(index, paramsError, 'PARSE_PARAMS') + } + + if (searchError) { + handleSerialError(index, searchError, 'VALIDATE_SEARCH') + } + } + const shouldPending = !!( onReady && !this.isServer && @@ -2312,7 +2326,6 @@ export class RouterCore< (this.options as any)?.defaultPendingComponent) ) - let executeBeforeLoad = true const setupPendingTimeout = () => { const match = this.getMatch(matchId)! if ( @@ -2329,14 +2342,33 @@ export class RouterCore< match._nonReactive.pendingTimeout = pendingTimeout } } - if ( - // If we are in the middle of a load, either of these will be present - // (not to be confused with `loadPromise`, which is always defined) + + // If we are in the middle of a load, either of these will be present + // (not to be confused with `loadPromise`, which is always defined) + const hasBeforeLoadOrLoaderPromise = existingMatch._nonReactive.beforeLoadPromise || existingMatch._nonReactive.loaderPromise - ) { + + if (hasBeforeLoadOrLoaderPromise) { setupPendingTimeout() + } + // we can bail out early if there is no `beforeLoad` + if (!route.options.beforeLoad) { + handleSearchAndParamSerialErrors(index, matchId) + const parentMatchContext = + parentMatch?.context ?? this.options.context ?? {} + updateMatch(matchId, (prev) => ({ + ...prev, + context: { + ...parentMatchContext, + ...prev.__routeContext, + }, + })) + continue + } + let executeBeforeLoad = true + if (hasBeforeLoadOrLoaderPromise) { // Wait for the beforeLoad to resolve before we continue await existingMatch._nonReactive.beforeLoadPromise const match = this.getMatch(matchId)! @@ -2349,6 +2381,9 @@ export class RouterCore< handleRedirectAndNotFound(match, match.error) } } + + let beforeLoadContext = + this.getMatch(matchId)!.__beforeLoadContext if (executeBeforeLoad) { // If we are not in the middle of a load OR the previous load failed, start it try { @@ -2362,15 +2397,7 @@ export class RouterCore< prevLoadPromise?.resolve() }) - const { paramsError, searchError } = this.getMatch(matchId)! - - if (paramsError) { - handleSerialError(index, paramsError, 'PARSE_PARAMS') - } - - if (searchError) { - handleSerialError(index, searchError, 'VALIDATE_SEARCH') - } + handleSearchAndParamSerialErrors(index, matchId) setupPendingTimeout() @@ -2415,8 +2442,8 @@ export class RouterCore< matches, } - const beforeLoadContext = - await route.options.beforeLoad?.(beforeLoadFnContext) + beforeLoadContext = + await route.options.beforeLoad!(beforeLoadFnContext) if ( isRedirect(beforeLoadContext) || @@ -2424,20 +2451,15 @@ export class RouterCore< ) { handleSerialError(index, beforeLoadContext, 'BEFORE_LOAD') } - - updateMatch(matchId, (prev) => { - return { - ...prev, - __beforeLoadContext: beforeLoadContext, - context: { - ...parentMatchContext, - ...prev.__routeContext, - ...beforeLoadContext, - }, - abortController, - } - }) } catch (err) { + updateMatch(matchId, (prev) => ({ + ...prev, + __beforeLoadContext: beforeLoadContext, + context: { + ...prev.context, + ...beforeLoadContext, + }, + })) handleSerialError(index, err, 'BEFORE_LOAD') } @@ -2447,7 +2469,12 @@ export class RouterCore< return { ...prev, + __beforeLoadContext: beforeLoadContext, isFetching: false, + context: { + ...prev.context, + ...beforeLoadContext, + }, } }) } From d7d519bdcd1becf203e71c7d3fd311489bed52a6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 21:14:13 +0200 Subject: [PATCH 02/10] fix faulty solid-router Transitioner test --- packages/solid-router/tests/Transitioner.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/solid-router/tests/Transitioner.test.tsx b/packages/solid-router/tests/Transitioner.test.tsx index ffd3ffb100..dffe2bd4fd 100644 --- a/packages/solid-router/tests/Transitioner.test.tsx +++ b/packages/solid-router/tests/Transitioner.test.tsx @@ -30,13 +30,12 @@ describe('Transitioner', () => { // Mock router.load() to verify it gets called const loadSpy = vi.spyOn(router, 'load') - await router.load() - render(() => ) + await router.latestLoadPromise // Wait for the createRenderEffect to run and call router.load() await waitFor(() => { - expect(loadSpy).toHaveBeenCalledTimes(2) + expect(loadSpy).toHaveBeenCalledTimes(1) expect(loader).toHaveBeenCalledTimes(1) }) From 1669e71803da19841d8adfa0f24e3df1ee4e962d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 21:26:25 +0200 Subject: [PATCH 03/10] skip store update if beforeLoad is synchronous --- .../store-updates-during-navigation.test.tsx | 63 ++++++++++++++++--- packages/router-core/src/router.ts | 38 ++++++----- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 85f95da9f5..5ff77a12a5 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { Link, @@ -16,7 +16,19 @@ afterEach(() => { cleanup() }) -function setup({ RootComponent }: { RootComponent: RouteComponent }) { +function setup({ + RootComponent, + beforeLoad, + loader, + defaultPendingMs, + defaultPendingMinMs, +}: { + RootComponent: RouteComponent + beforeLoad?: () => any + loader?: () => any + defaultPendingMs?: number, + defaultPendingMinMs?: number, +}) { const rootRoute = createRootRoute({ component: RootComponent, }) @@ -34,23 +46,23 @@ function setup({ RootComponent }: { RootComponent: RouteComponent }) { const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/posts', - beforeLoad: () => new Promise((resolve) => setTimeout(resolve, 100)), - loader: () => new Promise((resolve) => setTimeout(resolve, 100)), + beforeLoad, + loader, component: () =>

PostsTitle

, }) const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute, postsRoute]), - defaultPendingMs: 100, - defaultPendingMinMs: 300, + defaultPendingMs, + defaultPendingMinMs, defaultPendingComponent: () =>

Loading...

, }) return render() } -describe('Store updates during navigation', () => { - it("isn't called *too many* times", async () => { +describe('Store doesn\'t update *too many* times during navigation', () => { + test("everything (async loader, async beforeLoad, pendingMs)", async () => { const select = vi.fn() setup({ @@ -58,6 +70,11 @@ describe('Store updates during navigation', () => { useRouterState({ select }) return }, + beforeLoad: () => + new Promise((resolve) => setTimeout(resolve, 100)), + loader: () => new Promise((resolve) => setTimeout(resolve, 100)), + defaultPendingMs: 100, + defaultPendingMinMs: 300, }) // navigate to /posts @@ -75,4 +92,34 @@ describe('Store updates during navigation', () => { // Any change that increases this number should be investigated. expect(after - before).toBe(16) }) + + test('sync beforeLoad', async () => { + const select = vi.fn() + + setup({ + RootComponent: () => { + useRouterState({ select }) + return + }, + beforeLoad: () => { }, + loader: () => new Promise((resolve) => setTimeout(resolve, 100)), + defaultPendingMs: 100, + defaultPendingMinMs: 300, + }) + + // navigate to /posts + const link = await waitFor(() => + screen.getByRole('link', { name: 'Posts' }), + ) + const before = select.mock.calls.length + act(() => link.click()) + const title = await waitFor(() => screen.getByText('PostsTitle')) + expect(title).toBeInTheDocument() + const after = select.mock.calls.length + + // This number should be as small as possible to minimize the amount of work + // that needs to be done during a navigation. + // Any change that increases this number should be investigated. + expect(after - before).toBe(15) + }) }) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 62d397cd22..d7c4800fad 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2384,6 +2384,7 @@ export class RouterCore< let beforeLoadContext = this.getMatch(matchId)!.__beforeLoadContext + const context = {} if (executeBeforeLoad) { // If we are not in the middle of a load OR the previous load failed, start it try { @@ -2406,20 +2407,11 @@ export class RouterCore< const parentMatchContext = parentMatch?.context ?? this.options.context ?? undefined - updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'beforeLoad', - fetchCount: prev.fetchCount + 1, - abortController, - context: { - ...parentMatchContext, - ...prev.__routeContext, - }, - })) - - const { search, params, context, cause } = + const { search, params, __routeContext, cause } = this.getMatch(matchId)! + Object.assign(context, parentMatchContext, __routeContext) + const preload = resolvePreload(matchId) const beforeLoadFnContext: BeforeLoadContextOptions< @@ -2442,8 +2434,24 @@ export class RouterCore< matches, } - beforeLoadContext = - await route.options.beforeLoad!(beforeLoadFnContext) + const beforeLoadResult = + route.options.beforeLoad!(beforeLoadFnContext) + const beforeLoadIsAsync = + beforeLoadResult && 'then' in beforeLoadResult + + if (beforeLoadIsAsync) { + updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: 'beforeLoad', + fetchCount: prev.fetchCount + 1, + abortController, + context, + })) + } + + beforeLoadContext = beforeLoadIsAsync + ? await beforeLoadResult + : beforeLoadResult if ( isRedirect(beforeLoadContext) || @@ -2457,6 +2465,7 @@ export class RouterCore< __beforeLoadContext: beforeLoadContext, context: { ...prev.context, + context, ...beforeLoadContext, }, })) @@ -2473,6 +2482,7 @@ export class RouterCore< isFetching: false, context: { ...prev.context, + ...context, ...beforeLoadContext, }, } From 8258f15d206bec247262e92371b971badce1b8b0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 21:26:38 +0200 Subject: [PATCH 04/10] prettier --- .../tests/store-updates-during-navigation.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 5ff77a12a5..cbfabecc9f 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -26,8 +26,8 @@ function setup({ RootComponent: RouteComponent beforeLoad?: () => any loader?: () => any - defaultPendingMs?: number, - defaultPendingMinMs?: number, + defaultPendingMs?: number + defaultPendingMinMs?: number }) { const rootRoute = createRootRoute({ component: RootComponent, @@ -61,8 +61,8 @@ function setup({ return render() } -describe('Store doesn\'t update *too many* times during navigation', () => { - test("everything (async loader, async beforeLoad, pendingMs)", async () => { +describe("Store doesn't update *too many* times during navigation", () => { + test('everything (async loader, async beforeLoad, pendingMs)', async () => { const select = vi.fn() setup({ @@ -101,7 +101,7 @@ describe('Store doesn\'t update *too many* times during navigation', () => { useRouterState({ select }) return }, - beforeLoad: () => { }, + beforeLoad: () => {}, loader: () => new Promise((resolve) => setTimeout(resolve, 100)), defaultPendingMs: 100, defaultPendingMinMs: 300, From f88d5a63b666ac317b9d65cb7bf5f3b5552194d6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 22:09:34 +0200 Subject: [PATCH 05/10] fix promise detection --- packages/router-core/src/router.ts | 4 ++-- packages/router-core/src/utils.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index d7c4800fad..560e943e16 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -9,6 +9,7 @@ import { createControlledPromise, deepEqual, functionalUpdate, + isPromise, last, pick, replaceEqualDeep, @@ -2436,8 +2437,7 @@ export class RouterCore< const beforeLoadResult = route.options.beforeLoad!(beforeLoadFnContext) - const beforeLoadIsAsync = - beforeLoadResult && 'then' in beforeLoadResult + const beforeLoadIsAsync = isPromise(beforeLoadResult) if (beforeLoadIsAsync) { updateMatch(matchId, (prev) => ({ diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 784e7c4227..4afd82d87b 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -473,3 +473,13 @@ export function isModuleNotFoundError(error: any): boolean { error.message.startsWith('Importing a module script failed') ) } + +export function isPromise( + value: Promise> | T, +): value is Promise> { + return Boolean( + value && + typeof value === 'object' && + typeof (value as Promise).then === 'function', + ) +} From 9d4b7c2e698436b6b9bb057a5343061b325012d5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 15 Aug 2025 23:44:57 +0200 Subject: [PATCH 06/10] re-apply changes --- .../store-updates-during-navigation.test.tsx | 20 +- packages/router-core/src/router.ts | 195 +++++++++--------- 2 files changed, 119 insertions(+), 96 deletions(-) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 19507e182e..2ee24ef378 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -110,7 +110,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(17) + expect(updates).toBe(14) }) test('redirection in preload', async () => { @@ -128,6 +128,22 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(8) + expect(updates).toBe(6) + }) + + test('sync beforeLoad', async () => { + const params = setup({ + beforeLoad: () => {}, + loader: () => new Promise((resolve) => setTimeout(resolve, 100)), + defaultPendingMs: 100, + defaultPendingMinMs: 300, + }) + + const updates = await run(params) + + // This number should be as small as possible to minimize the amount of work + // that needs to be done during a navigation. + // Any change that increases this number should be investigated. + expect(updates).toBe(14) }) }) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 1200bcc892..0b99185ada 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2318,11 +2318,9 @@ export class RouterCore< const match = this.getMatch(matchId)! if (shouldPending && match._nonReactive.pendingTimeout === undefined) { const pendingTimeout = setTimeout(() => { - try { - // Update the match and prematurely resolve the loadMatches promise so that - // the pending component can start rendering - this.triggerOnReady(innerLoadContext) - } catch {} + // Update the match and prematurely resolve the loadMatches promise so that + // the pending component can start rendering + this.triggerOnReady(innerLoadContext) }, pendingMs) match._nonReactive.pendingTimeout = pendingTimeout } @@ -2371,51 +2369,10 @@ export class RouterCore< index: number, route: AnyRoute, ): void | Promise => { - const resolve = () => { - innerLoadContext.updateMatch(matchId, (prev) => { - prev._nonReactive.beforeLoadPromise?.resolve() - prev._nonReactive.beforeLoadPromise = undefined - - return { - ...prev, - isFetching: false, - } - }) - } - - try { - const match = this.getMatch(matchId)! - match._nonReactive.beforeLoadPromise = createControlledPromise() - // explicitly capture the previous loadPromise - const prevLoadPromise = match._nonReactive.loadPromise - match._nonReactive.loadPromise = createControlledPromise(() => { - prevLoadPromise?.resolve() - }) - - const { paramsError, searchError } = this.getMatch(matchId)! - - if (paramsError) { - this.handleSerialError( - innerLoadContext, - index, - paramsError, - 'PARSE_PARAMS', - ) - } - - if (searchError) { - this.handleSerialError( - innerLoadContext, - index, - searchError, - 'VALIDATE_SEARCH', - ) - } - - this.setupPendingTimeout(innerLoadContext, matchId, route) - - const abortController = new AbortController() + const match = this.getMatch(matchId)! + const abortController = new AbortController() + const pending = () => { const parentMatchId = innerLoadContext.matches[index - 1]?.id const parentMatch = parentMatchId ? this.getMatch(parentMatchId)! @@ -2433,69 +2390,118 @@ export class RouterCore< ...prev.__routeContext, }, })) + } - const { search, params, context, cause } = this.getMatch(matchId)! + const resolve = () => { + match._nonReactive.beforeLoadPromise?.resolve() + match._nonReactive.beforeLoadPromise = undefined + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: false, + })) + } - const preload = this.resolvePreload(innerLoadContext, matchId) + match._nonReactive.beforeLoadPromise = createControlledPromise() + // explicitly capture the previous loadPromise + const prevLoadPromise = match._nonReactive.loadPromise + match._nonReactive.loadPromise = createControlledPromise(() => { + prevLoadPromise?.resolve() + }) - const beforeLoadFnContext: BeforeLoadContextOptions< - any, - any, - any, - any, - any - > = { - search, - abortController, - params, - preload, - context, - location: innerLoadContext.location, - navigate: (opts: any) => - this.navigate({ ...opts, _fromLocation: innerLoadContext.location }), - buildLocation: this.buildLocation, - cause: preload ? 'preload' : cause, - matches: innerLoadContext.matches, - } + const { paramsError, searchError } = this.getMatch(matchId)! - const updateContext = (beforeLoadContext: any) => { - if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { - this.handleSerialError( - innerLoadContext, - index, - beforeLoadContext, - 'BEFORE_LOAD', - ) - } + if (paramsError) { + this.handleSerialError( + innerLoadContext, + index, + paramsError, + 'PARSE_PARAMS', + ) + } + + if (searchError) { + this.handleSerialError( + innerLoadContext, + index, + searchError, + 'VALIDATE_SEARCH', + ) + } + this.setupPendingTimeout(innerLoadContext, matchId, route) + + // if there is no `beforeLoad` option, skip everything, batch update the store, return early + if (!route.options.beforeLoad) { + batch(() => { + pending() + resolve() + }) + return + } + + pending() + + const updateContext = (beforeLoadContext: any) => { + if (beforeLoadContext === undefined) { + resolve() + return + } + if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { + this.handleSerialError( + innerLoadContext, + index, + beforeLoadContext, + 'BEFORE_LOAD', + ) + } + batch(() => { innerLoadContext.updateMatch(matchId, (prev) => ({ ...prev, __beforeLoadContext: beforeLoadContext, context: { - ...parentMatchContext, - ...prev.__routeContext, + ...prev.context, ...beforeLoadContext, }, - abortController, })) - } - - const beforeLoadContext = route.options.beforeLoad?.(beforeLoadFnContext) + resolve() + }) + } + const { search, params, context, cause } = this.getMatch(matchId)! + const preload = this.resolvePreload(innerLoadContext, matchId) + const beforeLoadFnContext: BeforeLoadContextOptions< + any, + any, + any, + any, + any + > = { + search, + abortController, + params, + preload, + context, + location: innerLoadContext.location, + navigate: (opts: any) => + this.navigate({ + ...opts, + _fromLocation: innerLoadContext.location, + }), + buildLocation: this.buildLocation, + cause: preload ? 'preload' : cause, + matches: innerLoadContext.matches, + } + try { + const beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext) if (isPromise(beforeLoadContext)) { - return beforeLoadContext - .then(updateContext) - .catch((err) => { - this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') - }) - .then(resolve) + return beforeLoadContext.then(updateContext).catch((err) => { + this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') + }) } else { updateContext(beforeLoadContext) } } catch (err) { this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') } - - resolve() return } @@ -2709,7 +2715,8 @@ export class RouterCore< } catch (e) { let error = e - await this.potentialPendingMinPromise(matchId) + const pendingPromise = this.potentialPendingMinPromise(matchId) + if (pendingPromise) await pendingPromise this.handleRedirectAndNotFound( innerLoadContext, From 891b55cdc40dafaaedb09d77ca783e32c512bc8c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 15 Aug 2025 23:56:03 +0200 Subject: [PATCH 07/10] lower still --- .../store-updates-during-navigation.test.tsx | 4 +-- packages/router-core/src/router.ts | 36 +++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 2ee24ef378..ca73945c74 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -133,7 +133,7 @@ describe("Store doesn't update *too many* times during navigation", () => { test('sync beforeLoad', async () => { const params = setup({ - beforeLoad: () => {}, + beforeLoad: () => ({ foo: 'bar' }), loader: () => new Promise((resolve) => setTimeout(resolve, 100)), defaultPendingMs: 100, defaultPendingMinMs: 300, @@ -144,6 +144,6 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(14) + expect(updates).toBe(13) }) }) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 0b99185ada..154b9754af 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2371,24 +2371,27 @@ export class RouterCore< ): void | Promise => { const match = this.getMatch(matchId)! const abortController = new AbortController() + const parentMatchId = innerLoadContext.matches[index - 1]?.id + const parentMatch = parentMatchId + ? this.getMatch(parentMatchId)! + : undefined + const parentMatchContext = + parentMatch?.context ?? this.options.context ?? undefined + const context = { + ...parentMatchContext, + ...match.__routeContext, + } - const pending = () => { - const parentMatchId = innerLoadContext.matches[index - 1]?.id - const parentMatch = parentMatchId - ? this.getMatch(parentMatchId)! - : undefined - const parentMatchContext = - parentMatch?.context ?? this.options.context ?? undefined + let isPending = false + const pending = () => { + isPending = true innerLoadContext.updateMatch(matchId, (prev) => ({ ...prev, isFetching: 'beforeLoad', fetchCount: prev.fetchCount + 1, abortController, - context: { - ...parentMatchContext, - ...prev.__routeContext, - }, + context, })) } @@ -2439,11 +2442,12 @@ export class RouterCore< return } - pending() - const updateContext = (beforeLoadContext: any) => { if (beforeLoadContext === undefined) { - resolve() + batch(() => { + if (!isPending) pending() + resolve() + }) return } if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { @@ -2455,6 +2459,7 @@ export class RouterCore< ) } batch(() => { + if (!isPending) pending() innerLoadContext.updateMatch(matchId, (prev) => ({ ...prev, __beforeLoadContext: beforeLoadContext, @@ -2466,7 +2471,7 @@ export class RouterCore< resolve() }) } - const { search, params, context, cause } = this.getMatch(matchId)! + const { search, params, cause } = match const preload = this.resolvePreload(innerLoadContext, matchId) const beforeLoadFnContext: BeforeLoadContextOptions< any, @@ -2493,6 +2498,7 @@ export class RouterCore< try { const beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext) if (isPromise(beforeLoadContext)) { + pending() return beforeLoadContext.then(updateContext).catch((err) => { this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') }) From 4fd8dfc7c2e22b780566744b1b5331970c66abbc Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 16 Aug 2025 01:18:24 +0200 Subject: [PATCH 08/10] how about this? or am i too tired? --- packages/router-core/src/router.ts | 142 +++++++++++++++-------------- 1 file changed, 76 insertions(+), 66 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 154b9754af..1635f1657a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2370,39 +2370,6 @@ export class RouterCore< route: AnyRoute, ): void | Promise => { const match = this.getMatch(matchId)! - const abortController = new AbortController() - const parentMatchId = innerLoadContext.matches[index - 1]?.id - const parentMatch = parentMatchId - ? this.getMatch(parentMatchId)! - : undefined - const parentMatchContext = - parentMatch?.context ?? this.options.context ?? undefined - const context = { - ...parentMatchContext, - ...match.__routeContext, - } - - let isPending = false - - const pending = () => { - isPending = true - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'beforeLoad', - fetchCount: prev.fetchCount + 1, - abortController, - context, - })) - } - - const resolve = () => { - match._nonReactive.beforeLoadPromise?.resolve() - match._nonReactive.beforeLoadPromise = undefined - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: false, - })) - } match._nonReactive.beforeLoadPromise = createControlledPromise() // explicitly capture the previous loadPromise @@ -2411,7 +2378,7 @@ export class RouterCore< prevLoadPromise?.resolve() }) - const { paramsError, searchError } = this.getMatch(matchId)! + const { paramsError, searchError } = match if (paramsError) { this.handleSerialError( @@ -2433,6 +2400,39 @@ export class RouterCore< this.setupPendingTimeout(innerLoadContext, matchId, route) + const abortController = new AbortController() + + const parentMatchId = innerLoadContext.matches[index - 1]?.id + const parentMatch = parentMatchId + ? this.getMatch(parentMatchId)! + : undefined + const parentMatchContext = + parentMatch?.context ?? this.options.context ?? undefined + + const context = { ...parentMatchContext, ...match.__routeContext } + + let isPending = false + const pending = () => { + if (isPending) return + isPending = true + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: 'beforeLoad', + fetchCount: prev.fetchCount + 1, + abortController, + context, + })) + } + + const resolve = () => { + match._nonReactive.beforeLoadPromise?.resolve() + match._nonReactive.beforeLoadPromise = undefined + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: false, + })) + } + // if there is no `beforeLoad` option, skip everything, batch update the store, return early if (!route.options.beforeLoad) { batch(() => { @@ -2442,15 +2442,38 @@ export class RouterCore< return } + const { search, params, cause } = match + const preload = this.resolvePreload(innerLoadContext, matchId) + const beforeLoadFnContext: BeforeLoadContextOptions< + any, + any, + any, + any, + any + > = { + search, + abortController, + params, + preload, + context, + location: innerLoadContext.location, + navigate: (opts: any) => + this.navigate({ ...opts, _fromLocation: innerLoadContext.location }), + buildLocation: this.buildLocation, + cause: preload ? 'preload' : cause, + matches: innerLoadContext.matches, + } + const updateContext = (beforeLoadContext: any) => { if (beforeLoadContext === undefined) { batch(() => { - if (!isPending) pending() + pending() resolve() }) return } if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { + pending() this.handleSerialError( innerLoadContext, index, @@ -2458,8 +2481,9 @@ export class RouterCore< 'BEFORE_LOAD', ) } + batch(() => { - if (!isPending) pending() + pending() innerLoadContext.updateMatch(matchId, (prev) => ({ ...prev, __beforeLoadContext: beforeLoadContext, @@ -2471,43 +2495,24 @@ export class RouterCore< resolve() }) } - const { search, params, cause } = match - const preload = this.resolvePreload(innerLoadContext, matchId) - const beforeLoadFnContext: BeforeLoadContextOptions< - any, - any, - any, - any, - any - > = { - search, - abortController, - params, - preload, - context, - location: innerLoadContext.location, - navigate: (opts: any) => - this.navigate({ - ...opts, - _fromLocation: innerLoadContext.location, - }), - buildLocation: this.buildLocation, - cause: preload ? 'preload' : cause, - matches: innerLoadContext.matches, - } + + let beforeLoadContext try { - const beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext) + beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext) if (isPromise(beforeLoadContext)) { pending() - return beforeLoadContext.then(updateContext).catch((err) => { - this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') - }) - } else { - updateContext(beforeLoadContext) + return beforeLoadContext + .catch((err) => { + this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') + }) + .then(updateContext) } } catch (err) { + pending() this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') } + + updateContext(beforeLoadContext) return } @@ -2927,6 +2932,11 @@ export class RouterCore< for (let i = 0; i < innerLoadContext.matches.length; i++) { const beforeLoad = this.handleBeforeLoad(innerLoadContext, i) if (isPromise(beforeLoad)) await beforeLoad + console.log( + 'before load done', + innerLoadContext.matches[i]!.id, + this.getMatch(innerLoadContext.matches[i]!.id)?.context, + ) } // Execute all loaders in parallel From 15320862e5b53e43e5d5887ab102be33acda32d5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 16 Aug 2025 01:25:14 +0200 Subject: [PATCH 09/10] remove console.log --- packages/router-core/src/router.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 1635f1657a..0008552246 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2932,11 +2932,6 @@ export class RouterCore< for (let i = 0; i < innerLoadContext.matches.length; i++) { const beforeLoad = this.handleBeforeLoad(innerLoadContext, i) if (isPromise(beforeLoad)) await beforeLoad - console.log( - 'before load done', - innerLoadContext.matches[i]!.id, - this.getMatch(innerLoadContext.matches[i]!.id)?.context, - ) } // Execute all loaders in parallel From 8a7f95446be6508e3068da6adbd457d1d4feb8ec Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 16 Aug 2025 10:37:59 +0200 Subject: [PATCH 10/10] more test cases in store-updates --- .../store-updates-during-navigation.test.tsx | 73 +++++++++++++++++-- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index ca73945c74..9a81d91f13 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -1,5 +1,11 @@ import { afterEach, describe, expect, test, vi } from 'vitest' -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' import { Link, Outlet, @@ -7,6 +13,7 @@ import { createRootRoute, createRoute, createRouter, + notFound, redirect, useRouterState, } from '../src' @@ -74,6 +81,8 @@ function setup({ defaultPendingMs, defaultPendingMinMs, defaultPendingComponent: () =>

Loading...

, + defaultNotFoundComponent: () =>

Not Found Title

, + defaultPreload: 'intent', }) render() @@ -81,14 +90,14 @@ function setup({ return { select, router } } -async function run({ select }: ReturnType, opts?: {}) { +async function run({ select }: ReturnType) { // navigate to /posts const link = await waitFor(() => screen.getByRole('link', { name: 'Posts' })) const before = select.mock.calls.length - act(() => link.click()) - const title = await waitFor(() => - screen.getByRole('heading', { name: /Title$/ }), - ) // matches /posts and /other + fireEvent.click(link) + const title = await waitFor( + () => screen.getByRole('heading', { name: /Title$/ }), // matches /posts and /other and not found + ) expect(title).toBeInTheDocument() const after = select.mock.calls.length @@ -146,4 +155,56 @@ describe("Store doesn't update *too many* times during navigation", () => { // Any change that increases this number should be investigated. expect(updates).toBe(13) }) + + test('nothing', async () => { + const params = setup({}) + + const updates = await run(params) + + // This number should be as small as possible to minimize the amount of work + // that needs to be done during a navigation. + // Any change that increases this number should be investigated. + expect(updates).toBeGreaterThanOrEqual(10) // WARN: this is flaky, and sometimes (rarely) is 11 + expect(updates).toBeLessThanOrEqual(11) + }) + + test('not found in beforeLoad', async () => { + const params = setup({ + beforeLoad: () => { + throw notFound() + }, + }) + + const updates = await run(params) + + // This number should be as small as possible to minimize the amount of work + // that needs to be done during a navigation. + // Any change that increases this number should be investigated. + expect(updates).toBe(8) + }) + + test('hover preload, then navigate, w/ async loaders', async () => { + const { select } = setup({ + beforeLoad: () => Promise.resolve({ foo: 'bar' }), + loader: () => Promise.resolve({ hello: 'world' }), + }) + + const link = await waitFor(() => + screen.getByRole('link', { name: 'Posts' }), + ) + const before = select.mock.calls.length + fireEvent.focus(link) + fireEvent.click(link) + const title = await waitFor(() => + screen.getByRole('heading', { name: /Title$/ }), + ) + expect(title).toBeInTheDocument() + const after = select.mock.calls.length + const updates = after - before + + // This number should be as small as possible to minimize the amount of work + // that needs to be done during a navigation. + // Any change that increases this number should be investigated. + expect(updates).toBe(19) + }) })