From fb098c09f65f4f14b8c079a82a2e128b6cc32efe Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 09:53:26 +0200 Subject: [PATCH 01/14] refactor(router-core): Reduce navigation work by making some properties non-reactive --- packages/router-core/src/Matches.ts | 9 +-- packages/router-core/src/router.ts | 101 +++++++++++++--------------- 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 8f99e4f688..1ebe2de2fb 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -138,9 +138,11 @@ export interface RouteMatch< updatedAt: number loadPromise?: ControlledPromise /** @internal */ - beforeLoadPromise?: ControlledPromise - /** @internal */ - loaderPromise?: ControlledPromise + __nonReactive: { + beforeLoadPromise?: ControlledPromise + loaderPromise?: ControlledPromise + pendingTimeout?: ReturnType + } loaderData?: TLoaderData /** @internal */ __routeContext: Record @@ -159,7 +161,6 @@ export interface RouteMatch< globalNotFound?: boolean staticData: StaticDataRouteOption minPendingPromise?: ControlledPromise - pendingTimeout?: ReturnType ssr?: boolean | 'data-only' _dehydrated?: boolean _forcePending?: boolean diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index f6bf304292..8739018ad9 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1285,6 +1285,7 @@ export class RouterCore< error: undefined, paramsError: parseErrors[index], __routeContext: {}, + __nonReactive: {}, __beforeLoadContext: undefined, context: {}, abortController: new AbortController(), @@ -1388,13 +1389,8 @@ export class RouterCore< if (!match) return match.abortController.abort() - this.updateMatch(id, (prev) => { - clearTimeout(prev.pendingTimeout) - return { - ...prev, - pendingTimeout: undefined, - } - }) + match.__nonReactive.pendingTimeout = undefined + clearTimeout(match.__nonReactive.pendingTimeout) } cancelMatches = () => { @@ -2133,8 +2129,10 @@ export class RouterCore< } } - match.beforeLoadPromise?.resolve() - match.loaderPromise?.resolve() + match.__nonReactive.beforeLoadPromise?.resolve() + match.__nonReactive.loaderPromise?.resolve() + match.__nonReactive.beforeLoadPromise = undefined + match.__nonReactive.loaderPromise = undefined updateMatch(match.id, (prev) => ({ ...prev, @@ -2145,8 +2143,6 @@ export class RouterCore< : 'error', isFetching: false, error: err, - beforeLoadPromise: undefined, - loaderPromise: undefined, })) if (!(err as any).routeId) { @@ -2216,7 +2212,8 @@ export class RouterCore< } updateMatch(matchId, (prev) => { - prev.beforeLoadPromise?.resolve() + prev.__nonReactive.beforeLoadPromise?.resolve() + prev.__nonReactive.beforeLoadPromise = undefined prev.loadPromise?.resolve() return { @@ -2226,7 +2223,6 @@ export class RouterCore< isFetching: false, updatedAt: Date.now(), abortController: new AbortController(), - beforeLoadPromise: undefined, } }) } @@ -2321,9 +2317,10 @@ export class RouterCore< let executeBeforeLoad = true const setupPendingTimeout = () => { + const match = this.getMatch(matchId)! if ( shouldPending && - this.getMatch(matchId)!.pendingTimeout === undefined + match.__nonReactive.pendingTimeout === undefined ) { const pendingTimeout = setTimeout(() => { try { @@ -2332,22 +2329,19 @@ export class RouterCore< triggerOnReady() } catch {} }, pendingMs) - updateMatch(matchId, (prev) => ({ - ...prev, - pendingTimeout, - })) + 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) - existingMatch.beforeLoadPromise || - existingMatch.loaderPromise + existingMatch.__nonReactive.beforeLoadPromise || + existingMatch.__nonReactive.loaderPromise ) { setupPendingTimeout() // Wait for the beforeLoad to resolve before we continue - await existingMatch.beforeLoadPromise + await existingMatch.__nonReactive.beforeLoadPromise const match = this.getMatch(matchId)! if (match.status === 'error') { executeBeforeLoad = true @@ -2364,12 +2358,12 @@ export class RouterCore< updateMatch(matchId, (prev) => { // explicitly capture the previous loadPromise const prevLoadPromise = prev.loadPromise + prev.__nonReactive.beforeLoadPromise = createControlledPromise() return { ...prev, loadPromise: createControlledPromise(() => { prevLoadPromise?.resolve() }), - beforeLoadPromise: createControlledPromise(), } }) @@ -2453,11 +2447,11 @@ export class RouterCore< } updateMatch(matchId, (prev) => { - prev.beforeLoadPromise?.resolve() + prev.__nonReactive.beforeLoadPromise?.resolve() + prev.__nonReactive.beforeLoadPromise = undefined return { ...prev, - beforeLoadPromise: undefined, isFetching: false, } }) @@ -2524,7 +2518,7 @@ export class RouterCore< } } // there is a loaderPromise, so we are in the middle of a load - else if (prevMatch.loaderPromise) { + else if (prevMatch.__nonReactive.loaderPromise) { // do not block if we already have stale data we can show // but only if the ongoing load is not a preload since error handling is different for preloads // and we don't want to swallow errors @@ -2535,7 +2529,7 @@ export class RouterCore< ) { return this.getMatch(matchId)! } - await prevMatch.loaderPromise + await prevMatch.__nonReactive.loaderPromise const match = this.getMatch(matchId)! if (match.error) { handleRedirectAndNotFound(match, match.error) @@ -2592,13 +2586,15 @@ export class RouterCore< ? shouldReloadOption(getLoaderContext()) : shouldReloadOption - updateMatch(matchId, (prev) => ({ - ...prev, - loaderPromise: createControlledPromise(), - preload: - !!preload && - !this.state.matches.some((d) => d.id === matchId), - })) + updateMatch(matchId, (prev) => { + prev.__nonReactive.loaderPromise = createControlledPromise() + return ({ + ...prev, + preload: + !!preload && + !this.state.matches.some((d) => d.id === matchId), + }) + }) const runLoader = async () => { try { @@ -2682,11 +2678,13 @@ export class RouterCore< } catch (err) { const head = await executeHead() - updateMatch(matchId, (prev) => ({ - ...prev, - loaderPromise: undefined, - ...head, - })) + updateMatch(matchId, (prev) => { + prev.__nonReactive.loaderPromise = undefined + return ({ + ...prev, + ...head, + }) + }) handleRedirectAndNotFound(this.getMatch(matchId)!, err) } } @@ -2703,14 +2701,10 @@ export class RouterCore< ;(async () => { try { await runLoader() - const { loaderPromise, loadPromise } = - this.getMatch(matchId)! - loaderPromise?.resolve() - loadPromise?.resolve() - updateMatch(matchId, (prev) => ({ - ...prev, - loaderPromise: undefined, - })) + const match = this.getMatch(matchId)! + match.__nonReactive.loaderPromise?.resolve() + match.loadPromise?.resolve() + match.__nonReactive.loaderPromise = undefined } catch (err) { if (isRedirect(err)) { await this.navigate(err.options) @@ -2734,24 +2728,23 @@ export class RouterCore< } } if (!loaderIsRunningAsync) { - const { loaderPromise, loadPromise } = + const match = this.getMatch(matchId)! - loaderPromise?.resolve() - loadPromise?.resolve() + match.__nonReactive.loaderPromise?.resolve() + match.loadPromise?.resolve() } updateMatch(matchId, (prev) => { - clearTimeout(prev.pendingTimeout) + clearTimeout(prev.__nonReactive.pendingTimeout) + prev.__nonReactive.pendingTimeout = undefined + if (!loaderIsRunningAsync) + prev.__nonReactive.loaderPromise = undefined return { ...prev, isFetching: loaderIsRunningAsync ? prev.isFetching : false, - loaderPromise: loaderIsRunningAsync - ? prev.loaderPromise - : undefined, invalid: false, - pendingTimeout: undefined, _dehydrated: undefined, } }) From 20030dba02e921faea1c91e6364141c8bf8a3d8b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 07:58:35 +0000 Subject: [PATCH 02/14] ci: apply automated fixes --- packages/router-core/src/router.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 8739018ad9..b5dba041e2 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2358,7 +2358,8 @@ export class RouterCore< updateMatch(matchId, (prev) => { // explicitly capture the previous loadPromise const prevLoadPromise = prev.loadPromise - prev.__nonReactive.beforeLoadPromise = createControlledPromise() + prev.__nonReactive.beforeLoadPromise = + createControlledPromise() return { ...prev, loadPromise: createControlledPromise(() => { @@ -2587,13 +2588,14 @@ export class RouterCore< : shouldReloadOption updateMatch(matchId, (prev) => { - prev.__nonReactive.loaderPromise = createControlledPromise() - return ({ + prev.__nonReactive.loaderPromise = + createControlledPromise() + return { ...prev, preload: !!preload && !this.state.matches.some((d) => d.id === matchId), - }) + } }) const runLoader = async () => { @@ -2680,10 +2682,10 @@ export class RouterCore< updateMatch(matchId, (prev) => { prev.__nonReactive.loaderPromise = undefined - return ({ + return { ...prev, ...head, - }) + } }) handleRedirectAndNotFound(this.getMatch(matchId)!, err) } @@ -2728,8 +2730,7 @@ export class RouterCore< } } if (!loaderIsRunningAsync) { - const match = - this.getMatch(matchId)! + const match = this.getMatch(matchId)! match.__nonReactive.loaderPromise?.resolve() match.loadPromise?.resolve() } From bda643a4db311f6565fbaefda956ba0d7a9001d0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 10:41:21 +0200 Subject: [PATCH 03/14] more non-reactive props --- packages/react-router/src/Match.tsx | 45 +++++------ packages/router-core/src/Matches.ts | 14 ++-- packages/router-core/src/router.ts | 92 +++++++++++----------- packages/router-core/src/ssr/ssr-client.ts | 22 +++--- packages/solid-router/src/Match.tsx | 47 ++++++----- 5 files changed, 107 insertions(+), 113 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 7ec21746e3..47cd323566 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -227,11 +227,11 @@ export const MatchInner = React.memo(function MatchInnerImpl({ }, [key, route.options.component, router.options.defaultComponent]) if (match._displayPending) { - throw router.getMatch(match.id)?.displayPendingPromise + throw router.getMatch(match.id)?._nonReactive.displayPendingPromise } if (match._forcePending) { - throw router.getMatch(match.id)?.minPendingPromise + throw router.getMatch(match.id)?._nonReactive.minPendingPromise } // see also hydrate() in packages/router-core/src/ssr/ssr-client.ts @@ -239,31 +239,26 @@ export const MatchInner = React.memo(function MatchInnerImpl({ // We're pending, and if we have a minPendingMs, we need to wait for it const pendingMinMs = route.options.pendingMinMs ?? router.options.defaultPendingMinMs + if (pendingMinMs) { + const routerMatch = router.getMatch(match.id) + if (routerMatch && !routerMatch._nonReactive.minPendingPromise) { + // Create a promise that will resolve after the minPendingMs + if (!router.isServer) { + const minPendingPromise = createControlledPromise() + + Promise.resolve().then(() => { + routerMatch._nonReactive.minPendingPromise = minPendingPromise + }) - if (pendingMinMs && !router.getMatch(match.id)?.minPendingPromise) { - // Create a promise that will resolve after the minPendingMs - if (!router.isServer) { - const minPendingPromise = createControlledPromise() - - Promise.resolve().then(() => { - router.updateMatch(match.id, (prev) => ({ - ...prev, - minPendingPromise, - })) - }) - - setTimeout(() => { - minPendingPromise.resolve() - - // We've handled the minPendingPromise, so we can delete it - router.updateMatch(match.id, (prev) => ({ - ...prev, - minPendingPromise: undefined, - })) - }, pendingMinMs) + setTimeout(() => { + minPendingPromise.resolve() + // We've handled the minPendingPromise, so we can delete it + routerMatch._nonReactive.minPendingPromise = undefined + }, pendingMinMs) + } } } - throw router.getMatch(match.id)?.loadPromise + throw router.getMatch(match.id)?._nonReactive.loadPromise } if (match.status === 'notFound') { @@ -280,7 +275,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ // false, // 'Tried to render a redirected route match! This is a weird circumstance, please file an issue!', // ) - throw router.getMatch(match.id)?.loadPromise + throw router.getMatch(match.id)?._nonReactive.loadPromise } if (match.status === 'error') { diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 1ebe2de2fb..a49c97ead7 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -136,12 +136,17 @@ export interface RouteMatch< paramsError: unknown searchError: unknown updatedAt: number - loadPromise?: ControlledPromise - /** @internal */ - __nonReactive: { + _nonReactive: { + /** @internal */ beforeLoadPromise?: ControlledPromise + /** @internal */ loaderPromise?: ControlledPromise + /** @internal */ pendingTimeout?: ReturnType + loadPromise?: ControlledPromise + displayPendingPromise?: Promise + minPendingPromise?: ControlledPromise + dehydrated?: boolean } loaderData?: TLoaderData /** @internal */ @@ -160,11 +165,8 @@ export interface RouteMatch< headers?: Record globalNotFound?: boolean staticData: StaticDataRouteOption - minPendingPromise?: ControlledPromise ssr?: boolean | 'data-only' - _dehydrated?: boolean _forcePending?: boolean - displayPendingPromise?: Promise _displayPending?: boolean } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index b5dba041e2..ff89aa75a9 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1285,7 +1285,9 @@ export class RouterCore< error: undefined, paramsError: parseErrors[index], __routeContext: {}, - __nonReactive: {}, + _nonReactive: { + loadPromise: createControlledPromise(), + }, __beforeLoadContext: undefined, context: {}, abortController: new AbortController(), @@ -1301,7 +1303,6 @@ export class RouterCore< headScripts: undefined, meta: undefined, staticData: route.options.staticData || {}, - loadPromise: createControlledPromise(), fullPath: route.fullPath, } } @@ -1389,8 +1390,8 @@ export class RouterCore< if (!match) return match.abortController.abort() - match.__nonReactive.pendingTimeout = undefined - clearTimeout(match.__nonReactive.pendingTimeout) + match._nonReactive.pendingTimeout = undefined + clearTimeout(match._nonReactive.pendingTimeout) } cancelMatches = () => { @@ -2129,10 +2130,10 @@ export class RouterCore< } } - match.__nonReactive.beforeLoadPromise?.resolve() - match.__nonReactive.loaderPromise?.resolve() - match.__nonReactive.beforeLoadPromise = undefined - match.__nonReactive.loaderPromise = undefined + match._nonReactive.beforeLoadPromise?.resolve() + match._nonReactive.loaderPromise?.resolve() + match._nonReactive.beforeLoadPromise = undefined + match._nonReactive.loaderPromise = undefined updateMatch(match.id, (prev) => ({ ...prev, @@ -2149,7 +2150,7 @@ export class RouterCore< ;(err as any).routeId = match.routeId } - match.loadPromise?.resolve() + match._nonReactive.loadPromise?.resolve() if (isRedirect(err)) { rendered = true @@ -2169,7 +2170,7 @@ export class RouterCore< const shouldSkipLoader = (matchId: string) => { const match = this.getMatch(matchId)! // upon hydration, we skip the loader if the match has been dehydrated on the server - if (!this.isServer && match._dehydrated) { + if (!this.isServer && match._nonReactive.dehydrated) { return true } @@ -2212,9 +2213,9 @@ export class RouterCore< } updateMatch(matchId, (prev) => { - prev.__nonReactive.beforeLoadPromise?.resolve() - prev.__nonReactive.beforeLoadPromise = undefined - prev.loadPromise?.resolve() + prev._nonReactive.beforeLoadPromise?.resolve() + prev._nonReactive.beforeLoadPromise = undefined + prev._nonReactive.loadPromise?.resolve() return { ...prev, @@ -2320,7 +2321,7 @@ export class RouterCore< const match = this.getMatch(matchId)! if ( shouldPending && - match.__nonReactive.pendingTimeout === undefined + match._nonReactive.pendingTimeout === undefined ) { const pendingTimeout = setTimeout(() => { try { @@ -2329,19 +2330,19 @@ export class RouterCore< triggerOnReady() } catch {} }, pendingMs) - match.__nonReactive.pendingTimeout = pendingTimeout + 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) - existingMatch.__nonReactive.beforeLoadPromise || - existingMatch.__nonReactive.loaderPromise + existingMatch._nonReactive.beforeLoadPromise || + existingMatch._nonReactive.loaderPromise ) { setupPendingTimeout() // Wait for the beforeLoad to resolve before we continue - await existingMatch.__nonReactive.beforeLoadPromise + await existingMatch._nonReactive.beforeLoadPromise const match = this.getMatch(matchId)! if (match.status === 'error') { executeBeforeLoad = true @@ -2355,18 +2356,15 @@ export class RouterCore< if (executeBeforeLoad) { // If we are not in the middle of a load OR the previous load failed, start it try { - updateMatch(matchId, (prev) => { - // explicitly capture the previous loadPromise - const prevLoadPromise = prev.loadPromise - prev.__nonReactive.beforeLoadPromise = - createControlledPromise() - return { - ...prev, - loadPromise: createControlledPromise(() => { - prevLoadPromise?.resolve() - }), - } - }) + 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)! @@ -2448,8 +2446,8 @@ export class RouterCore< } updateMatch(matchId, (prev) => { - prev.__nonReactive.beforeLoadPromise?.resolve() - prev.__nonReactive.beforeLoadPromise = undefined + prev._nonReactive.beforeLoadPromise?.resolve() + prev._nonReactive.beforeLoadPromise = undefined return { ...prev, @@ -2502,8 +2500,8 @@ export class RouterCore< const potentialPendingMinPromise = async () => { const latestMatch = this.getMatch(matchId)! - if (latestMatch.minPendingPromise) { - await latestMatch.minPendingPromise + if (latestMatch._nonReactive.minPendingPromise) { + await latestMatch._nonReactive.minPendingPromise } } @@ -2519,7 +2517,7 @@ export class RouterCore< } } // there is a loaderPromise, so we are in the middle of a load - else if (prevMatch.__nonReactive.loaderPromise) { + else if (prevMatch._nonReactive.loaderPromise) { // do not block if we already have stale data we can show // but only if the ongoing load is not a preload since error handling is different for preloads // and we don't want to swallow errors @@ -2530,7 +2528,7 @@ export class RouterCore< ) { return this.getMatch(matchId)! } - await prevMatch.__nonReactive.loaderPromise + await prevMatch._nonReactive.loaderPromise const match = this.getMatch(matchId)! if (match.error) { handleRedirectAndNotFound(match, match.error) @@ -2588,7 +2586,7 @@ export class RouterCore< : shouldReloadOption updateMatch(matchId, (prev) => { - prev.__nonReactive.loaderPromise = + prev._nonReactive.loaderPromise = createControlledPromise() return { ...prev, @@ -2681,7 +2679,7 @@ export class RouterCore< const head = await executeHead() updateMatch(matchId, (prev) => { - prev.__nonReactive.loaderPromise = undefined + prev._nonReactive.loaderPromise = undefined return { ...prev, ...head, @@ -2704,9 +2702,9 @@ export class RouterCore< try { await runLoader() const match = this.getMatch(matchId)! - match.__nonReactive.loaderPromise?.resolve() - match.loadPromise?.resolve() - match.__nonReactive.loaderPromise = undefined + match._nonReactive.loaderPromise?.resolve() + match._nonReactive.loadPromise?.resolve() + match._nonReactive.loaderPromise = undefined } catch (err) { if (isRedirect(err)) { await this.navigate(err.options) @@ -2731,22 +2729,22 @@ export class RouterCore< } if (!loaderIsRunningAsync) { const match = this.getMatch(matchId)! - match.__nonReactive.loaderPromise?.resolve() - match.loadPromise?.resolve() + match._nonReactive.loaderPromise?.resolve() + match._nonReactive.loadPromise?.resolve() } updateMatch(matchId, (prev) => { - clearTimeout(prev.__nonReactive.pendingTimeout) - prev.__nonReactive.pendingTimeout = undefined + clearTimeout(prev._nonReactive.pendingTimeout) + prev._nonReactive.pendingTimeout = undefined if (!loaderIsRunningAsync) - prev.__nonReactive.loaderPromise = undefined + prev._nonReactive.loaderPromise = undefined + prev._nonReactive.dehydrated = undefined return { ...prev, isFetching: loaderIsRunningAsync ? prev.isFetching : false, invalid: false, - _dehydrated: undefined, } }) return this.getMatch(matchId)! diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index a2b446be62..8fc659f2fd 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -80,17 +80,19 @@ export async function hydrate(router: AnyRouter): Promise { route.options.pendingMinMs ?? router.options.defaultPendingMinMs if (pendingMinMs) { const minPendingPromise = createControlledPromise() - match.minPendingPromise = minPendingPromise + match._nonReactive.minPendingPromise = minPendingPromise match._forcePending = true setTimeout(() => { minPendingPromise.resolve() // We've handled the minPendingPromise, so we can delete it - router.updateMatch(match.id, (prev) => ({ - ...prev, - minPendingPromise: undefined, - _forcePending: undefined, - })) + router.updateMatch(match.id, (prev) => { + prev._nonReactive.minPendingPromise = undefined + return { + ...prev, + _forcePending: undefined, + } + }) }, pendingMinMs) } } @@ -110,9 +112,9 @@ export async function hydrate(router: AnyRouter): Promise { Object.assign(match, hydrateMatch(dehydratedMatch)) if (match.ssr === false) { - match._dehydrated = false + match._nonReactive.dehydrated = false } else { - match._dehydrated = true + match._nonReactive.dehydrated = true } if (match.ssr === 'data-only' || match.ssr === false) { @@ -190,7 +192,7 @@ export async function hydrate(router: AnyRouter): Promise { if (!hasSsrFalseMatches && !isSpaMode) { matches.forEach((match) => { // remove the _dehydrate flag since we won't run router.load() which would remove it - match._dehydrated = undefined + match._nonReactive.dehydrated = undefined }) return routeChunkPromise } @@ -213,7 +215,7 @@ export async function hydrate(router: AnyRouter): Promise { setMatchForcePending(match) match._displayPending = true - match.displayPendingPromise = loadPromise + match._nonReactive.displayPendingPromise = loadPromise loadPromise.then(() => { batch(() => { diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index ccc8cf5147..318043f240 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -232,7 +232,8 @@ export const MatchInner = (props: { matchId: string }): any => { {(_) => { const [displayPendingResult] = Solid.createResource( - () => router.getMatch(match().id)?.displayPendingPromise, + () => + router.getMatch(match().id)?._nonReactive.displayPendingPromise, ) return <>{displayPendingResult()} @@ -241,7 +242,7 @@ export const MatchInner = (props: { matchId: string }): any => { {(_) => { const [minPendingResult] = Solid.createResource( - () => router.getMatch(match().id)?.minPendingPromise, + () => router.getMatch(match().id)?._nonReactive.minPendingPromise, ) return <>{minPendingResult()} @@ -252,33 +253,29 @@ export const MatchInner = (props: { matchId: string }): any => { const pendingMinMs = route().options.pendingMinMs ?? router.options.defaultPendingMinMs - if (pendingMinMs && !router.getMatch(match().id)?.minPendingPromise) { - // Create a promise that will resolve after the minPendingMs - if (!router.isServer) { - const minPendingPromise = createControlledPromise() - - Promise.resolve().then(() => { - router.updateMatch(match().id, (prev) => ({ - ...prev, - minPendingPromise, - })) - }) - - setTimeout(() => { - minPendingPromise.resolve() - - // We've handled the minPendingPromise, so we can delete it - router.updateMatch(match().id, (prev) => ({ - ...prev, - minPendingPromise: undefined, - })) - }, pendingMinMs) + if (pendingMinMs) { + const routerMatch = router.getMatch(match().id) + if (routerMatch && !routerMatch._nonReactive.minPendingPromise) { + // Create a promise that will resolve after the minPendingMs + if (!router.isServer) { + const minPendingPromise = createControlledPromise() + + Promise.resolve().then(() => { + routerMatch._nonReactive.minPendingPromise = minPendingPromise + }) + + setTimeout(() => { + minPendingPromise.resolve() + // We've handled the minPendingPromise, so we can delete it + routerMatch._nonReactive.minPendingPromise = undefined + }, pendingMinMs) + } } } const [loaderResult] = Solid.createResource(async () => { await new Promise((r) => setTimeout(r, 0)) - return router.getMatch(match().id)?.loadPromise + return router.getMatch(match().id)?._nonReactive.loadPromise }) return <>{loaderResult()} @@ -297,7 +294,7 @@ export const MatchInner = (props: { matchId: string }): any => { const [loaderResult] = Solid.createResource(async () => { await new Promise((r) => setTimeout(r, 0)) - return router.getMatch(match().id)?.loadPromise + return router.getMatch(match().id)?._nonReactive.loadPromise }) return <>{loaderResult()} From 84c20f5ea4b3ec5f1cf464b71a4e918b16b43521 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 11:44:35 +0200 Subject: [PATCH 04/14] fix _nonReactive.dehydrated setter in hydrate() --- packages/router-core/src/ssr/ssr-client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 8fc659f2fd..d0f989d3e9 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -105,7 +105,8 @@ export async function hydrate(router: AnyRouter): Promise { (d) => d.i === match.id, ) if (!dehydratedMatch) { - Object.assign(match, { dehydrated: false, ssr: false }) + match._nonReactive.dehydrated = false + match.ssr = false return } From aef12fef67aa318cb3df3a0d85de282ee8fb32e9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 11:44:50 +0200 Subject: [PATCH 05/14] minor cleanup --- packages/react-router/src/Match.tsx | 21 ++++++++------- packages/router-core/src/ssr/ssr-client.ts | 31 +++++++++------------- packages/solid-router/src/Match.tsx | 21 ++++++++------- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 47cd323566..06c3c1cbbd 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -6,7 +6,6 @@ import { getLocationChangeInfo, isNotFound, isRedirect, - pick, rootRouteId, } from '@tanstack/router-core' import { CatchBoundary, ErrorComponent } from './CatchBoundary' @@ -37,7 +36,11 @@ export const Match = React.memo(function MatchImpl({ match, `Could not find match for matchId "${matchId}". Please file an issue!`, ) - return pick(match, ['routeId', 'ssr', '_displayPending']) + return { + routeId: match.routeId, + ssr: match.ssr, + _displayPending: match._displayPending, + } }, structuralSharing: true as any, }) @@ -204,13 +207,13 @@ export const MatchInner = React.memo(function MatchInnerImpl({ return { key, routeId, - match: pick(match, [ - 'id', - 'status', - 'error', - '_forcePending', - '_displayPending', - ]), + match: { + id: match.id, + status: match.status, + error: match.error, + _forcePending: match._forcePending, + _displayPending: match._displayPending, + } } }, structuralSharing: true as any, diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index d0f989d3e9..668a42938f 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -20,17 +20,16 @@ export interface TsrSsrGlobal { } function hydrateMatch( + match: AnyRouteMatch, deyhydratedMatch: DehydratedMatch, -): Partial { - return { - id: deyhydratedMatch.i, - __beforeLoadContext: deyhydratedMatch.b, - loaderData: deyhydratedMatch.l, - status: deyhydratedMatch.s, - ssr: deyhydratedMatch.ssr, - updatedAt: deyhydratedMatch.u, - error: deyhydratedMatch.e, - } +): void { + match.id = deyhydratedMatch.i + match.__beforeLoadContext = deyhydratedMatch.b + match.loaderData = deyhydratedMatch.l + match.status = deyhydratedMatch.s + match.ssr = deyhydratedMatch.ssr + match.updatedAt = deyhydratedMatch.u + match.error = deyhydratedMatch.e } export interface DehydratedMatch { i: MakeRouteMatch['id'] @@ -110,13 +109,9 @@ export async function hydrate(router: AnyRouter): Promise { return } - Object.assign(match, hydrateMatch(dehydratedMatch)) + hydrateMatch(match, dehydratedMatch) - if (match.ssr === false) { - match._nonReactive.dehydrated = false - } else { - match._nonReactive.dehydrated = true - } + match._nonReactive.dehydrated = match.ssr !== false if (match.ssr === 'data-only' || match.ssr === false) { if (firstNonSsrMatchIndex === undefined) { @@ -189,10 +184,10 @@ export async function hydrate(router: AnyRouter): Promise { const isSpaMode = matches[matches.length - 1]!.id !== lastMatchId const hasSsrFalseMatches = matches.some((m) => m.ssr === false) - // all matches have data from the server and we are not in SPA mode so we don't need to kick of router.load() + // all matches have data from the server and we are not in SPA mode so we don't need to kick of router.load() if (!hasSsrFalseMatches && !isSpaMode) { matches.forEach((match) => { - // remove the _dehydrate flag since we won't run router.load() which would remove it + // remove the dehydrated flag since we won't run router.load() which would remove it match._nonReactive.dehydrated = undefined }) return routeChunkPromise diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 318043f240..e039a2c66b 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -6,7 +6,6 @@ import { getLocationChangeInfo, isNotFound, isRedirect, - pick, rootRouteId, } from '@tanstack/router-core' import { Dynamic } from 'solid-js/web' @@ -30,7 +29,11 @@ export const Match = (props: { matchId: string }) => { match, `Could not find match for matchId "${props.matchId}". Please file an issue!`, ) - return pick(match, ['routeId', 'ssr', '_displayPending']) + return { + routeId: match.routeId, + ssr: match.ssr, + _displayPending: match._displayPending, + } }, }) @@ -200,13 +203,13 @@ export const MatchInner = (props: { matchId: string }): any => { return { key, routeId, - match: pick(match, [ - 'id', - 'status', - 'error', - '_forcePending', - '_displayPending', - ]), + match: { + id: match.id, + status: match.status, + error: match.error, + _forcePending: match._forcePending, + _displayPending: match._displayPending, + }, } }, }) From 496b3f4368f128c0746b3ed73739093cb9dca016 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 09:45:47 +0000 Subject: [PATCH 06/14] ci: apply automated fixes --- packages/react-router/src/Match.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 06c3c1cbbd..0661c6ae3a 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -213,7 +213,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ error: match.error, _forcePending: match._forcePending, _displayPending: match._displayPending, - } + }, } }, structuralSharing: true as any, From ced2dbd7af84a01bd81314babc1708b2bf8ebffc Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 11:51:03 +0200 Subject: [PATCH 07/14] remove loadPromise from RouteMatchType docs --- docs/router/framework/react/api/router/RouteMatchType.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/router/framework/react/api/router/RouteMatchType.md b/docs/router/framework/react/api/router/RouteMatchType.md index d4559b0bf1..251c2c031e 100644 --- a/docs/router/framework/react/api/router/RouteMatchType.md +++ b/docs/router/framework/react/api/router/RouteMatchType.md @@ -18,7 +18,6 @@ interface RouteMatch { paramsError: unknown searchError: unknown updatedAt: number - loadPromise?: Promise loaderData?: Route['loaderData'] context: Route['allContext'] search: Route['fullSearchSchema'] From a64f926591f012aed5b46111790fa56e6564d06c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 12:07:45 +0200 Subject: [PATCH 08/14] move ssr to _nonReactive --- .../react/api/router/RouteMatchType.md | 1 - packages/react-router/src/Match.tsx | 2 +- packages/router-core/src/Matches.ts | 2 +- packages/router-core/src/router.ts | 34 ++++++++++--------- packages/router-core/src/ssr/ssr-client.ts | 15 ++++---- packages/router-core/src/ssr/ssr-server.ts | 17 ++++------ packages/solid-router/src/Match.tsx | 2 +- 7 files changed, 36 insertions(+), 37 deletions(-) diff --git a/docs/router/framework/react/api/router/RouteMatchType.md b/docs/router/framework/react/api/router/RouteMatchType.md index 251c2c031e..57ccb65dbc 100644 --- a/docs/router/framework/react/api/router/RouteMatchType.md +++ b/docs/router/framework/react/api/router/RouteMatchType.md @@ -24,6 +24,5 @@ interface RouteMatch { fetchedAt: number abortController: AbortController cause: 'enter' | 'stay' - ssr?: boolean | 'data-only' } ``` diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 0661c6ae3a..c3829947a5 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -38,7 +38,7 @@ export const Match = React.memo(function MatchImpl({ ) return { routeId: match.routeId, - ssr: match.ssr, + ssr: match._nonReactive.ssr, _displayPending: match._displayPending, } }, diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index a49c97ead7..38b92530b6 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -147,6 +147,7 @@ export interface RouteMatch< displayPendingPromise?: Promise minPendingPromise?: ControlledPromise dehydrated?: boolean + ssr?: boolean | 'data-only' } loaderData?: TLoaderData /** @internal */ @@ -165,7 +166,6 @@ export interface RouteMatch< headers?: Record globalNotFound?: boolean staticData: StaticDataRouteOption - ssr?: boolean | 'data-only' _forcePending?: boolean _displayPending?: boolean } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index ff89aa75a9..11da31e7fb 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2174,10 +2174,8 @@ export class RouterCore< return true } - if (this.isServer) { - if (match.ssr === false) { - return true - } + if (this.isServer && match._nonReactive.ssr === false) { + return true } return false } @@ -2248,15 +2246,13 @@ export class RouterCore< ssr = matchId === rootRouteId } else { const defaultSsr = this.options.defaultSsr ?? true - if (parentMatch?.ssr === false) { + if (parentMatch?._nonReactive.ssr === false) { ssr = false } else { let tempSsr: boolean | 'data-only' if (route.options.ssr === undefined) { tempSsr = defaultSsr } else if (typeof route.options.ssr === 'function') { - const { search, params } = this.getMatch(matchId)! - function makeMaybe(value: any, error: any) { if (error) { return { status: 'error' as const, error } @@ -2265,8 +2261,14 @@ export class RouterCore< } const ssrFnContext: SsrContextOptions = { - search: makeMaybe(search, existingMatch.searchError), - params: makeMaybe(params, existingMatch.paramsError), + search: makeMaybe( + existingMatch.search, + existingMatch.searchError, + ), + params: makeMaybe( + existingMatch.params, + existingMatch.paramsError, + ), location, matches: matches.map((match) => ({ index: match.index, @@ -2277,7 +2279,7 @@ export class RouterCore< routeId: match.routeId, search: makeMaybe(match.search, match.searchError), params: makeMaybe(match.params, match.paramsError), - ssr: match.ssr, + ssr: match._nonReactive.ssr, })), } tempSsr = @@ -2286,17 +2288,17 @@ export class RouterCore< tempSsr = route.options.ssr } - if (tempSsr === true && parentMatch?.ssr === 'data-only') { + if ( + tempSsr === true && + parentMatch?._nonReactive.ssr === 'data-only' + ) { ssr = 'data-only' } else { ssr = tempSsr } } } - updateMatch(matchId, (prev) => ({ - ...prev, - ssr, - })) + existingMatch._nonReactive.ssr = ssr } if (shouldSkipLoader(matchId)) { @@ -2609,7 +2611,7 @@ export class RouterCore< if ( !this.isServer || (this.isServer && - this.getMatch(matchId)!.ssr === true) + this.getMatch(matchId)!._nonReactive.ssr === true) ) { this.loadRouteChunk(route) } diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 668a42938f..f4f5d64dc3 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -27,7 +27,7 @@ function hydrateMatch( match.__beforeLoadContext = deyhydratedMatch.b match.loaderData = deyhydratedMatch.l match.status = deyhydratedMatch.s - match.ssr = deyhydratedMatch.ssr + match._nonReactive.ssr = deyhydratedMatch.ssr match.updatedAt = deyhydratedMatch.u match.error = deyhydratedMatch.e } @@ -38,7 +38,7 @@ export interface DehydratedMatch { e?: MakeRouteMatch['error'] u: MakeRouteMatch['updatedAt'] s: MakeRouteMatch['status'] - ssr?: MakeRouteMatch['ssr'] + ssr?: MakeRouteMatch['_nonReactive']['ssr'] } export interface DehydratedRouter { @@ -105,15 +105,18 @@ export async function hydrate(router: AnyRouter): Promise { ) if (!dehydratedMatch) { match._nonReactive.dehydrated = false - match.ssr = false + match._nonReactive.ssr = false return } hydrateMatch(match, dehydratedMatch) - match._nonReactive.dehydrated = match.ssr !== false + match._nonReactive.dehydrated = match._nonReactive.ssr !== false - if (match.ssr === 'data-only' || match.ssr === false) { + if ( + match._nonReactive.ssr === 'data-only' || + match._nonReactive.ssr === false + ) { if (firstNonSsrMatchIndex === undefined) { firstNonSsrMatchIndex = match.index setMatchForcePending(match) @@ -183,7 +186,7 @@ export async function hydrate(router: AnyRouter): Promise { ) const isSpaMode = matches[matches.length - 1]!.id !== lastMatchId - const hasSsrFalseMatches = matches.some((m) => m.ssr === false) + const hasSsrFalseMatches = matches.some((m) => m._nonReactive.ssr === false) // all matches have data from the server and we are not in SPA mode so we don't need to kick of router.load() if (!hasSsrFalseMatches && !isSpaMode) { matches.forEach((match) => { diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 186cb2c4e2..5ce5242ef7 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -32,18 +32,13 @@ export function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch { s: match.status, } - const properties = [ - ['__beforeLoadContext', 'b'], - ['loaderData', 'l'], - ['error', 'e'], - ['ssr', 'ssr'], - ] as const + if (match.__beforeLoadContext !== undefined) + dehydratedMatch.b = match.__beforeLoadContext + if (match.loaderData !== undefined) dehydratedMatch.l = match.loaderData + if (match.error !== undefined) dehydratedMatch.e = match.error + if (match._nonReactive.ssr !== undefined) + dehydratedMatch.ssr = match._nonReactive.ssr - for (const [key, shorthand] of properties) { - if (match[key] !== undefined) { - dehydratedMatch[shorthand] = match[key] - } - } return dehydratedMatch } diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index e039a2c66b..e03c32571f 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -31,7 +31,7 @@ export const Match = (props: { matchId: string }) => { ) return { routeId: match.routeId, - ssr: match.ssr, + ssr: match._nonReactive.ssr, _displayPending: match._displayPending, } }, From bbc54687d57b50e5ecb537c92391865cbdfa1099 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 12:21:48 +0200 Subject: [PATCH 09/14] Revert "move ssr to _nonReactive" This reverts commit a64f926591f012aed5b46111790fa56e6564d06c. --- .../react/api/router/RouteMatchType.md | 1 + packages/react-router/src/Match.tsx | 2 +- packages/router-core/src/Matches.ts | 2 +- packages/router-core/src/router.ts | 34 +++++++++---------- packages/router-core/src/ssr/ssr-client.ts | 15 ++++---- packages/router-core/src/ssr/ssr-server.ts | 17 ++++++---- packages/solid-router/src/Match.tsx | 2 +- 7 files changed, 37 insertions(+), 36 deletions(-) diff --git a/docs/router/framework/react/api/router/RouteMatchType.md b/docs/router/framework/react/api/router/RouteMatchType.md index 57ccb65dbc..251c2c031e 100644 --- a/docs/router/framework/react/api/router/RouteMatchType.md +++ b/docs/router/framework/react/api/router/RouteMatchType.md @@ -24,5 +24,6 @@ interface RouteMatch { fetchedAt: number abortController: AbortController cause: 'enter' | 'stay' + ssr?: boolean | 'data-only' } ``` diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index c3829947a5..0661c6ae3a 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -38,7 +38,7 @@ export const Match = React.memo(function MatchImpl({ ) return { routeId: match.routeId, - ssr: match._nonReactive.ssr, + ssr: match.ssr, _displayPending: match._displayPending, } }, diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 38b92530b6..a49c97ead7 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -147,7 +147,6 @@ export interface RouteMatch< displayPendingPromise?: Promise minPendingPromise?: ControlledPromise dehydrated?: boolean - ssr?: boolean | 'data-only' } loaderData?: TLoaderData /** @internal */ @@ -166,6 +165,7 @@ export interface RouteMatch< headers?: Record globalNotFound?: boolean staticData: StaticDataRouteOption + ssr?: boolean | 'data-only' _forcePending?: boolean _displayPending?: boolean } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 11da31e7fb..ff89aa75a9 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2174,8 +2174,10 @@ export class RouterCore< return true } - if (this.isServer && match._nonReactive.ssr === false) { - return true + if (this.isServer) { + if (match.ssr === false) { + return true + } } return false } @@ -2246,13 +2248,15 @@ export class RouterCore< ssr = matchId === rootRouteId } else { const defaultSsr = this.options.defaultSsr ?? true - if (parentMatch?._nonReactive.ssr === false) { + if (parentMatch?.ssr === false) { ssr = false } else { let tempSsr: boolean | 'data-only' if (route.options.ssr === undefined) { tempSsr = defaultSsr } else if (typeof route.options.ssr === 'function') { + const { search, params } = this.getMatch(matchId)! + function makeMaybe(value: any, error: any) { if (error) { return { status: 'error' as const, error } @@ -2261,14 +2265,8 @@ export class RouterCore< } const ssrFnContext: SsrContextOptions = { - search: makeMaybe( - existingMatch.search, - existingMatch.searchError, - ), - params: makeMaybe( - existingMatch.params, - existingMatch.paramsError, - ), + search: makeMaybe(search, existingMatch.searchError), + params: makeMaybe(params, existingMatch.paramsError), location, matches: matches.map((match) => ({ index: match.index, @@ -2279,7 +2277,7 @@ export class RouterCore< routeId: match.routeId, search: makeMaybe(match.search, match.searchError), params: makeMaybe(match.params, match.paramsError), - ssr: match._nonReactive.ssr, + ssr: match.ssr, })), } tempSsr = @@ -2288,17 +2286,17 @@ export class RouterCore< tempSsr = route.options.ssr } - if ( - tempSsr === true && - parentMatch?._nonReactive.ssr === 'data-only' - ) { + if (tempSsr === true && parentMatch?.ssr === 'data-only') { ssr = 'data-only' } else { ssr = tempSsr } } } - existingMatch._nonReactive.ssr = ssr + updateMatch(matchId, (prev) => ({ + ...prev, + ssr, + })) } if (shouldSkipLoader(matchId)) { @@ -2611,7 +2609,7 @@ export class RouterCore< if ( !this.isServer || (this.isServer && - this.getMatch(matchId)!._nonReactive.ssr === true) + this.getMatch(matchId)!.ssr === true) ) { this.loadRouteChunk(route) } diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index f4f5d64dc3..668a42938f 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -27,7 +27,7 @@ function hydrateMatch( match.__beforeLoadContext = deyhydratedMatch.b match.loaderData = deyhydratedMatch.l match.status = deyhydratedMatch.s - match._nonReactive.ssr = deyhydratedMatch.ssr + match.ssr = deyhydratedMatch.ssr match.updatedAt = deyhydratedMatch.u match.error = deyhydratedMatch.e } @@ -38,7 +38,7 @@ export interface DehydratedMatch { e?: MakeRouteMatch['error'] u: MakeRouteMatch['updatedAt'] s: MakeRouteMatch['status'] - ssr?: MakeRouteMatch['_nonReactive']['ssr'] + ssr?: MakeRouteMatch['ssr'] } export interface DehydratedRouter { @@ -105,18 +105,15 @@ export async function hydrate(router: AnyRouter): Promise { ) if (!dehydratedMatch) { match._nonReactive.dehydrated = false - match._nonReactive.ssr = false + match.ssr = false return } hydrateMatch(match, dehydratedMatch) - match._nonReactive.dehydrated = match._nonReactive.ssr !== false + match._nonReactive.dehydrated = match.ssr !== false - if ( - match._nonReactive.ssr === 'data-only' || - match._nonReactive.ssr === false - ) { + if (match.ssr === 'data-only' || match.ssr === false) { if (firstNonSsrMatchIndex === undefined) { firstNonSsrMatchIndex = match.index setMatchForcePending(match) @@ -186,7 +183,7 @@ export async function hydrate(router: AnyRouter): Promise { ) const isSpaMode = matches[matches.length - 1]!.id !== lastMatchId - const hasSsrFalseMatches = matches.some((m) => m._nonReactive.ssr === false) + const hasSsrFalseMatches = matches.some((m) => m.ssr === false) // all matches have data from the server and we are not in SPA mode so we don't need to kick of router.load() if (!hasSsrFalseMatches && !isSpaMode) { matches.forEach((match) => { diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 5ce5242ef7..186cb2c4e2 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -32,13 +32,18 @@ export function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch { s: match.status, } - if (match.__beforeLoadContext !== undefined) - dehydratedMatch.b = match.__beforeLoadContext - if (match.loaderData !== undefined) dehydratedMatch.l = match.loaderData - if (match.error !== undefined) dehydratedMatch.e = match.error - if (match._nonReactive.ssr !== undefined) - dehydratedMatch.ssr = match._nonReactive.ssr + const properties = [ + ['__beforeLoadContext', 'b'], + ['loaderData', 'l'], + ['error', 'e'], + ['ssr', 'ssr'], + ] as const + for (const [key, shorthand] of properties) { + if (match[key] !== undefined) { + dehydratedMatch[shorthand] = match[key] + } + } return dehydratedMatch } diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index e03c32571f..e039a2c66b 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -31,7 +31,7 @@ export const Match = (props: { matchId: string }) => { ) return { routeId: match.routeId, - ssr: match._nonReactive.ssr, + ssr: match.ssr, _displayPending: match._displayPending, } }, From ef2777b31e8964617eb8bf5c771d7d6b41e0d0a3 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 12:40:48 +0200 Subject: [PATCH 10/14] ssr is not reactive, but still public --- packages/router-core/src/Matches.ts | 1 + packages/router-core/src/router.ts | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index a49c97ead7..ea6885d6ef 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -165,6 +165,7 @@ export interface RouteMatch< headers?: Record globalNotFound?: boolean staticData: StaticDataRouteOption + /** This attribute is not reactive */ ssr?: boolean | 'data-only' _forcePending?: boolean _displayPending?: boolean diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index ff89aa75a9..a49531853c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2293,10 +2293,7 @@ export class RouterCore< } } } - updateMatch(matchId, (prev) => ({ - ...prev, - ssr, - })) + existingMatch.ssr = ssr } if (shouldSkipLoader(matchId)) { From 80a060efc418483d654a364c0f031f97f2acc70d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 13:26:00 +0200 Subject: [PATCH 11/14] add unit test --- .../store-updates-during-navigation.test.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/react-router/tests/store-updates-during-navigation.test.tsx diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx new file mode 100644 index 0000000000..078c18f343 --- /dev/null +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useRouterState, +} from '../src' +import type { RouteComponent } from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +function setup({ + RootComponent, +}: { + RootComponent: RouteComponent +}) { + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

IndexTitle

+ Posts + + ), + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + beforeLoad: () => new Promise((resolve) => setTimeout(resolve, 10)), + loader: () => new Promise((resolve) => setTimeout(resolve, 10)), + component: () =>

