Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
ceaa4d2
fix: lane match loader rewrite
Sheraff Jul 13, 2026
adf73dd
fix client/server build
Sheraff Jul 13, 2026
6f75f50
adopt preloaded beforeLoad
Sheraff Jul 13, 2026
f4b8934
fix adoption of server data
Sheraff Jul 13, 2026
7b5436b
fix internal error propagation, only user code is try/catch
Sheraff Jul 13, 2026
fb21a42
fix: solid Transitionner unsub cleanup before early return
Sheraff Jul 13, 2026
bf90602
fix: react-router not found ambiguous assertion
Sheraff Jul 13, 2026
f2ccae2
docs
Sheraff Jul 13, 2026
da5cb82
fix: preload always releases its borrowed lease
Sheraff Jul 13, 2026
cc21be0
error priority handling
Sheraff Jul 13, 2026
3a56878
Merge branch 'main' into fix-router-core-lane-match-loader
Sheraff Jul 13, 2026
2e8bbbe
Merge remote-tracking branch 'origin/main' into fix-router-core-lane-…
Sheraff Jul 13, 2026
af0f0c7
tests
Sheraff Jul 14, 2026
f54bf4b
fix #7673
Sheraff Jul 14, 2026
7be1149
ci: apply automated fixes
autofix-ci[bot] Jul 14, 2026
0ed58ea
rename consts to uppercase
Sheraff Jul 14, 2026
4fd731e
cleanup load-client
Sheraff Jul 14, 2026
acbfba3
fix load-client for undefined loaderData
Sheraff Jul 14, 2026
8fcf2eb
cleanup load-server
Sheraff Jul 14, 2026
3ba393e
ci: apply automated fixes
autofix-ci[bot] Jul 14, 2026
3d252e6
more cleanup
Sheraff Jul 14, 2026
4150cdf
ci: apply automated fixes
autofix-ci[bot] Jul 14, 2026
3ecc24b
update tests
Sheraff Jul 14, 2026
c801dcc
final coverage
Sheraff Jul 14, 2026
ded47d1
final fixed issue tests
Sheraff Jul 14, 2026
16dfcf9
ci: apply automated fixes
autofix-ci[bot] Jul 14, 2026
05efe8c
Merge remote-tracking branch 'origin/main' into fix-router-core-lane-…
Sheraff Jul 15, 2026
ba80053
minor byte shave in router-core
Sheraff Jul 15, 2026
125b13f
minor byte shave in Match
Sheraff Jul 15, 2026
2bba8fa
minor byte shaving
Sheraff Jul 15, 2026
1f50bd3
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
cf40d59
comments and cleanup
Sheraff Jul 15, 2026
ec631eb
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
b56cb51
test: does `declare` save any bytes?
Sheraff Jul 15, 2026
ef40259
test again, but i selected everything this time
Sheraff Jul 15, 2026
e177851
test: same, but property wasn't even initialized
Sheraff Jul 15, 2026
e7322f8
simplify Transitioner based on having a unique transitioner per router
Sheraff Jul 15, 2026
9810491
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
45f7731
deprecated test
Sheraff Jul 15, 2026
a8d7c8c
no reset key on server
Sheraff Jul 15, 2026
f90018f
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
f7f266b
some jsdoc
Sheraff Jul 15, 2026
5a8cc80
simplify getSemanticMatch
Sheraff Jul 15, 2026
1b20933
more jsdoc
Sheraff Jul 15, 2026
83fea0f
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
1f4838d
beforeLoad context cleanup on error + naming + misc bugs
Sheraff Jul 15, 2026
4746633
server preload is no-op
Sheraff Jul 17, 2026
6683863
console.error over promise.reject for lifecycle errors
Sheraff Jul 17, 2026
3d68db6
INTERNALS update
Sheraff Jul 17, 2026
423f905
fix(router-core): complete lane match loader rewrite (#7858)
Sheraff Jul 20, 2026
7443d96
Merge remote-tracking branch 'origin/main' into fix-router-core-lane-…
Sheraff Jul 20, 2026
1f27102
fix(router-core): harden lane loading lifecycle (#7869)
Sheraff Jul 21, 2026
03a1fde
perf(router): reduce SSR lane loading overhead (#7875)
Sheraff Jul 22, 2026
9043f4f
Merge branch 'main' into fix-router-core-lane-match-loader
Sheraff Jul 22, 2026
da82a47
ci: apply automated fixes
autofix-ci[bot] Jul 22, 2026
f84d087
ci: apply automated fixes (attempt 2/3)
autofix-ci[bot] Jul 22, 2026
90a7f39
fix(router): address lane loader regressions (#7880)
Sheraff Jul 23, 2026
baa18cd
fix(router): final-review regressions, changeset, and byte recovery (…
Sheraff Jul 23, 2026
55ceb3a
chore(router-core): lane match loader final byte-shave (#7885)
Sheraff Jul 23, 2026
fd8e256
Merge branch 'main' into fix-router-core-lane-match-loader
Sheraff Jul 23, 2026
a4d5b8f
Merge remote-tracking branch 'origin/main' into fix-router-core-lane-…
Sheraff Jul 23, 2026
2d639e1
Merge branch 'main' into fix-router-core-lane-match-loader
schiller-manuel Jul 23, 2026
1d20a9d
Always execute beforeLoad for client lanes (#7922)
Sheraff Jul 30, 2026
b8204ea
Use scoped Vitest cleanup in lane regression tests (#7923)
Sheraff Jul 30, 2026
00d68c4
Simplify loader flight and render acknowledgement ownership (#7926)
Sheraff Aug 2, 2026
1c28ed7
don't fail nav on lifecycle user error
Sheraff Aug 2, 2026
f21614f
Merge branch 'main' into fix-router-core-lane-match-loader
Sheraff Aug 2, 2026
fbe5a60
Fix invalidation of active preloads (#7941)
Sheraff Aug 3, 2026
27d10cb
update INTERNALS.md
Sheraff Aug 4, 2026
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 @@ -35,7 +35,6 @@ type RenderedEvent = {
}

type InterruptedNavigationRouter = {
latestLoadPromise: Promise<void> | undefined
load: () => Promise<void>
navigate: (options: {
to: '/fast/$id' | '/slow/$id'
Expand Down Expand Up @@ -104,18 +103,6 @@ function assertSlowNavigationSettlement(settlement: NavigationSettlement) {
)
}

async function awaitExpectedLoadSettlement(loadPromise: Promise<void>) {
try {
await loadPromise
} catch (reason) {
if (reasonHasAbortShape(reason) || reasonHasCancellationShape(reason)) {
return
}

throw reason
}
}

function reasonHasAbortShape(reason: unknown) {
return reason instanceof DOMException && reason.name === 'AbortError'
}
Expand Down Expand Up @@ -144,7 +131,6 @@ export function createWorkload(
let navigateFast: (id: string) => Promise<void> = uninitialized
let startSlowNavigation: (id: string) => Promise<NavigationSettlement> =
uninitializedSettlement
let getLatestLoadPromise: () => Promise<void> | undefined = () => undefined

function assertRenderedPage(page: 'shell' | 'fast', id?: string) {
const element = container?.querySelector<HTMLElement>('[data-bench-page]')
Expand Down Expand Up @@ -203,7 +189,6 @@ export function createWorkload(
const mounted = mountTestApp(container)
const router = mounted.router as InterruptedNavigationRouter
unmount = mounted.unmount
getLatestLoadPromise = () => router.latestLoadPromise

unsub = router.subscribe('onRendered', (event) => {
if (
Expand Down Expand Up @@ -265,7 +250,6 @@ export function createWorkload(
expectedRenderedPath = undefined
navigateFast = uninitialized
startSlowNavigation = uninitializedSettlement
getLatestLoadPromise = () => undefined
}

async function interrupt(
Expand All @@ -276,12 +260,6 @@ export function createWorkload(
const slowNavigation = startSlowNavigation(slowId)

await waitForSlowLoader(slowId)
const slowLoadPromise = getLatestLoadPromise()

if (!slowLoadPromise) {
throw new Error(`Slow navigation did not start a load for id: ${slowId}`)
}

await navigateFast(fastId)
resolveSlowLoader(slowId)

Expand All @@ -291,7 +269,6 @@ export function createWorkload(
assertSlowNavigationSettlement(settlement)
}

await awaitExpectedLoadSettlement(slowLoadPromise)
await drainMicrotasks()
}

Expand Down
2 changes: 2 additions & 0 deletions docs/router/guide/preloading.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ export const Route = createFileRoute('/posts/$postId')({
})
```

Client-side preloading also runs each new route's `beforeLoad` with `preload: true`. When a completed successful preload is still fresh under the built-in freshness and invalidation policy used for preloaded loader data, the client router can reuse the context returned by that invocation instead of calling `beforeLoad` again with `preload: false`. Reuse follows route match identity, including `loaderDeps`, and requires a reusable parent context. The `shouldReload` option remains loader-only. Pending, failed, invalidated, or stale preloads do not donate their `beforeLoad` context to a client navigation.

## Preloading with External Libraries

When integrating external caching libraries like React Query, which have their own mechanisms for determining stale data, you may want to override the default preloading and stale-while-revalidate logic of TanStack Router. These libraries often use options like staleTime to control the freshness of data.
Expand Down
33 changes: 31 additions & 2 deletions e2e/solid-start/selective-ssr/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as SsrFalsePendingMinRouteImport } from './routes/ssr-false-pending-min'
import { Route as PostsRouteImport } from './routes/posts'
import { Route as DataOnlyPendingComponentRouteImport } from './routes/data-only-pending-component'
import { Route as IndexRouteImport } from './routes/index'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'

const SsrFalsePendingMinRoute = SsrFalsePendingMinRouteImport.update({
id: '/ssr-false-pending-min',
path: '/ssr-false-pending-min',
getParentRoute: () => rootRouteImport,
} as any)
const PostsRoute = PostsRouteImport.update({
id: '/posts',
path: '/posts',
Expand All @@ -40,42 +46,64 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/data-only-pending-component': typeof DataOnlyPendingComponentRoute
'/posts': typeof PostsRouteWithChildren
'/ssr-false-pending-min': typeof SsrFalsePendingMinRoute
'/posts/$postId': typeof PostsPostIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/data-only-pending-component': typeof DataOnlyPendingComponentRoute
'/posts': typeof PostsRouteWithChildren
'/ssr-false-pending-min': typeof SsrFalsePendingMinRoute
'/posts/$postId': typeof PostsPostIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/data-only-pending-component': typeof DataOnlyPendingComponentRoute
'/posts': typeof PostsRouteWithChildren
'/ssr-false-pending-min': typeof SsrFalsePendingMinRoute
'/posts/$postId': typeof PostsPostIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/data-only-pending-component' | '/posts' | '/posts/$postId'
fullPaths:
| '/'
| '/data-only-pending-component'
| '/posts'
| '/ssr-false-pending-min'
| '/posts/$postId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/data-only-pending-component' | '/posts' | '/posts/$postId'
to:
| '/'
| '/data-only-pending-component'
| '/posts'
| '/ssr-false-pending-min'
| '/posts/$postId'
id:
| '__root__'
| '/'
| '/data-only-pending-component'
| '/posts'
| '/ssr-false-pending-min'
| '/posts/$postId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DataOnlyPendingComponentRoute: typeof DataOnlyPendingComponentRoute
PostsRoute: typeof PostsRouteWithChildren
SsrFalsePendingMinRoute: typeof SsrFalsePendingMinRoute
}

declare module '@tanstack/solid-router' {
interface FileRoutesByPath {
'/ssr-false-pending-min': {
id: '/ssr-false-pending-min'
path: '/ssr-false-pending-min'
fullPath: '/ssr-false-pending-min'
preLoaderRoute: typeof SsrFalsePendingMinRouteImport
parentRoute: typeof rootRouteImport
}
'/posts': {
id: '/posts'
path: '/posts'
Expand Down Expand Up @@ -121,6 +149,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DataOnlyPendingComponentRoute: DataOnlyPendingComponentRoute,
PostsRoute: PostsRouteWithChildren,
SsrFalsePendingMinRoute: SsrFalsePendingMinRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
48 changes: 48 additions & 0 deletions e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { onCleanup, onMount } from 'solid-js'
import { createFileRoute } from '@tanstack/solid-router'

const pendingMinMs = 1500

function recordEvent(type: string) {
if (typeof window === 'undefined') {
return
}

const global = window as any
global.__events ??= []
global.__events.push({ type, t: performance.now() })
Comment thread
Sheraff marked this conversation as resolved.
}

function Pending() {
onMount(() => {
recordEvent('pending-mounted')
})
onCleanup(() => {
recordEvent('pending-unmounted')
})

return <div data-testid="ssr-false-pending">Loading SSR false route...</div>
}

function Target() {
onMount(() => {
recordEvent('target-mounted')
})

return <div data-testid="ssr-false-target">SSR false route loaded</div>
}

export const Route = createFileRoute('/ssr-false-pending-min')({
ssr: false,
pendingMs: 0,
pendingMinMs,
pendingComponent: Pending,
loader: async () => {
recordEvent('loader-start')
await new Promise((resolve) => setTimeout(resolve, 50))
recordEvent('loader-done')

return { ok: true }
},
component: Target,
})
122 changes: 122 additions & 0 deletions e2e/solid-start/selective-ssr/tests/issue-7283-dev-hydration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { spawn } from 'node:child_process'
import path from 'node:path'
import { expect, test } from '@playwright/test'
import type { ChildProcess } from 'node:child_process'
import type { Page } from '@playwright/test'

// Regression test for https://github.com/TanStack/router/issues/7283
//
// Selective SSR routes (`ssr: false` / `ssr: 'data-only'`) that declare a
// `pendingComponent` must hydrate cleanly in DEV mode. Solid only validates
// hydration markers in dev, so the production webServer configured in
// playwright.config.ts masks the mismatch ("template is not a function",
// route never reaches its loaded component). This spec therefore boots its
// own `vite dev` server on a dedicated port and drives the app against it.

const DEV_PORT = 58283
const devBaseURL = `http://localhost:${DEV_PORT}`

let devServer: ChildProcess | undefined

async function waitForServer(url: string, timeoutMs: number) {
const deadline = Date.now() + timeoutMs
let lastError: unknown
while (Date.now() < deadline) {
try {
const res = await fetch(url)
if (res.ok) return
} catch (err) {
lastError = err
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
throw new Error(`dev server did not start on ${url}: ${lastError}`)
}

test.beforeAll(async () => {
const appDir = path.resolve(import.meta.dirname, '..')
devServer = spawn(
'pnpm',
[
'dev:e2e',
'--host',
'localhost',
'--port',
String(DEV_PORT),
'--strictPort',
],
{
cwd: appDir,
env: {
...process.env,
VITE_SERVER_PORT: String(DEV_PORT),
NODE_ENV: 'development',
},
stdio: 'ignore',
detached: true,
},
)
await waitForServer(`${devBaseURL}/`, 90_000)
})

test.afterAll(() => {
if (devServer?.pid) {
try {
process.kill(-devServer.pid, 'SIGTERM')
} catch {
// already gone
}
}
})

function collectHydrationFailures(page: Page) {
const failures: Array<string> = []
const isHydrationFailure = (text: string) =>
text.includes('template is not a function') ||
text.includes("wasn't caught by any route") ||
text.includes('Hydration Mismatch')
page.on('pageerror', (err) => {
if (isHydrationFailure(err.message)) failures.push(err.message)
})
page.on('console', (msg) => {
if (msg.type() === 'error' || msg.type() === 'warning') {
const text = msg.text()
if (isHydrationFailure(text)) failures.push(text)
}
})
return failures
}

test.describe('dev-mode hydration of selective SSR routes with pendingComponent', () => {
test.setTimeout(120_000)

test('ssr:false route with pendingComponent hydrates cleanly and reaches its loaded component (issue #7283)', async ({
page,
}) => {
const failures = collectHydrationFailures(page)

await page.goto(`${devBaseURL}/ssr-false-pending-min`)

// The loaded component must eventually replace the pending UI.
await expect(page.getByTestId('ssr-false-target')).toBeVisible({
timeout: 15_000,
})

// No dev hydration mismatch may occur along the way.
expect(failures).toEqual([])
})

test('data-only route with pendingComponent hydrates cleanly and reaches its loaded component (regression vs main)', async ({
page,
}) => {
const failures = collectHydrationFailures(page)

await page.goto(`${devBaseURL}/data-only-pending-component`)

await expect(
page.getByTestId('data-only-pending-component-ready-label'),
).toHaveText('OK - loader finished', { timeout: 15_000 })

expect(failures).toEqual([])
})
})
Loading
Loading