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
22 changes: 21 additions & 1 deletion packages/start-client-core/src/client/hydrateStart.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { hydrate } from '@tanstack/router-core/ssr/client'
import {
trimPathRight,
} from '@tanstack/router-core'

import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter'
import type { AnyStartInstanceOptions } from '../createStart'
Expand All @@ -8,6 +11,12 @@ import { getRouter } from '#tanstack-router-entry'
// eslint-disable-next-line import/no-duplicates,import/order
import { startInstance } from '#tanstack-start-entry'

declare global {
interface Window {
__TSS_SPA_SHELL__?: boolean
}
}

export async function hydrateStart(): Promise<AnyRouter> {
const router = await getRouter()

Expand Down Expand Up @@ -36,7 +45,18 @@ export async function hydrateStart(): Promise<AnyRouter> {
...{ serializationAdapters },
})
if (!router.state.matches.length) {
await hydrate(router)
if (
window.__TSS_SPA_SHELL__ &&
trimPathRight(window.location.pathname) !==
trimPathRight(router.options.basepath || '/')
) {
// If we are loading the SPA shell (index.html) and the path is not the root,
// it means we are in a fallback scenario (e.g. Cloudflare SPA fallback).
// The server-rendered content (Shell) will not match the client-side expected content (Deep Link).
// We skip hydration and let the RouterProvider mount the router freshly (Client Render).
} else {
await hydrate(router)
}
}

return router
Expand Down
194 changes: 194 additions & 0 deletions packages/start-client-core/tests/hydrateStart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { hydrateStart } from '../src/client/hydrateStart'
import { hydrate } from '@tanstack/router-core/ssr/client'
import type { AnyRouter } from '@tanstack/router-core'

declare global {
interface Window {
__TSS_START_OPTIONS__?: any
}
}

// Mocks
vi.mock('@tanstack/router-core/ssr/client', () => ({
hydrate: vi.fn(),
}))

// Mock factory to avoid duplication
const createMockRouter = (overrides = {}): Partial<AnyRouter> => ({
state: { matches: [] } as any,
options: { basepath: '/' } as any,
update: vi.fn(),
...overrides,
})

let mockGetRouter: ReturnType<typeof vi.fn>

vi.mock('#tanstack-router-entry', () => ({
get getRouter() {
if (!mockGetRouter) {
mockGetRouter = vi.fn(() => Promise.resolve(createMockRouter()))
}
return mockGetRouter
},
startInstance: undefined
}))

vi.mock('#tanstack-start-entry', () => ({
get getRouter() {
if (!mockGetRouter) {
mockGetRouter = vi.fn(() => Promise.resolve(createMockRouter()))
}
return mockGetRouter
},
startInstance: undefined
}))

