Skip to content

refactor(router-core): head, scripts, headers are executed in parallel and can skip store update #4925

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 16 commits into from
Aug 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ afterEach(() => {
cleanup()
})

async function setupAndRun({
function setup({
beforeLoad,
loader,
head,
Expand Down Expand Up @@ -78,6 +78,10 @@ async function setupAndRun({

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

return { select, router }
}

async function run({ select }: ReturnType<typeof setup>, opts?: {}) {
// navigate to /posts
const link = await waitFor(() => screen.getByRole('link', { name: 'Posts' }))
const before = select.mock.calls.length
Expand All @@ -93,30 +97,37 @@ async function setupAndRun({

describe("Store doesn't update *too many* times during navigation", () => {
test('async loader, async beforeLoad, pendingMs', async () => {
const updates = await setupAndRun({
const params = setup({
beforeLoad: () =>
new Promise<void>((resolve) => setTimeout(resolve, 100)),
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(19)
})

test('redirection', async () => {
const updates = await setupAndRun({
beforeLoad: () => {
test('redirection in preload', async () => {
const { select, router } = setup({
loader: () => {
throw redirect({ to: '/other' })
},
})

const before = select.mock.calls.length
await router.preloadRoute({ to: '/posts' })
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(26)
expect(updates).toBe(8)
})
})
158 changes: 93 additions & 65 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1393,8 +1393,8 @@ export class RouterCore<
if (!match) return

match.abortController.abort()
match._nonReactive.pendingTimeout = undefined
clearTimeout(match._nonReactive.pendingTimeout)
match._nonReactive.pendingTimeout = undefined
}

cancelMatches = () => {
Expand Down Expand Up @@ -2121,7 +2121,10 @@ export class RouterCore<
triggerOnReady()
}

const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
const handleRedirectAndNotFound = (
match: AnyRouteMatch | undefined,
err: any,
) => {
if (isRedirect(err) || isNotFound(err)) {
if (isRedirect(err)) {
if (err.redirectHandled) {
Expand All @@ -2131,27 +2134,30 @@ export class RouterCore<
}
}

match._nonReactive.beforeLoadPromise?.resolve()
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.beforeLoadPromise = undefined
match._nonReactive.loaderPromise = undefined

updateMatch(match.id, (prev) => ({
...prev,
status: isRedirect(err)
? 'redirected'
: isNotFound(err)
? 'notFound'
: 'error',
isFetching: false,
error: err,
}))
// in case of a redirecting match during preload, the match does not exist
if (match) {
match._nonReactive.beforeLoadPromise?.resolve()
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.beforeLoadPromise = undefined
match._nonReactive.loaderPromise = undefined

updateMatch(match.id, (prev) => ({
...prev,
status: isRedirect(err)
? 'redirected'
: isNotFound(err)
? 'notFound'
: 'error',
isFetching: false,
error: err,
}))

if (!(err as any).routeId) {
;(err as any).routeId = match.routeId
}
if (!(err as any).routeId) {
;(err as any).routeId = match.routeId
}

match._nonReactive.loadPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
}

if (isRedirect(err)) {
rendered = true
Expand Down Expand Up @@ -2204,13 +2210,13 @@ export class RouterCore<

err.routerCode = routerCode
firstBadMatchIndex = firstBadMatchIndex ?? index
handleRedirectAndNotFound(this.getMatch(matchId)!, err)
handleRedirectAndNotFound(this.getMatch(matchId), err)

try {
route.options.onError?.(err)
} catch (errorHandlerErr) {
err = errorHandlerErr
handleRedirectAndNotFound(this.getMatch(matchId)!, err)
handleRedirectAndNotFound(this.getMatch(matchId), err)
}

updateMatch(matchId, (prev) => {
Expand Down Expand Up @@ -2465,35 +2471,45 @@ export class RouterCore<
let loaderIsRunningAsync = false
const route = this.looseRoutesById[routeId]!

const executeHead = async () => {
const executeHead = () => {
const match = this.getMatch(matchId)
// in case of a redirecting match during preload, the match does not exist
if (!match) {
return
}
if (
!route.options.head &&
!route.options.scripts &&
!route.options.headers
) {
return
}
const assetContext = {
matches,
match,
params: match.params,
loaderData: match.loaderData,
}
const headFnContent =
await route.options.head?.(assetContext)
const meta = headFnContent?.meta
const links = headFnContent?.links
const headScripts = headFnContent?.scripts
const styles = headFnContent?.styles

const scripts = await route.options.scripts?.(assetContext)
const headers = await route.options.headers?.(assetContext)
return {
meta,
links,
headScripts,
headers,
scripts,
styles,
}

return Promise.all([
Copy link
Contributor

Choose a reason for hiding this comment

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

why no await Promise.all()?
personally, i like async/await syntax more than "then".
unless there is a technical reason?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we use await here, the function needs to be async, which means that it will always return a Promise, even if we returned early. I'd like to be able to do the if('then' in foo) thing where this function is called, but we can't do that if the function is async.

route.options.head?.(assetContext),
route.options.scripts?.(assetContext),
route.options.headers?.(assetContext),
]).then(([headFnContent, scripts, headers]) => {
const meta = headFnContent?.meta
const links = headFnContent?.links
const headScripts = headFnContent?.scripts
const styles = headFnContent?.styles

return {
meta,
links,
headScripts,
headers,
scripts,
styles,
}
})
}

const potentialPendingMinPromise = async () => {
Expand All @@ -2506,11 +2522,14 @@ export class RouterCore<
const prevMatch = this.getMatch(matchId)!
if (shouldSkipLoader(matchId)) {
if (this.isServer) {
const head = await executeHead()
updateMatch(matchId, (prev) => ({
...prev,
...head,
}))
const headResult = executeHead()
if (headResult) {
const head = await headResult
updateMatch(matchId, (prev) => ({
...prev,
...head,
}))
}
return this.getMatch(matchId)!
}
}
Expand Down Expand Up @@ -2622,7 +2641,7 @@ export class RouterCore<
await route.options.loader?.(getLoaderContext())

handleRedirectAndNotFound(
this.getMatch(matchId)!,
this.getMatch(matchId),
loaderData,
)
updateMatch(matchId, (prev) => ({
Expand All @@ -2634,7 +2653,8 @@ export class RouterCore<
// so we need to wait for it to resolve before
// we can use the options
await route._lazyPromise
const head = await executeHead()
const headResult = executeHead()
const head = headResult ? await headResult : undefined
await potentialPendingMinPromise()

// Last but not least, wait for the the components
Expand All @@ -2653,18 +2673,19 @@ export class RouterCore<

await potentialPendingMinPromise()

handleRedirectAndNotFound(this.getMatch(matchId)!, e)
handleRedirectAndNotFound(this.getMatch(matchId), e)

try {
route.options.onError?.(e)
} catch (onErrorError) {
error = onErrorError
handleRedirectAndNotFound(
this.getMatch(matchId)!,
this.getMatch(matchId),
onErrorError,
)
}
const head = await executeHead()
const headResult = executeHead()
const head = headResult ? await headResult : undefined
updateMatch(matchId, (prev) => ({
...prev,
error,
Expand All @@ -2674,16 +2695,20 @@ export class RouterCore<
}))
}
} catch (err) {
const head = await executeHead()

updateMatch(matchId, (prev) => {
prev._nonReactive.loaderPromise = undefined
return {
...prev,
...head,
const match = this.getMatch(matchId)
// in case of a redirecting match during preload, the match does not exist
if (match) {
const headResult = executeHead()
if (headResult) {
const head = await headResult
updateMatch(matchId, (prev) => ({
...prev,
...head,
}))
}
})
handleRedirectAndNotFound(this.getMatch(matchId)!, err)
match._nonReactive.loaderPromise = undefined
}
handleRedirectAndNotFound(match, err)
}
}

Expand Down Expand Up @@ -2718,11 +2743,14 @@ export class RouterCore<
// if the loader did not run, still update head.
// reason: parent's beforeLoad may have changed the route context
// and only now do we know the route context (and that the loader would not run)
const head = await executeHead()
updateMatch(matchId, (prev) => ({
...prev,
...head,
}))
const headResult = executeHead()
if (headResult) {
const head = await headResult
updateMatch(matchId, (prev) => ({
...prev,
...head,
}))
}
}
}
if (!loaderIsRunningAsync) {
Expand Down