Skip to content

solid-router 2.0.0-rc.0: any useRouterState call desyncs hydration of later siblings (unclaimed dead DOM) #8096

Description

@TylerRick

On @tanstack/solid-router@2.0.0-rc.0 + solid-js@2.0.0-rc.0, a component that calls useRouterState({ select }) and has later siblings in its parent leaves those siblings unclaimed after hydration ("Hydration tag mismatch", "Hydration completed with N unclaimed server-rendered node(s)") — their SSR DOM stays visible but dead. A selector that returns a constant and is never read still triggers it: creating the memo is enough.

Root cause is in solid-js (filed as solidjs/solid#3012): useRouterState creates its select memo with the internal option { transparent: true }, and a transparent memo created during hydration shifts sibling hydration ids. Until solid-js fixes that (or useRouterState stops passing transparent), every solid-start app hits this the moment a useRouterState consumer isn't the last child of its parent.

Reproduction

Minimal solid-start reproduction (~60 lines): a route component rendering <main> + a footer that calls useRouterState + a <pre> sibling — the <pre> fails to claim:

import { createFileRoute, useRouterState } from '@tanstack/solid-router';
import { createSignal } from 'solid-js';

export const Route = createFileRoute('/')({ component: Page });

function Footer(props: { disabled: boolean }) {
	const navigationPending = useRouterState({
		select: (state) => {
			const resolved = state.resolvedLocation?.pathname;
			if (resolved === undefined) return false;
			return resolved !== state.location.pathname;
		},
	});
	return (
		<nav>
			<button id="continue" disabled={props.disabled || navigationPending()}>
				Continue
			</button>
		</nav>
	);
}

function Page() {
	const [checked, setChecked] = createSignal(false);
	return (
		<>
			<main>
				<label>
					<input
						type="checkbox"
						checked={checked()}
						onChange={(e) => setChecked(e.currentTarget.checked)}
					/>{' '}
					done
				</label>
			</main>
			<Footer disabled={!checked()} />
			<pre id="debug">debug: {String(checked())}</pre>
		</>
	);
}

Result on load: Hydration tag mismatch ... expected <pre> + Hydration completed with 1 unclaimed server-rendered node(s): <pre id="debug">... and the checkbox never enables the Continue button. Replace useRouterState with a plain accessor (below) or a constant and hydration is clean. A pure-solid reproduction with no TanStack packages (a bare createMemo(() => false, { transparent: true }) in the footer) fails identically — see the solid-js issue.

Workaround we ship meanwhile

Read the stores through a plain accessor instead of useRouterState — identical reactivity (the underlying stores are signals on the client), no memo creation during hydration:

const router = useRouter();
const navigationPending = () => {
	const resolved = router.stores.resolvedLocation.get()?.pathname;
	if (resolved === undefined) return false;
	return resolved !== router.stores.location.get().pathname;
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions