Skip to content
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

SPA mode #3497

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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
16 changes: 14 additions & 2 deletions e2e/react-start/basic/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'
import { CustomMessage } from '~/components/CustomMessage'

export const Route = createFileRoute('/')({
ssr: false,
beforeLoad: () => {
console.log('beforeLoad')
},
loader: async () => {
console.log('loader')
await new Promise((resolve) => setTimeout(resolve, 500))
return {
message: 'Hello from the server!',
}
},
pendingComponent: () => <div className="p-2">Loading...</div>,
component: Home,
})

function Home() {
const { message } = Route.useLoaderData()
return (
<div className="p-2">
<h3>Welcome Home!!!</h3>
<CustomMessage message="Hello from a custom component!" />
<p>{message}</p>
</div>
)
}
14 changes: 14 additions & 0 deletions examples/react/start-basic/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
ssr: false,
beforeLoad: () => {
console.log('beforeLoad')
},
loader: async () => {
console.log('loader')
await new Promise((resolve) => setTimeout(resolve, 500))
return {
message: 'Hello from the server!',
}
},
pendingComponent: () => <div className="p-2">Loading...</div>,
component: Home,
})

function Home() {
const { message } = Route.useLoaderData()
return (
<div className="p-2">
<h3>Welcome Home!!!</h3>
<p>{message}</p>
</div>
)
}
6 changes: 4 additions & 2 deletions examples/react/start-basic/app/utils/loggingMiddleware.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createMiddleware } from '@tanstack/react-start'

const preLogMiddleware = createMiddleware()
//

const serverLogMiddleware = createMiddleware()
.client(async (ctx) => {
const clientTime = new Date()

Expand All @@ -26,7 +28,7 @@ const preLogMiddleware = createMiddleware()
})

