Skip to content
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
15 changes: 15 additions & 0 deletions packages/react-router/src/useRouterState.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useStore } from '@tanstack/react-store'
import { useRef } from 'react'
import { replaceEqualDeep } from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
import type {
AnyRouter,
Expand Down Expand Up @@ -51,9 +52,23 @@ export function useRouterState<
warn: opts?.router === undefined,
})
const router = opts?.router || contextRouter

// During SSR we render exactly once and do not need reactivity.
// Avoid subscribing to the store (and any structural sharing work) on the server.
const _isServer = isServer ?? router.isServer
if (_isServer) {
const state = router.state as RouterState<TRouter['routeTree']>
return (opts?.select ? opts.select(state) : state) as UseRouterStateResult<
TRouter,
TSelected
>
}

const previousResult =
// eslint-disable-next-line react-hooks/rules-of-hooks
useRef<ValidateSelected<TRouter, TSelected, TStructuralSharing>>(undefined)

// eslint-disable-next-line react-hooks/rules-of-hooks
return useStore(router.__store, (state) => {
if (opts?.select) {
if (opts.structuralSharing ?? router.options.defaultStructuralSharing) {
Expand Down
2 changes: 1 addition & 1 deletion packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { batch } from '@tanstack/store'
import invariant from 'tiny-invariant'
import { isServer } from '@tanstack/router-core/isServer'
import { batch } from './utils/batch'
import { createControlledPromise, isPromise } from './utils'
import { isNotFound } from './not-found'
import { rootRouteId } from './root'
Expand Down
58 changes: 44 additions & 14 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Store, batch } from '@tanstack/store'
import { Store } from '@tanstack/store'
import { createBrowserHistory, parseHref } from '@tanstack/history'
import { isServer } from '@tanstack/router-core/isServer'
import { batch } from './utils/batch'
import {
createControlledPromise,
decodePath,
Expand Down Expand Up @@ -898,6 +899,29 @@ declare global {
*
* @link https://tanstack.com/router/latest/docs/framework/react/api/router/RouterType
*/
type RouterStateStore<TState> = {
state: TState
setState: (updater: (prev: TState) => TState) => void
}

function createServerStore<TState>(
initialState: TState,
): RouterStateStore<TState> {
let state = initialState

return {
get state() {
return state
},
set state(next) {
state = next
},
setState: (updater: (prev: TState) => TState) => {
state = updater(state)
},
} as RouterStateStore<TState>
}
Comment on lines +902 to +923
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the Store type definition and __store usage patterns
rg -n "type Store<|interface Store<" packages/router-core/src/ -A 10

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

# Find all __store usages to see what methods are called
rg -n "\.__store\." packages/router-core/src/ -B 2 -A 2

Repository: TanStack/router

Length of output: 5763


🏁 Script executed:

# Check the RouterStateStore type definition and createServerStore function
cat -n packages/router-core/src/router.ts | sed -n '902,923p'

Repository: TanStack/router

Length of output: 667


🏁 Script executed:

# Look for the __store assignment and its type annotation
rg -n "__store" packages/router-core/src/router.ts -B 5 -A 5 | head -50

Repository: TanStack/router

Length of output: 1499


🏁 Script executed:

# Find the Store type/class definition
rg -n "class Store<|interface Store<|type Store<|export.*Store" packages/router-core/src/ -A 15 | head -100

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

# Check if Store is imported from elsewhere
rg -n "^import.*Store|^export.*Store" packages/router-core/src/router.ts -B 2 -A 2

Repository: TanStack/router

Length of output: 230


🏁 Script executed:

# Search for Store definition in the entire packages/router-core
fd -e ts -e tsx . packages/router-core/src/ | xargs rg -l "class Store|interface Store" | head -5

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

# Check if .subscribe is called on __store or router store
rg -n "\.subscribe\(|subscribe.*store|store.*subscribe" packages/router-core/src/ -i

Repository: TanStack/router

Length of output: 417


🏁 Script executed:

# Check what methods are actually called on __store across all usages
rg -n "\.__store\.[a-zA-Z_$]+" packages/router-core/src/ -o --replace '$0' | sort | uniq -c | sort -rn

Repository: TanStack/router

Length of output: 1183


🏁 Script executed:

# Search for Store method calls to identify the actual API
rg -n "subscribe|notify|getState|setState" packages/router-core/src/router.ts -i

Repository: TanStack/router

Length of output: 1191


🏁 Script executed:

# Check if Store is used elsewhere to understand its full API
rg -n "new Store\(" packages/router-core/src/ -B 2 -A 5 | head -40

Repository: TanStack/router

Length of output: 720


Unsound Store cast weakens type safety for the server store.

createServerStore returns RouterStateStore<TState> but is coerced to Store<any> via cast, which hides the type mismatch and violates TypeScript strict mode requirements. While current usage only calls .state and .setState() (both implemented in RouterStateStore), the cast removes compiler protections. Per coding guidelines requiring extensive type safety, change __store to RouterStateStore<RouterState<TRouteTree>> and remove the cast.

♻️ Proposed refactor
-  __store!: Store<RouterState<TRouteTree>>
+  __store!: RouterStateStore<RouterState<TRouteTree>>

-        this.__store = createServerStore(
-          getInitialRouterState(this.latestLocation),
-        ) as unknown as Store<any>
+        this.__store = createServerStore(
+          getInitialRouterState(this.latestLocation),
+        )
🤖 Prompt for AI Agents
In `@packages/router-core/src/router.ts` around lines 902 - 923, The server store
is being unsafely cast to a generic Store which weakens type safety; change the
__store declaration to use the concrete RouterStateStore type produced by
createServerStore and parameterize it with RouterState<TRouteTree> (i.e., use
RouterStateStore<RouterState<TRouteTree>> instead of casting to Store<any>),
remove the cast, and ensure usages rely on the strongly typed .state and
.setState provided by createServerStore so the compiler enforces correct types.


export class RouterCore<
in out TRouteTree extends AnyRoute,
in out TTrailingSlashOption extends TrailingSlashOption,
Expand Down Expand Up @@ -972,8 +996,8 @@ export class RouterCore<
}
}

// These are default implementations that can optionally be overridden
// by the router provider once rendered. We provide these so that the
// This is a default implementation that can optionally be overridden
// by the router provider once rendered. We provide this so that the
// router can be used in a non-react environment if necessary
startTransition: StartTransitionFn = (fn) => fn()

Expand Down Expand Up @@ -1076,18 +1100,24 @@ export class RouterCore<
}

if (!this.__store && this.latestLocation) {
this.__store = new Store(getInitialRouterState(this.latestLocation), {
onUpdate: () => {
this.__store.state = {
...this.state,
cachedMatches: this.state.cachedMatches.filter(
(d) => !['redirected'].includes(d.status),
),
}
},
})
if (isServer ?? this.isServer) {
this.__store = createServerStore(
getInitialRouterState(this.latestLocation),
) as unknown as Store<any>
} else {
this.__store = new Store(getInitialRouterState(this.latestLocation), {
onUpdate: () => {
this.__store.state = {
...this.state,
cachedMatches: this.state.cachedMatches.filter(
(d) => !['redirected'].includes(d.status),
),
}
},
})

setupScrollRestoration(this)
setupScrollRestoration(this)
}
}

let needsLocationUpdate = false
Expand Down
2 changes: 1 addition & 1 deletion packages/router-core/src/ssr/ssr-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import invariant from 'tiny-invariant'
import { batch } from '@tanstack/store'
import { batch } from '../utils/batch'
import { isNotFound } from '../not-found'
import { createControlledPromise } from '../utils'
import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './constants'
Expand Down
4 changes: 4 additions & 0 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isServer } from '@tanstack/router-core/isServer'
import type { RouteIds } from './routeInfo'
import type { AnyRouter } from './router'

Expand Down Expand Up @@ -221,6 +222,9 @@ const isEnumerable = Object.prototype.propertyIsEnumerable
* Do not use this with signals
*/
export function replaceEqualDeep<T>(prev: any, _next: T, _depth = 0): T {
if (isServer) {
return _next
}
if (prev === _next) {
return prev
}
Expand Down
18 changes: 18 additions & 0 deletions packages/router-core/src/utils/batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { batch as storeBatch } from '@tanstack/store'

import { isServer } from '@tanstack/router-core/isServer'

// `@tanstack/store`'s `batch` is for reactive notification batching.
// On the server we don't subscribe/render reactively, so a lightweight
// implementation that just executes is enough.
export function batch<T>(fn: () => T): T {
if (isServer) {
return fn()
}

let result!: T
storeBatch(() => {
result = fn()
})
Comment on lines +5 to +16
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd -a -t f 'isServer\.(ts|tsx|js|mjs|cjs)$' | head -n 20

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

rg -t ts -t tsx 'isServer' packages/router-core/src/utils/batch.ts -B 10 -A 5

Repository: TanStack/router

Length of output: 86


🏁 Script executed:

rg 'isServer' packages/router-core/src/utils/batch.ts -B 10 -A 5

Repository: TanStack/router

Length of output: 482


🏁 Script executed:

rg -l 'isServer' packages/router-core/src --type ts | head -20

Repository: TanStack/router

Length of output: 426


🏁 Script executed:

fd -t f . packages/router-core/src/isServer | sort

Repository: TanStack/router

Length of output: 195


🏁 Script executed:

cat packages/router-core/src/utils.ts | head -100

Repository: TanStack/router

Length of output: 2732


🏁 Script executed:

rg 'export.*isServer' packages/router-core/src/isServer/

Repository: TanStack/router

Length of output: 309


🏁 Script executed:

rg 'options.*isServer|isServer.*option' packages/router-core/src --type ts -B 3 -A 3

Repository: TanStack/router

Length of output: 956


🏁 Script executed:

rg 'batch\(' packages/router-core/src --type ts -B 3 -A 3 | head -60

Repository: TanStack/router

Length of output: 3336


🏁 Script executed:

cat packages/router-core/package.json | rg -A 20 'exports' | head -40

Repository: TanStack/router

Length of output: 584


🏁 Script executed:

rg 'isServer' packages/router-core/package.json

Repository: TanStack/router

Length of output: 1818


🏁 Script executed:

rg -A 50 '"./isServer"' packages/router-core/package.json | head -80

Repository: TanStack/router

Length of output: 1628


🏁 Script executed:

rg 'new Router\|createRouter' packages/router-core/src --type ts -B 3 -A 5 | head -40

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

rg 'options\.isServer\s*[=:]|isServer\s*:\s*' packages/router-core/src --type ts -B 2 -A 2

Repository: TanStack/router

Length of output: 295


🏁 Script executed:

rg 'isServer.*undefined|development' packages/router-core/src -B 2 -A 2 | head -30

Repository: TanStack/router

Length of output: 1697


🏁 Script executed:

rg 'batch\(' packages/router-core/src -B 5 -A 5 | grep -A 5 -B 5 'this\.isServer\|router\.'

Repository: TanStack/router

Length of output: 3417


Verify that batch respects forced server mode.

batch branches only on module-level isServer, which is set at build time via conditional exports. If router.options.isServer is explicitly set to force server behavior at runtime (where module-level isServer is false), this will still route through @tanstack/store batching instead of the lightweight path. Either confirm that the module-level isServer definition already accounts for runtime overrides, or consider whether batch call sites should pass a runtime override flag to respect router.options.isServer.

🤖 Prompt for AI Agents
In `@packages/router-core/src/utils/batch.ts` around lines 5 - 16, The current
batch implementation uses the module-level isServer flag and ignores any runtime
override on the router, so calling batch will use storeBatch even when
router.options.isServer requests server behavior; update batch to accept or
consult a runtime override (e.g., an optional second parameter or check a
provided runtime flag) and prefer router.options.isServer when present, or
expose a wrapper used by callers that passes router.options.isServer into batch;
specifically, modify the export function batch<T>(fn: () => T[,
runtimeIsServer?: boolean]) to check runtimeIsServer || isServer before calling
storeBatch (or change call sites to pass router.options.isServer into batch),
ensuring symbols batch, isServer, storeBatch and router.options.isServer are
used to determine the correct branch at runtime.

return result
}
15 changes: 15 additions & 0 deletions packages/solid-router/src/useRouterState.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useStore } from '@tanstack/solid-store'
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
import type {
AnyRouter,
Expand Down Expand Up @@ -54,6 +55,20 @@ export function useRouterState<
})
const router = opts?.router || contextRouter

