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
5 changes: 5 additions & 0 deletions .changeset/lucky-windows-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-router': patch
---

Fix hydration mismatch for `ssr: false` routes with a `pendingComponent`. The suspense fallback is now skipped only while hydrating server-rendered markup, so selective SSR routes hydrate cleanly while client-side navigations keep showing their pending component.
34 changes: 32 additions & 2 deletions e2e/solid-start/selective-ssr/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as SsrFalsePendingComponentRouteImport } from './routes/ssr-false-pending-component'
import { Route as PostsRouteImport } from './routes/posts'
import { Route as DataOnlyPendingComponentRouteImport } from './routes/data-only-pending-component'
import { Route as IndexRouteImport } from './routes/index'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'

const SsrFalsePendingComponentRoute =
SsrFalsePendingComponentRouteImport.update({
id: '/ssr-false-pending-component',
path: '/ssr-false-pending-component',
getParentRoute: () => rootRouteImport,
} as any)
const PostsRoute = PostsRouteImport.update({
id: '/posts',
path: '/posts',
Expand All @@ -40,42 +47,64 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/data-only-pending-component': typeof DataOnlyPendingComponentRoute
'/posts': typeof PostsRouteWithChildren
'/ssr-false-pending-component': typeof SsrFalsePendingComponentRoute
'/posts/$postId': typeof PostsPostIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/data-only-pending-component': typeof DataOnlyPendingComponentRoute
'/posts': typeof PostsRouteWithChildren
'/ssr-false-pending-component': typeof SsrFalsePendingComponentRoute
'/posts/$postId': typeof PostsPostIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/data-only-pending-component': typeof DataOnlyPendingComponentRoute
'/posts': typeof PostsRouteWithChildren
'/ssr-false-pending-component': typeof SsrFalsePendingComponentRoute
'/posts/$postId': typeof PostsPostIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/data-only-pending-component' | '/posts' | '/posts/$postId'
fullPaths:
| '/'
| '/data-only-pending-component'
| '/posts'
| '/ssr-false-pending-component'
| '/posts/$postId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/data-only-pending-component' | '/posts' | '/posts/$postId'
to:
| '/'
| '/data-only-pending-component'
| '/posts'
| '/ssr-false-pending-component'
| '/posts/$postId'
id:
| '__root__'
| '/'
| '/data-only-pending-component'
| '/posts'
| '/ssr-false-pending-component'
| '/posts/$postId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DataOnlyPendingComponentRoute: typeof DataOnlyPendingComponentRoute
PostsRoute: typeof PostsRouteWithChildren
SsrFalsePendingComponentRoute: typeof SsrFalsePendingComponentRoute
}

declare module '@tanstack/solid-router' {
interface FileRoutesByPath {
'/ssr-false-pending-component': {
id: '/ssr-false-pending-component'
path: '/ssr-false-pending-component'
fullPath: '/ssr-false-pending-component'
preLoaderRoute: typeof SsrFalsePendingComponentRouteImport
parentRoute: typeof rootRouteImport
}
'/posts': {
id: '/posts'
path: '/posts'
Expand Down Expand Up @@ -121,6 +150,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DataOnlyPendingComponentRoute: DataOnlyPendingComponentRoute,
PostsRoute: PostsRouteWithChildren,
SsrFalsePendingComponentRoute: SsrFalsePendingComponentRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createFileRoute } from '@tanstack/solid-router'
import { z } from 'zod'
import { ssrSchema } from '~/search'

export const Route = createFileRoute('/ssr-false-pending-component')({
validateSearch: z.object({ root: ssrSchema }),
ssr: false,
loader: async () => {
await new Promise((resolve) => setTimeout(resolve, 1500))
return { loadedAt: new Date().toISOString() }
},
pendingComponent: () => (
<div data-testid="ssr-false-pending-component-pending" />
),
component: SsrFalsePendingComponentRoute,
})

function SsrFalsePendingComponentRoute() {
const data = Route.useLoaderData()

return (
<div data-testid="ssr-false-pending-component-ready">
<p data-testid="ssr-false-pending-component-ready-label">
OK - loader finished
</p>
<pre data-testid="ssr-false-pending-component-ready-data">
{JSON.stringify(data(), null, 2)}
</pre>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,30 @@ test.describe('pending component hydration', () => {
expect.stringContaining('Hydration Mismatch'),
)
})

test('ssr:false route hydrates from pending element to loaded state on first load', async ({
page,
}) => {
const browserErrors = collectBrowserErrors(page)

await page.goto('/ssr-false-pending-component')

await expect(page).toHaveURL(/ssr-false-pending-component/)

await expect(
page.getByTestId('ssr-false-pending-component-pending'),
).toBeAttached()

await expect(
page.getByTestId('ssr-false-pending-component-ready-label'),
).toHaveText('OK - loader finished', { timeout: 10_000 })

expect(browserErrors).toEqual([])
expect(browserErrors).not.toContainEqual(
expect.stringContaining('template is not a function'),
)
expect(browserErrors).not.toContainEqual(
expect.stringContaining('Hydration Mismatch'),
)
})
})
13 changes: 8 additions & 5 deletions packages/solid-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,15 @@ export const Match = (props: { matchId: string }) => {
currentMatchState().ssr === false ||
currentMatchState().ssr === 'data-only'

// Selective SSR (`ssr: false` and `ssr: 'data-only'`) renders the
// pending component inside the suspense boundary on the server, so
// rendering a suspense fallback while hydrating would try to claim
// markup the server never sent, causing a hydration mismatch. Outside
// of hydration (client-side navigation or pure CSR) the fallback must
// stay so the pending component can show while the match suspends.
const shouldSkipSuspenseFallback =
(isServer ?? router.isServer)
? resolvedNoSsr
: currentMatchState().ssr === 'data-only'
resolvedNoSsr &&
((isServer ?? router.isServer) || !!Solid.sharedConfig.context)

const ResolvedSuspenseBoundary = () => Solid.Suspense

Expand All @@ -110,8 +115,6 @@ export const Match = (props: { matchId: string }) => {
<Dynamic
component={ResolvedSuspenseBoundary()}
fallback={
// Data-only SSR renders the inner fallback on the server, so
// avoid adding an extra suspense fallback on the client.
shouldSkipSuspenseFallback ? undefined : (
<Dynamic component={resolvePendingComponent()} />
)
Expand Down
Loading