export const logMiddleware = createMiddleware()
.middleware([preLogMiddleware])
.middleware([serverLogMiddleware])
.client(async (ctx) => {
const res = await ctx.next()

Expand Down
10 changes: 9 additions & 1 deletion packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { matchContext } from './matchContext'
import { SafeFragment } from './SafeFragment'
import { renderRouteNotFound } from './renderRouteNotFound'
import { ScrollRestoration } from './scroll-restoration'
import { createSsrError } from './ssr-error'
import type { ParsedLocation } from '@tanstack/router-core'
import type { AnyRoute } from './route'

Expand Down Expand Up @@ -57,7 +58,9 @@ export const Match = React.memo(function MatchImpl({
(!route.isRoot || route.options.wrapInSuspense) &&
(route.options.wrapInSuspense ??
PendingComponent ??
(route.options.errorComponent as any)?.preload)
((route.options.errorComponent as any)?.preload ||
!route.ssr ||
route.ssr === 'data-only'))
? React.Suspense
: SafeFragment

Expand Down Expand Up @@ -219,6 +222,10 @@ export const MatchInner = React.memo(function MatchInnerImpl({
throw router.getMatch(match.id)?.loadPromise
}

if (router.isServer && (!route.ssr || route.ssr === 'data-only')) {
throw createSsrError()
}

if (match.status === 'error') {
// If we're on the server, we need to use React's new and super
// wonky api for throwing errors from a server side render inside
Expand Down Expand Up @@ -268,6 +275,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}, pendingMinMs)
}
}

throw router.getMatch(match.id)?.loadPromise
}

Expand Down
19 changes: 7 additions & 12 deletions packages/react-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,24 +64,19 @@ export function Transitioner() {

// Try to load the initial location
useLayoutEffect(() => {
// Don't load if we're already mounted
if (
(typeof window !== 'undefined' && router.clientSsr) ||
(mountLoadForRouter.current.router === router &&
mountLoadForRouter.current.mounted)
mountLoadForRouter.current.router === router &&
mountLoadForRouter.current.mounted
) {
return
}
mountLoadForRouter.current = { router, mounted: true }

const tryLoad = async () => {
try {
await router.load()
} catch (err) {
console.error(err)
}
}
mountLoadForRouter.current = { router, mounted: true }

tryLoad()
router.load().catch((err) => {
console.error(err)
})
}, [router])

useLayoutEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,5 @@ export { ScriptOnce } from './ScriptOnce'
export { Asset } from './Asset'
export { HeadContent } from './HeadContent'
export { Scripts } from './Scripts'

export { createSsrError, isSsrError } from './ssr-error'
13 changes: 0 additions & 13 deletions packages/react-router/src/lazyRouteComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as React from 'react'
import { Outlet } from './Match'
import type { AsyncRouteComponent } from './route'

// If the load fails due to module not found, it may mean a new version of
Expand Down Expand Up @@ -44,7 +43,6 @@ export function lazyRouteComponent<
>(
importer: () => Promise<T>,
exportName?: TKey,
ssr?: () => boolean,
): T[TKey] extends (props: infer TProps) => any
? AsyncRouteComponent<TProps>
: never {
Expand All @@ -54,10 +52,6 @@ export function lazyRouteComponent<
let reload: boolean

const load = () => {
if (typeof document === 'undefined' && ssr?.() === false) {
comp = (() => null) as any
return Promise.resolve()
}
if (!loadPromise) {
loadPromise = importer()
.then((res) => {
Expand Down Expand Up @@ -109,13 +103,6 @@ export function lazyRouteComponent<
throw load()
}

if (ssr?.() === false) {
return (
<ClientOnly fallback={<Outlet />}>
{React.createElement(comp, props)}
</ClientOnly>
)
}
return React.createElement(comp, props)
}

Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export class Route<
private _path!: TPath
private _fullPath!: TFullPath
private _to!: TrimPathRight<TFullPath>
private _ssr!: boolean
private _ssr!: boolean | 'data-only'

public get to() {
/* invariant(
Expand Down
41 changes: 39 additions & 2 deletions packages/react-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2221,6 +2221,26 @@ export class Router<
}
}

const shouldSkipLoader = (matchId: string) => {
const match = this.getMatch(matchId)!
const route = this.looseRoutesById[match.routeId]!

// Check if any parent route has ssr: false
const parentMatches = matches.slice(
0,
matches.findIndex((m) => m.id === matchId),
)

const isNonSsr =
!route.ssr ||
parentMatches.some((m) => {
const parentRoute = this.looseRoutesById[m.routeId]!
return !parentRoute.ssr
})

return (this.isServer && isNonSsr) || (!this.isServer && match.dehydrated)
}

try {
await new Promise<void>((resolveAll, rejectAll) => {
;(async () => {
Expand Down Expand Up @@ -2273,6 +2293,10 @@ export class Router<

const route = this.looseRoutesById[routeId]!

if (shouldSkipLoader(matchId)) {
continue
}

const pendingMs =
route.options.pendingMs ?? this.options.defaultPendingMs

Expand All @@ -2287,7 +2311,10 @@ export class Router<
this.options.defaultPendingComponent)
)

let executeBeforeLoad = true
// By default, execute the beforeLoad if the match is not dehydrated
// We'll unset this after the loader skips down below
let executeBeforeLoad = !existingMatch.dehydrated

if (
// If we are in the middle of a load, either of these will be present
// (not to be confused with `loadPromise`, which is always defined)
Expand Down Expand Up @@ -2432,12 +2459,19 @@ export class Router<
validResolvedMatches.forEach(({ id: matchId, routeId }, index) => {
matchPromises.push(
(async () => {
const route = this.looseRoutesById[routeId]!

const { loaderPromise: prevLoaderPromise } =
this.getMatch(matchId)!

let loaderShouldRunAsync = false
let loaderIsRunningAsync = false

// Do not run the loader if the route is not SSR'able
if (shouldSkipLoader(matchId)) {
return this.getMatch(matchId)!
}

if (prevLoaderPromise) {
await prevLoaderPromise
const match = this.getMatch(matchId)!
Expand All @@ -2446,7 +2480,6 @@ export class Router<
}
} else {
const parentMatchPromise = matchPromises[index - 1] as any
const route = this.looseRoutesById[routeId]!

const getLoaderContext = (): LoaderFnContext => {
const {
Expand Down Expand Up @@ -2619,9 +2652,11 @@ export class Router<

// If the route is successful and still fresh, just resolve
const { status, invalid } = this.getMatch(matchId)!

loaderShouldRunAsync =
status === 'success' &&
(invalid || (shouldReload ?? age > staleAge))

if (preload && route.options.preload === false) {
// Do nothing
} else if (loaderShouldRunAsync && !sync) {
Expand Down Expand Up @@ -2650,6 +2685,7 @@ export class Router<
await runLoader()
}
}

if (!loaderIsRunningAsync) {
const { loaderPromise, loadPromise } =
this.getMatch(matchId)!
Expand All @@ -2664,6 +2700,7 @@ export class Router<
? prev.loaderPromise
: undefined,
invalid: false,
dehydrated: false,
}))
return this.getMatch(matchId)!
})(),
Expand Down
9 changes: 9 additions & 0 deletions packages/react-router/src/ssr-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const message = 'SSR has been disabled for this route'

export function createSsrError() {
return new Error(message)
}

export function isSsrError(error: any): error is Error {
return error instanceof Error && error.message.includes(message)
}
22 changes: 22 additions & 0 deletions packages/react-start-client/src/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,26 @@ const serializers = [
// From
(v) => BigInt(v),
),
// Infinity
createSerializer(
// Key
'infinity',
// Check
(v): v is number => v === Infinity || v === -Infinity,
// To
(v) => (v > 0 ? '+' : '-'),
// From
(v) => (v === '+' ? Infinity : -Infinity),
),
// NaN
createSerializer(
// Key
'nan',
// Check
(v): v is number => typeof v === 'number' && isNaN(v),
// To
() => 0,
// From
() => NaN,
),
] as const
9 changes: 3 additions & 6 deletions packages/react-start-client/src/ssr-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ export function hydrate(router: AnyRouter) {
)

if (dehydratedMatch) {
Object.assign(match, dehydratedMatch)
Object.assign(match, dehydratedMatch, {
dehydrated: true,
})

const parentMatch = matches[match.index - 1]
const parentContext = parentMatch?.context ?? router.options.context ?? {}
Expand Down Expand Up @@ -168,11 +170,6 @@ export function hydrate(router: AnyRouter) {
;(match as unknown as SsrMatch).extracted?.forEach((ex) => {
deepMutableSetByPath(match, ['loaderData', ...ex.path], ex.value)
})
} else {
Object.assign(match, {
status: 'success',
updatedAt: Date.now(),
})
}

const assetContext = {
Expand Down
9 changes: 7 additions & 2 deletions packages/react-start-server/src/defaultStreamHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PassThrough } from 'node:stream'
import { isbot } from 'isbot'
import ReactDOMServer from 'react-dom/server'

import { isSsrError } from '@tanstack/react-router'
import { StartServer } from './StartServer'

import {
Expand Down Expand Up @@ -55,12 +56,16 @@ export const defaultStreamHandler = defineHandlerCallback(
},
}),
onError: (error, info) => {
console.error('Error in renderToPipeableStream:', error, info)
if (!isSsrError(error)) {
console.error('Error in renderToPipeableStream:', error, info)
}
},
},
)
} catch (e) {
console.error('Error in renderToPipeableStream:', e)
if (!isSsrError(e)) {
console.error('Error in renderToPipeableStream:', e)
}
}

const responseStream = transformPipeableStreamWithRouter(
Expand Down
Loading