Skip to content

refactor(router-core): skip beforeLoad and related store updates if options is not defined #4928

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Aug 16, 2025
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
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,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
notFound,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Ensure the notFound export is available from the public entry

If notFound is a new public API, verify it’s exported from the package entry and re-exported where this test imports it.

Run this to confirm the export and its surface:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Looking for notFound exports (direct or re-export):"
rg -nP -C2 '(export\s+function\s+notFound\b)|(export\s+const\s+notFound\b)|(export\s*{\s*notFound\s*})' --type ts --glob '!**/node_modules/**'

echo
echo "Verifying createRouter options include defaultNotFoundComponent & defaultPreload:"
rg -nP -C3 '\b(defaultNotFoundComponent|defaultPreload)\b' --type ts --glob '!**/node_modules/**'

Length of output: 82102


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Import section from the test file ==="
sed -n '1,50p' packages/react-router/tests/store-updates-during-navigation.test.tsx

echo
echo "=== Re-exports of notFound in react-router public entry (index.ts) ==="
rg -nP 'export\s+.*notFound' packages/react-router/src/index.ts || echo "No direct re-exports found in index.ts"

Length of output: 1446


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Files in packages/react-router/src ==="
ls -1 packages/react-router/src

echo
echo "=== Looking for index.* files ==="
find packages/react-router/src -maxdepth 1 -type f -name "index.*" || echo "No index.* files found"

echo
echo "=== Showing contents of any index.* file ==="
for f in packages/react-router/src/index.*; do
  echo "---- $f ----"
  sed -n '1,200p' "$f"
done 2>/dev/null || true

Length of output: 5057


Missing public export of notFound

The test in
packages/react-router/tests/store-updates-during-navigation.test.tsx
imports notFound, but it isn’t re-exported in the public entry at
packages/react-router/src/index.tsx. Add it to the export list:

• File: packages/react-router/src/index.tsx

-export {
-  defer,
-  TSR_DEFERRED_PROMISE,
-  isMatch,
-  joinPaths,
-  cleanPath,
-  trimPathLeft,
-  trimPathRight,
-  trimPath,
-  resolvePath,
-  parsePathname,
-  interpolatePath,
-  matchPathname,
-  removeBasepath,
-  matchByPath,
-  encode,
-  decode,
-  rootRouteId,
-  defaultSerializeError,
-  defaultParseSearch,
-  defaultStringifySearch,
-  parseSearchWith,
-  stringifySearchWith,
-  escapeJSON, // SSR
-  pick,
-  functionalUpdate,
-  replaceEqualDeep,
-  isPlainObject,
-  isPlainArray,
-  deepEqual,
-  shallow,
-  createControlledPromise,
-  retainSearchParams,
-  stripSearchParams,
-} from '@tanstack/router-core'
+export {
+  defer,
+  TSR_DEFERRED_PROMISE,
+  isMatch,
+  joinPaths,
+  cleanPath,
+  trimPathLeft,
+  trimPathRight,
+  trimPath,
+  resolvePath,
+  parsePathname,
+  interpolatePath,
+  matchPathname,
+  removeBasepath,
+  matchByPath,
+  encode,
+  decode,
+  rootRouteId,
+  defaultSerializeError,
+  defaultParseSearch,
+  defaultStringifySearch,
+  parseSearchWith,
+  stringifySearchWith,
+  escapeJSON, // SSR
+  pick,
+  functionalUpdate,
+  replaceEqualDeep,
+  isPlainObject,
+  isPlainArray,
+  deepEqual,
+  shallow,
+  createControlledPromise,
+  retainSearchParams,
+  stripSearchParams,
+  notFound,
+} from '@tanstack/router-core'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
notFound,
export {
defer,
TSR_DEFERRED_PROMISE,
isMatch,
joinPaths,
cleanPath,
trimPathLeft,
trimPathRight,
trimPath,
resolvePath,
parsePathname,
interpolatePath,
matchPathname,
removeBasepath,
matchByPath,
encode,
decode,
rootRouteId,
defaultSerializeError,
defaultParseSearch,
defaultStringifySearch,
parseSearchWith,
stringifySearchWith,
escapeJSON, // SSR
pick,
functionalUpdate,
replaceEqualDeep,
isPlainObject,
isPlainArray,
deepEqual,
shallow,
createControlledPromise,
retainSearchParams,
stripSearchParams,
notFound,
} from '@tanstack/router-core'
🤖 Prompt for AI Agents
packages/react-router/src/index.tsx (export list): The test imports `notFound`
but it is not exported from the package entry; add a named export for `notFound`
to the public export list in packages/react-router/src/index.tsx so it can be
imported by tests and consumers. Ensure the exported identifier matches the
internal symbol (or re-export from its source module), update the export list
accordingly, and run the test suite to verify the import succeeds.

redirect,
useRouterState,
} from '../src'
Expand Down Expand Up @@ -74,21 +81,23 @@ function setup({
defaultPendingMs,
defaultPendingMinMs,
defaultPendingComponent: () => <p>Loading...</p>,
defaultNotFoundComponent: () => <h1>Not Found Title</h1>,
defaultPreload: 'intent',
})

render(<RouterProvider router={router} />)

return { select, router }
}

async function run({ select }: ReturnType<typeof setup>, opts?: {}) {
async function run({ select }: ReturnType<typeof setup>) {
// 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

Expand All @@ -110,7 +119,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 () => {
Expand All @@ -125,9 +134,77 @@ describe("Store doesn't update *too many* times during navigation", () => {
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(6)
})

test('sync beforeLoad', async () => {
const params = setup({
beforeLoad: () => ({ foo: 'bar' }),
loader: () => new Promise<void>((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(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)
})
})
204 changes: 111 additions & 93 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -2371,131 +2369,150 @@ export class RouterCore<
index: number,
route: AnyRoute,
): void | Promise<void> => {
const resolve = () => {
innerLoadContext.updateMatch(matchId, (prev) => {
prev._nonReactive.beforeLoadPromise?.resolve()
prev._nonReactive.beforeLoadPromise = undefined
const match = this.getMatch(matchId)!

return {
...prev,
isFetching: false,
}
})
}
match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
// explicitly capture the previous loadPromise
const prevLoadPromise = match._nonReactive.loadPromise
match._nonReactive.loadPromise = createControlledPromise<void>(() => {
prevLoadPromise?.resolve()
})

try {
const match = this.getMatch(matchId)!
match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
// explicitly capture the previous loadPromise
const prevLoadPromise = match._nonReactive.loadPromise
match._nonReactive.loadPromise = createControlledPromise<void>(() => {
prevLoadPromise?.resolve()
})
const { paramsError, searchError } = match

const { paramsError, searchError } = this.getMatch(matchId)!
if (paramsError) {
this.handleSerialError(
innerLoadContext,
index,
paramsError,
'PARSE_PARAMS',
)
}

if (paramsError) {
this.handleSerialError(
innerLoadContext,
index,
paramsError,
'PARSE_PARAMS',
)
}
if (searchError) {
this.handleSerialError(
innerLoadContext,
index,
searchError,
'VALIDATE_SEARCH',
)
}

if (searchError) {
this.handleSerialError(
innerLoadContext,
index,
searchError,
'VALIDATE_SEARCH',
)
}
this.setupPendingTimeout(innerLoadContext, matchId, route)

this.setupPendingTimeout(innerLoadContext, matchId, route)
const abortController = new AbortController()

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 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: {
...parentMatchContext,
...prev.__routeContext,
},
context,
}))
}

const resolve = () => {
match._nonReactive.beforeLoadPromise?.resolve()
match._nonReactive.beforeLoadPromise = undefined
innerLoadContext.updateMatch(matchId, (prev) => ({
...prev,
isFetching: false,
}))
}

const { search, params, context, cause } = this.getMatch(matchId)!
// if there is no `beforeLoad` option, skip everything, batch update the store, return early
if (!route.options.beforeLoad) {
batch(() => {
pending()
resolve()
})
return
}

const preload = this.resolvePreload(innerLoadContext, matchId)
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 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(() => {
pending()
resolve()
})
return
}
if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
pending()
this.handleSerialError(
innerLoadContext,
index,
beforeLoadContext,
'BEFORE_LOAD',
)
}

const updateContext = (beforeLoadContext: any) => {
if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
this.handleSerialError(
innerLoadContext,
index,
beforeLoadContext,
'BEFORE_LOAD',
)
}

batch(() => {
pending()
innerLoadContext.updateMatch(matchId, (prev) => ({
...prev,
__beforeLoadContext: beforeLoadContext,
context: {
...parentMatchContext,
...prev.__routeContext,
...prev.context,
...beforeLoadContext,
},
abortController,
}))
}
resolve()
})
}

const beforeLoadContext = route.options.beforeLoad?.(beforeLoadFnContext)
let beforeLoadContext
try {
beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext)
if (isPromise(beforeLoadContext)) {
pending()
return beforeLoadContext
.then(updateContext)
.catch((err) => {
this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
})
.then(resolve)
} else {
updateContext(beforeLoadContext)
.then(updateContext)
}
} catch (err) {
pending()
this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
}

resolve()
updateContext(beforeLoadContext)
return
}

Expand Down Expand Up @@ -2709,7 +2726,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,
Expand Down