From 0cf3e3a2d5fc51e1c148c57b9bf2c37a835cd8ec Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Thu, 11 Jun 2026 18:58:30 -0500 Subject: [PATCH] fix(solid-router): hydration mismatch for ssr:false routes with pendingComponent Selective SSR routes render the pending component inside the suspense boundary on the server and skip the suspense fallback. On the client, the fallback was only skipped for ssr: 'data-only', so hydrating an ssr: false route rendered a fallback with no matching server markup, crashing dev hydration with "template is not a function". Skip the fallback only while actually hydrating (via solid-js sharedConfig.context) so client-side navigations and pure CSR keep showing the pending component while the match suspends. Fixes #7283 Co-Authored-By: Claude Fable 5 --- .changeset/lucky-windows-shake.md | 5 +++ .../selective-ssr/src/routeTree.gen.ts | 34 +++++++++++++++++-- .../routes/ssr-false-pending-component.tsx | 31 +++++++++++++++++ .../tests/pending-component-hydration.spec.ts | 26 ++++++++++++++ packages/solid-router/src/Match.tsx | 13 ++++--- 5 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 .changeset/lucky-windows-shake.md create mode 100644 e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-component.tsx diff --git a/.changeset/lucky-windows-shake.md b/.changeset/lucky-windows-shake.md new file mode 100644 index 0000000000..bcd1031e3a --- /dev/null +++ b/.changeset/lucky-windows-shake.md @@ -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. diff --git a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts index df2b865e51..8473c00b48 100644 --- a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts +++ b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts @@ -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', @@ -40,12 +47,14 @@ 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 { @@ -53,18 +62,30 @@ export interface FileRoutesById { '/': 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 } @@ -72,10 +93,18 @@ 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' @@ -121,6 +150,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, DataOnlyPendingComponentRoute: DataOnlyPendingComponentRoute, PostsRoute: PostsRouteWithChildren, + SsrFalsePendingComponentRoute: SsrFalsePendingComponentRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-component.tsx b/e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-component.tsx new file mode 100644 index 0000000000..f97deeb6ed --- /dev/null +++ b/e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-component.tsx @@ -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: () => ( +
+ ), + component: SsrFalsePendingComponentRoute, +}) + +function SsrFalsePendingComponentRoute() { + const data = Route.useLoaderData() + + return ( +
+

+ OK - loader finished +

+
+        {JSON.stringify(data(), null, 2)}
+      
+
+ ) +} diff --git a/e2e/solid-start/selective-ssr/tests/pending-component-hydration.spec.ts b/e2e/solid-start/selective-ssr/tests/pending-component-hydration.spec.ts index 5d1055a570..c915fae17e 100644 --- a/e2e/solid-start/selective-ssr/tests/pending-component-hydration.spec.ts +++ b/e2e/solid-start/selective-ssr/tests/pending-component-hydration.spec.ts @@ -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'), + ) + }) }) diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 205e778689..1b354041a7 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -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 @@ -110,8 +115,6 @@ export const Match = (props: { matchId: string }) => { )