// During SSR we render exactly once and do not need reactivity.
// Avoid subscribing to the store on the server since the server store
// implementation does not provide subscribe() semantics.
const _isServer = isServer ?? router.isServer
if (_isServer) {
const state = router.state as RouterState<TRouter['routeTree']>
const selected = (
opts?.select ? opts.select(state) : state
) as UseRouterStateResult<TRouter, TSelected>
return (() => selected) as Accessor<
UseRouterStateResult<TRouter, TSelected>
>
}

return useStore(
router.__store,
(state) => {
Expand Down
13 changes: 13 additions & 0 deletions packages/vue-router/src/useRouterState.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useStore } from '@tanstack/vue-store'
import * as Vue from 'vue'
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
import type {
AnyRouter,
Expand Down Expand Up @@ -35,6 +36,18 @@ export function useRouterState<
>
}

// During SSR we render exactly once and do not need reactivity.
// Avoid subscribing to the store on the server since the server store
// implementation does not provide subscribe() semantics.
const _isServer = isServer ?? router.isServer

if (_isServer) {
const state = router.state as RouterState<TRouter['routeTree']>
return Vue.ref(opts?.select ? opts.select(state) : state) as Vue.Ref<
UseRouterStateResult<TRouter, TSelected>
>
}

return useStore(router.__store, (state) => {
if (opts?.select) return opts.select(state)

Expand Down
Loading