PostsTitle

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + }) + + return render() +} + +describe('Store updates during navigation', () => { + it('isn\'t called *too many* times', async () => { + const select = vi.fn() + + setup({ + RootComponent: () => { + useRouterState({ select }) + return + }, + }) + + // 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 + + expect(after - before).toBe(19) + }) +}) From a46108024a0709eadb901342230cdc9970a74955 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:26:53 +0000 Subject: [PATCH 12/14] ci: apply automated fixes --- .../tests/store-updates-during-navigation.test.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 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 078c18f343..6769e49fcb 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -16,11 +16,7 @@ afterEach(() => { cleanup() }) -function setup({ - RootComponent, -}: { - RootComponent: RouteComponent -}) { +function setup({ RootComponent }: { RootComponent: RouteComponent }) { const rootRoute = createRootRoute({ component: RootComponent, }) @@ -51,7 +47,7 @@ function setup({ } describe('Store updates during navigation', () => { - it('isn\'t called *too many* times', async () => { + it("isn't called *too many* times", async () => { const select = vi.fn() setup({ @@ -62,7 +58,9 @@ describe('Store updates during navigation', () => { }) // navigate to /posts - const link = await waitFor(() => screen.getByRole('link', { name: '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')) From 8367122906c5cf3af2522c3263f0768e641b05e8 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 13:32:26 +0200 Subject: [PATCH 13/14] update test setup --- .../tests/store-updates-during-navigation.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 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 6769e49fcb..c5d33b173a 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -34,13 +34,16 @@ function setup({ RootComponent }: { RootComponent: RouteComponent }) { const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/posts', - beforeLoad: () => new Promise((resolve) => setTimeout(resolve, 10)), - loader: () => new Promise((resolve) => setTimeout(resolve, 10)), + beforeLoad: () => new Promise((resolve) => setTimeout(resolve, 100)), + loader: () => new Promise((resolve) => setTimeout(resolve, 100)), component: () =>

PostsTitle

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

Loading...

, }) return render() From fbdc46a77bea67a21d6171fed878133d32d7835d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 11 Aug 2025 13:34:52 +0200 Subject: [PATCH 14/14] add code comment --- .../tests/store-updates-during-navigation.test.tsx | 3 +++ 1 file changed, 3 insertions(+) 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 c5d33b173a..4c18a56815 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -70,6 +70,9 @@ describe('Store updates during navigation', () => { 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(19) }) })