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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions