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
6 changes: 6 additions & 0 deletions .changeset/polite-gifts-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/router-core': patch
'@tanstack/start-server-core': patch
---

Reuse the parsed request location during SSR instead of repeating input rewrites. Match server routes against the app router's parsed pathname while preserving encoded pathnames for server handlers and middleware.
4 changes: 3 additions & 1 deletion packages/router-core/src/load-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,9 @@ export async function loadServerRoute(
router: AnyRouter,
opts?: ServerLoadOptions,
): Promise<void> {
router.updateLatestLocation()
if (!opts?._skipLocationUpdate) {
router.updateLatestLocation()
}
const next = router.latestLocation
const previous = router._committed
const previousEnd = router._lifecycleEnd
Expand Down
2 changes: 2 additions & 0 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,8 @@ export type LoadFn = (opts?: {
sync?: boolean
action?: { type: HistoryAction }
_signal?: AbortSignal
/** @private Reuse the location already prepared for this SSR request. */
_skipLocationUpdate?: boolean
}) => Promise<void>

export type CommitLocationFn = ({
Expand Down
1 change: 1 addition & 0 deletions packages/router-core/src/ssr/createRequestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function createRequestHandler<TRouter extends AnyRouter>({

await router.load({
_signal: signal,
_skipLocationUpdate: true,
})
signal.throwIfAborted()

Expand Down
134 changes: 134 additions & 0 deletions packages/router-core/tests/server-history.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,142 @@
import { createMemoryHistory, createServerHistory } from '@tanstack/history'
import { expect, test, vi } from 'vitest'
import { BaseRootRoute, BaseRoute, redirect } from '../src'
import { createRequestHandler } from '../src/ssr/server'
import { createTestRouter, loadServerResponse } from './routerTestUtils'

test.each([
{ basepath: '', origin: undefined },
{ basepath: '/app', origin: undefined },
{ basepath: '/app', origin: 'https://canonical.example' },
])(
'request handling rewrites once with basepath $basepath and origin $origin',
async ({ basepath, origin }) => {
const root = new BaseRootRoute()
const input = vi.fn(({ url }: { url: URL }) => {
url.pathname = url.pathname.replace('/public/', '/posts/')
return url
})
const router = createTestRouter({
isServer: true,
basepath,
origin,
rewrite: {
input,
output: ({ url }) => {
url.pathname = url.pathname.replace('/posts/', '/public/')
return url
},
},
routeTree: root.addChildren([
new BaseRoute({
getParentRoute: () => root,
path: '/posts/$postId',
loader: ({ params }) => params.postId,
}),
]),
})
input.mockClear()

const response = await createRequestHandler({
createRouter: () => router,
request: new Request(
`https://example.com${basepath}/public/caf%C3%A9?view=full`,
),
})(({ router: loadedRouter }) => {
return Response.json({
postId: loadedRouter.state.matches.at(-1)?.loaderData,
pathname: loadedRouter.state.location.pathname,
search: loadedRouter.state.location.search,
})
})

expect(response.status).toBe(200)
expect(await response.json()).toEqual({
postId: 'café',
pathname: '/posts/café',
search: { view: 'full' },
})
expect(input).toHaveBeenCalledTimes(1)
expect(input.mock.calls[0]?.[0].url.origin).toBe(
origin ?? 'https://example.com',
)
},
)

test.each([
{ isServer: false, action: 'push' },
{ isServer: false, action: 'replace' },
{ isServer: true, action: 'push' },
{ isServer: true, action: 'replace' },
] as const)(
'loads refresh memory history after $action with isServer=$isServer by default',
async ({ isServer, action }) => {
const root = new BaseRootRoute()
const targetLoader = vi.fn(() => 'target')
const history = createMemoryHistory({ initialEntries: ['/'] })
const router = createTestRouter({
isServer,
history,
routeTree: root.addChildren([
new BaseRoute({ getParentRoute: () => root, path: '/' }),
new BaseRoute({
getParentRoute: () => root,
path: '/target',
loader: targetLoader,
}),
]),
})
await router.load()

history[action]('/target')
await router.load()

expect(router.state.location.pathname).toBe('/target')
expect(targetLoader).toHaveBeenCalledTimes(1)
expect(router.state.matches.at(-1)?.loaderData).toBe('target')
},
)

test('server loads reuse a location prepared with updated router options', async () => {
const root = new BaseRootRoute()
const input = vi.fn(({ url }: { url: URL }) => {
url.pathname = url.pathname.replace('/public', '/target')
return url
})
const router = createTestRouter({
isServer: true,
basepath: '/old',
history: createMemoryHistory({ initialEntries: ['/old/'] }),
routeTree: root.addChildren([
new BaseRoute({ getParentRoute: () => root, path: '/' }),
new BaseRoute({
getParentRoute: () => root,
path: '/target',
loader: () => 'target',
}),
]),
})
router.update({
history: createServerHistory('/app/public'),
basepath: '/app',
rewrite: {
input,
output: ({ url }) => {
url.pathname = url.pathname.replace('/target', '/public')
return url
},
},
})
input.mockClear()

await router.load({ _skipLocationUpdate: true })

expect(router.state.location.pathname).toBe('/target')
expect(router.state.location.publicHref).toBe('/app/public')
expect(router.state.matches.at(-1)?.loaderData).toBe('target')
expect(input).not.toHaveBeenCalled()
})

test.each([false, true])(
'router navigation respects isServer=%s with memory history',
async (isServer) => {
Expand Down
21 changes: 11 additions & 10 deletions packages/start-server-core/src/createStartHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from '@tanstack/start-client-core'
import {
_getRenderedMatches,
executeRewriteInput,
isDangerousProtocol,
isPromise,
isRedirect,
Expand Down Expand Up @@ -729,7 +728,10 @@ export function createStartHandler<TRegister = Register>(
// `additionalContext` is request-scoped and only read from router.options
// during load; avoid a full router.update() and redundant location parse.
routerInstance.options.additionalContext = { serverContext }
await routerInstance.load({ _signal: signal })
await routerInstance.load({
_signal: signal,
_skipLocationUpdate: true,
})
signal.throwIfAborted()

if (routerInstance._serverResult?.type === 'redirect') {
Expand Down Expand Up @@ -774,7 +776,6 @@ export function createStartHandler<TRegister = Register>(
handleServerRoutes({
getRouter,
request,
url,
executeRouter,
context,
executedRequestMiddlewares,
Expand Down Expand Up @@ -909,14 +910,12 @@ async function handleRedirectResponse(
async function handleServerRoutes({
getRouter,
request,
url,
executeRouter,
context,
executedRequestMiddlewares,
}: {
getRouter: () => Promise<AnyRouter>
request: Request
url: URL
executeRouter: (
serverContext: any,
matchedRoutes?: ReadonlyArray<AnyRoute>,
Expand All @@ -925,13 +924,15 @@ async function handleServerRoutes({
executedRequestMiddlewares: Set<AnyRequestMiddleware>
}): Promise<SsrResponse> {
const router = await getRouter()
const rewrittenUrl = executeRewriteInput(router.rewrite, url)
const pathname = rewrittenUrl.pathname
const location = router.latestLocation
// Preserve the encoded pathname exposed to server handlers and middleware.
const pathname = location.href.split(/[?#]/, 1)[0]!
Comment thread
schiller-manuel marked this conversation as resolved.
// this will perform a fuzzy match, however for server routes we need an exact match
// if the route is not an exact match, executeRouter will handle rendering the app router
// the match will be cached internally, so no extra work is done during the app router render
const [matchedRoutes, rawParams, foundRoute] =
router.getMatchedRoutes(pathname)
// The cached match avoids another route-tree traversal during the app router render.
const [matchedRoutes, rawParams, foundRoute] = router.getMatchedRoutes(
location.pathname,
)

const isExactMatch = foundRoute && rawParams['**'] === undefined

Expand Down
Loading
Loading