describe('hydrateStart', () => {
beforeEach(() => {
// Ensure mockGetRouter is initialized
if (!mockGetRouter) {
mockGetRouter = vi.fn(() => Promise.resolve(createMockRouter()))
}

vi.clearAllMocks()
delete window.__TSS_SPA_SHELL__
delete window.__TSS_START_OPTIONS__
window.history.pushState({}, '', '/')
mockGetRouter.mockResolvedValue(createMockRouter())
})

describe('Normal hydration (no SPA shell)', () => {
it('should call hydrate(router) when no SPA shell marker is present', async () => {
const router = await hydrateStart()

expect(hydrate).toHaveBeenCalledWith(router)
expect(router.update).toHaveBeenCalledWith(
expect.objectContaining({
serializationAdapters: expect.any(Array)
})
)
})

it('should call hydrate(router) when SPA shell marker is present but path matches root', async () => {
window.__TSS_SPA_SHELL__ = true
window.history.pushState({}, '', '/')

const router = await hydrateStart()
expect(hydrate).toHaveBeenCalledWith(router)
})
})

describe('SPA shell fallback detection', () => {
it('should NOT call hydrate(router) when SPA shell marker is present and path is deep', async () => {
window.__TSS_SPA_SHELL__ = true
window.history.pushState({}, '', '/deep/link')

await hydrateStart()
expect(hydrate).not.toHaveBeenCalled()
})

it('should skip hydration for deep paths with trailing slash', async () => {
window.__TSS_SPA_SHELL__ = true
window.history.pushState({}, '', '/deep/link/')

await hydrateStart()
expect(hydrate).not.toHaveBeenCalled()
})
})

describe('Custom basepath handling', () => {
it('should hydrate when path matches basepath with trailing slash', async () => {
window.__TSS_SPA_SHELL__ = true
window.history.pushState({}, '', '/app/')

mockGetRouter.mockResolvedValueOnce(createMockRouter({
options: { basepath: '/app' }
}))

await hydrateStart()
expect(hydrate).toHaveBeenCalled() // /app/ matches basepath /app after normalization
})

it('should skip hydration when path is deeper than custom basepath', async () => {
window.__TSS_SPA_SHELL__ = true
window.history.pushState({}, '', '/app/dashboard')

mockGetRouter.mockResolvedValueOnce(createMockRouter({
options: { basepath: '/app' }
}))

await hydrateStart()
expect(hydrate).not.toHaveBeenCalled()
})

it('should handle empty basepath', async () => {
window.__TSS_SPA_SHELL__ = true
window.history.pushState({}, '', '/any-path')

mockGetRouter.mockResolvedValueOnce(createMockRouter({
options: { basepath: '' }
}))

await hydrateStart()
expect(hydrate).not.toHaveBeenCalled()
})
})

describe('Existing matches handling', () => {
it('should skip hydration when matches already exist', async () => {
mockGetRouter.mockResolvedValueOnce(createMockRouter({
state: { matches: [{ id: 'test-match' }] }
}))

await hydrateStart()
expect(hydrate).not.toHaveBeenCalled()
})

it('should not check SPA shell when matches exist', async () => {
window.__TSS_SPA_SHELL__ = true
window.history.pushState({}, '', '/deep/link')

mockGetRouter.mockResolvedValueOnce(createMockRouter({
state: { matches: [{ id: 'test-match' }] }
}))

await hydrateStart()
// Should skip entire hydration block, not just the SPA check
expect(hydrate).not.toHaveBeenCalled()
})
})

describe('Router configuration', () => {
it('should always call router.update with serializationAdapters', async () => {
const router = await hydrateStart()

expect(router.update).toHaveBeenCalledWith(
expect.objectContaining({
basepath: process.env.TSS_ROUTER_BASEPATH,
serializationAdapters: expect.arrayContaining([
expect.objectContaining({ key: expect.any(String) })
])
})
)
})

it('should merge router serializationAdapters if they exist', async () => {
const existingAdapter = { name: 'existing-adapter', serialize: vi.fn() }
mockGetRouter.mockResolvedValueOnce(createMockRouter({
options: {
basepath: '/',
serializationAdapters: [existingAdapter]
}
}))

const router = await hydrateStart()

expect(router.update).toHaveBeenCalledWith(
expect.objectContaining({
serializationAdapters: expect.arrayContaining([existingAdapter])
})
)
})
})
})
24 changes: 23 additions & 1 deletion packages/start-plugin-core/src/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,29 @@ export async function prerender({
routerBasePath,
)

const html = await res.text()
let html = await res.text()

if (isSpaShell) {
// Inject SPA shell marker for client-side detection
const headCloseTag = '</head>'
if (html.includes(headCloseTag)) {
html = html.replace(
headCloseTag,
'<script>window.__TSS_SPA_SHELL__ = true</script>' +
headCloseTag,
)
} else {
// Fallback: inject at the beginning of body if </head> not found
const bodyOpenTag = '<body'
if (html.includes(bodyOpenTag)) {
html = html.replace(
bodyOpenTag,
'<script>window.__TSS_SPA_SHELL__ = true</script>' +
bodyOpenTag,
)
}
}
}

const filepath = path.join(outputDir, filename)

Expand Down