Skip to content
Open
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 @@ -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
7 changes: 7 additions & 0 deletions e2e/react-start/basic/src/routes/specialChars/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export const Route = createFileRoute('/specialChars/search')({
validateSearch: z.object({
searchParam: z.string(),
}),
beforeLoad: () => ({
beforeLoadOn: isBrowser ? 'client' : 'server',
}),
loader: async () => {
console.log(`[loader] Running on ${isBrowser ? 'client' : 'server'}`)
return {
Expand All @@ -18,11 +21,15 @@ export const Route = createFileRoute('/specialChars/search')({

function RouteComponent() {
const search = Route.useSearch()
const { beforeLoadOn } = Route.useRouteContext()
const { loadedOn } = Route.useLoaderData()

return (
<div>
Hello "/specialChars/search"!
<div data-testid="special-search-before-load-info">
Before load on: {beforeLoadOn}
</div>
<div data-testid="special-search-loaded-info">Loaded on: {loadedOn}</div>
<span data-testid={'special-search-param'}>{search.searchParam}</span>
</div>
Expand Down
5 changes: 5 additions & 0 deletions e2e/react-start/basic/tests/special-characters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,16 @@ test.describe('Unicode route rendering', () => {
const loadedOn = await page
.getByTestId('special-search-loaded-info')
.textContent()
const beforeLoadOn = await page
.getByTestId('special-search-before-load-info')
.textContent()

if (isSpaMode) {
expect(loadedOn).toBe('Loaded on: client')
expect(beforeLoadOn).toBe('Before load on: client')
} else {
expect(loadedOn).toBe('Loaded on: server')
expect(beforeLoadOn).toBe('Before load on: server')
}
})

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 on lines +6 to +13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- target file ---'
cat -n e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx

echo '--- search __events ---'
rg -n "__events|recordEvent|interface Window|declare global" e2e/solid-start/selective-ssr/src/routes -S

echo '--- search repo-wide Window augmentation ---'
rg -n "interface Window|declare global" -S .

Repository: TanStack/router

Length of output: 4375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,80p' e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx | cat -n

printf '\n--- related typings ---\n'
rg -n "window\\.__(events|events)|__events|interface Window|declare global" -S e2e/solid-start/selective-ssr src . \
  --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**'

Repository: TanStack/router

Length of output: 3954


Type the event buffer instead of casting window to any. window as any bypasses strict TS here; add a local Window augmentation for __events and push through window.__events.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx` around
lines 6 - 13, Update recordEvent to avoid casting window to any by declaring a
local Window augmentation for the __events event buffer, then initialize and
append through window.__events. Preserve the existing server guard and event
shape, including the performance timestamp.

Source: Coding guidelines

}

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