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
3 changes: 3 additions & 0 deletions labeler-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
'package: router-generator':
- changed-files:
- any-glob-to-any-file: 'packages/router-generator/**/*'
'package: router-is-server':
- changed-files:
- any-glob-to-any-file: 'packages/router-is-server/**/*'
'package: router-plugin':
- changed-files:
- any-glob-to-any-file: 'packages/router-plugin/**/*'
Expand Down
3 changes: 2 additions & 1 deletion packages/react-router/src/Asset.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react'
import { isServer } from '@tanstack/router-core'
import { useRouter } from './useRouter'
import type { RouterManagedTag } from '@tanstack/router-core'

Expand Down Expand Up @@ -143,7 +144,7 @@ function Script({
return undefined
}, [attrs, children])

if (!router.isServer) {
if (!(isServer ?? router.isServer)) {
const { src, ...rest } = attrs || {}
// render an empty script on the client just to avoid hydration errors
return (
Expand Down
5 changes: 3 additions & 2 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getLocationChangeInfo,
isNotFound,
isRedirect,
isServer,
rootRouteId,
} from '@tanstack/router-core'
import { CatchBoundary, ErrorComponent } from './CatchBoundary'
Expand Down Expand Up @@ -246,7 +247,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
const routerMatch = router.getMatch(match.id)
if (routerMatch && !routerMatch._nonReactive.minPendingPromise) {
// Create a promise that will resolve after the minPendingMs
if (!router.isServer) {
if (!(isServer ?? router.isServer)) {
const minPendingPromise = createControlledPromise<void>()

routerMatch._nonReactive.minPendingPromise = minPendingPromise
Expand Down Expand Up @@ -285,7 +286,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
// of a suspense boundary. This is the only way to get
// renderToPipeableStream to not hang indefinitely.
// We'll serialize the error and rethrow it on the client.
if (router.isServer) {
if (isServer ?? router.isServer) {
const RouteErrorComponent =
(route.options.errorComponent ??
router.options.defaultErrorComponent) ||
Expand Down
7 changes: 4 additions & 3 deletions packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react'
import warning from 'tiny-warning'
import { rootRouteId } from '@tanstack/router-core'
import { isServer, rootRouteId } from '@tanstack/router-core'
import { CatchBoundary, ErrorComponent } from './CatchBoundary'
import { useRouterState } from './useRouterState'
import { useRouter } from './useRouter'
Expand Down Expand Up @@ -56,13 +56,14 @@ export function Matches() {

// Do not render a root Suspense during SSR or hydrating from SSR
const ResolvedSuspense =
router.isServer || (typeof document !== 'undefined' && router.ssr)
(isServer ?? router.isServer) ||
(typeof document !== 'undefined' && router.ssr)
? SafeFragment
: React.Suspense

const inner = (
<ResolvedSuspense fallback={pendingElement}>
{!router.isServer && <Transitioner />}
{!(isServer ?? router.isServer) && <Transitioner />}
<MatchesInner />
</ResolvedSuspense>
)
Expand Down
3 changes: 2 additions & 1 deletion packages/react-router/src/ScriptOnce.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { isServer } from '@tanstack/router-core'
import { useRouter } from './useRouter'

/**
* Server-only helper to emit a script tag exactly once during SSR.
*/
export function ScriptOnce({ children }: { children: string }) {
const router = useRouter()
if (!router.isServer) {
if (!(isServer ?? router.isServer)) {
return null
}

Expand Down
3 changes: 2 additions & 1 deletion packages/react-router/src/scroll-restoration.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
defaultGetScrollRestorationKey,
escapeHtml,
isServer,
restoreScroll,
storageKey,
} from '@tanstack/router-core'
Expand All @@ -9,7 +10,7 @@ import { ScriptOnce } from './ScriptOnce'

export function ScrollRestoration() {
const router = useRouter()
if (!router.isScrollRestoring || !router.isServer) {
if (!router.isScrollRestoring || !(isServer ?? router.isServer)) {
return null
}
if (typeof router.options.scrollRestoration === 'function') {
Expand Down
7 changes: 7 additions & 0 deletions packages/react-router/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import packageJson from './package.json'

const config = defineConfig({
plugins: [react()],
// Add 'development' condition for tests to resolve @tanstack/router-is-server
// to the development export (isServer = undefined) instead of node (isServer = true)
...(process.env.VITEST && {
resolve: {
conditions: ['development'],
},
}),
test: {
name: packageJson.name,
dir: './tests',
Expand Down
1 change: 1 addition & 0 deletions packages/router-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
},
"dependencies": {
"@tanstack/history": "workspace:*",
"@tanstack/router-is-server": "workspace:*",
"@tanstack/store": "^0.8.0",
"cookie-es": "^2.0.0",
"seroval": "^1.4.2",
Expand Down
1 change: 1 addition & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export {
export { encode, decode } from './qss'
export { rootRouteId } from './root'
export type { RootRouteId } from './root'
export { isServer } from './isServer'

export { BaseRoute, BaseRouteApi, BaseRootRoute } from './route'
export type {
Expand Down
24 changes: 24 additions & 0 deletions packages/router-core/src/isServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Static server/client detection for tree-shaking support.
*
* This file re-exports `isServer` from `@tanstack/router-is-server` which uses
* conditional exports to provide different values based on the environment:
*
* - `browser` condition → `false` (client)
* - `node`/`worker`/`deno`/`bun` → `true` (server)
* - `development` condition → `undefined` (for tests, falls back to router.isServer)
*
* The bundler resolves the correct file at build time based on export conditions,
* and since the value is a literal constant, dead code can be eliminated.
*
* @example
* ```typescript
* import { isServer } from '@tanstack/router-core'
*
* // The ?? operator provides fallback for development/test mode
* if (isServer ?? router.isServer) {
* // Server-only code - eliminated in client bundles
* }
* ```
*/
export { isServer } from '@tanstack/router-is-server'
15 changes: 8 additions & 7 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createControlledPromise, isPromise } from './utils'
import { isNotFound } from './not-found'
import { rootRouteId } from './root'
import { isRedirect } from './redirect'
import { isServer } from './isServer'
import type { NotFoundError } from './not-found'
import type { ParsedLocation } from './location'
import type {
Expand Down Expand Up @@ -169,11 +170,11 @@ const shouldSkipLoader = (
): boolean => {
const match = inner.router.getMatch(matchId)!
// upon hydration, we skip the loader if the match has been dehydrated on the server
if (!inner.router.isServer && match._nonReactive.dehydrated) {
if (!(isServer ?? inner.router.isServer) && match._nonReactive.dehydrated) {
return true
}

if (inner.router.isServer && match.ssr === false) {
if ((isServer ?? inner.router.isServer) && match.ssr === false) {
return true
}

Expand Down Expand Up @@ -306,7 +307,7 @@ const setupPendingTimeout = (
route.options.pendingMs ?? inner.router.options.defaultPendingMs
const shouldPending = !!(
inner.onReady &&
!inner.router.isServer &&
!(isServer ?? inner.router.isServer) &&
!resolvePreload(inner, matchId) &&
(route.options.loader ||
route.options.beforeLoad ||
Expand Down Expand Up @@ -520,7 +521,7 @@ const handleBeforeLoad = (

const serverSsr = () => {
// on the server, determine whether SSR the current match or not
if (inner.router.isServer) {
if (isServer ?? inner.router.isServer) {
const maybePromise = isBeforeLoadSsr(inner, matchId, index, route)
if (isPromise(maybePromise)) return maybePromise.then(queueExecution)
}
Expand Down Expand Up @@ -635,7 +636,7 @@ const runLoader = async (

// Actually run the loader and handle the result
try {
if (!inner.router.isServer || match.ssr === true) {
if (!(isServer ?? inner.router.isServer) || match.ssr === true) {
loadRouteChunk(route)
}

Expand Down Expand Up @@ -759,7 +760,7 @@ const loadRouteMatch = async (
const route = inner.router.looseRoutesById[routeId]!

if (shouldSkipLoader(inner, matchId)) {
if (inner.router.isServer) {
if (isServer ?? inner.router.isServer) {
return inner.router.getMatch(matchId)!
}
} else {
Expand Down Expand Up @@ -879,7 +880,7 @@ export async function loadMatches(arg: {
// make sure the pending component is immediately rendered when hydrating a match that is not SSRed
// the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
if (
!inner.router.isServer &&
!(isServer ?? inner.router.isServer) &&
inner.router.state.matches.some((d) => d._forcePending)
) {
triggerOnReady(inner)
Expand Down
22 changes: 15 additions & 7 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
trimPath,
trimPathRight,
} from './path'
import { isServer } from './isServer'
import { createLRUCache } from './lru-cache'
import { isNotFound } from './not-found'
import { setupScrollRestoration } from './scroll-restoration'
Expand Down Expand Up @@ -1018,7 +1019,7 @@ export class RouterCore<
(this.options.history && this.options.history !== this.history)
) {
if (!this.options.history) {
if (!this.isServer) {
if (!(isServer ?? this.isServer)) {
this.history = createBrowserHistory() as TRouterHistory
}
} else {
Expand All @@ -1028,7 +1029,11 @@ export class RouterCore<

this.origin = this.options.origin
if (!this.origin) {
if (!this.isServer && window?.origin && window.origin !== 'null') {
if (
!(isServer ?? this.isServer) &&
window?.origin &&
window.origin !== 'null'
) {
this.origin = window.origin
} else {
// fallback for the server, can be overridden by calling router.update({origin}) on the server
Expand All @@ -1044,7 +1049,7 @@ export class RouterCore<
this.routeTree = this.options.routeTree as TRouteTree
let processRouteTreeResult: ProcessRouteTreeResult<TRouteTree>
if (
this.isServer &&
(isServer ?? this.isServer) &&
globalThis.__TSR_CACHE__ &&
globalThis.__TSR_CACHE__.routeTree === this.routeTree
) {
Expand All @@ -1055,7 +1060,10 @@ export class RouterCore<
this.resolvePathCache = createLRUCache(1000)
processRouteTreeResult = this.buildRouteTree()
// only cache if nothing else is cached yet
if (this.isServer && globalThis.__TSR_CACHE__ === undefined) {
if (
(isServer ?? this.isServer) &&
globalThis.__TSR_CACHE__ === undefined
) {
globalThis.__TSR_CACHE__ = {
routeTree: this.routeTree,
processRouteTreeResult: processRouteTreeResult as any,
Expand Down Expand Up @@ -1479,7 +1487,7 @@ export class RouterCore<

match = {
id: matchId,
ssr: this.isServer ? undefined : route.options.ssr,
ssr: (isServer ?? this.isServer) ? undefined : route.options.ssr,
index,
routeId: route.id,
params: previousMatch
Expand Down Expand Up @@ -2215,7 +2223,7 @@ export class RouterCore<
this.cancelMatches()
this.updateLatestLocation()

if (this.isServer) {
if (isServer ?? this.isServer) {
// for SPAs on the initial load, this is handled by the Transitioner
const nextLocation = this.buildLocation({
to: this.latestLocation.pathname,
Expand Down Expand Up @@ -2365,7 +2373,7 @@ export class RouterCore<
} catch (err) {
if (isRedirect(err)) {
redirect = err
if (!this.isServer) {
if (!(isServer ?? this.isServer)) {
this.navigate({
...redirect.options,
replace: true,
Expand Down
5 changes: 3 additions & 2 deletions packages/router-core/src/scroll-restoration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { functionalUpdate } from './utils'
import { isServer } from './isServer'
import type { AnyRouter } from './router'
import type { ParsedLocation } from './location'
import type { NonNullableUpdater } from './utils'
Expand Down Expand Up @@ -217,7 +218,7 @@ export function restoreScroll({
/** Setup global listeners and hooks to support scroll restoration. */
/** Setup global listeners and hooks to support scroll restoration. */
export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
if (!scrollRestorationCache && !router.isServer) {
if (!scrollRestorationCache && !(isServer ?? router.isServer)) {
return
}
const shouldScrollRestoration =
Expand All @@ -228,7 +229,7 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) {
}

if (
router.isServer ||
(isServer ?? router.isServer) ||
router.isScrollRestorationSetup ||
!scrollRestorationCache
) {
Expand Down
15 changes: 15 additions & 0 deletions packages/router-is-server/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// @ts-check

import rootConfig from '../../eslint.config.js'

export default [
...rootConfig,
{
rules: {
// We explicitly annotate `isServer: boolean` even though it's inferrable
// because the explicit type is important for documentation and ensures
// consistent typing across all conditional export files
'@typescript-eslint/no-inferrable-types': 'off',
},
},
]
Loading
Loading