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 }) => { )