From 2150ba40d7f94db8d17403ddf6ce90d78072cc53 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 15 Sep 2026 21:22:34 +0200 Subject: [PATCH 1/3] fix(vue-router): avoid hash-dependent Link hydration mismatches --- .changeset/sixty-rings-find.md | 5 + packages/vue-router/src/link.tsx | 20 +- .../tests/link-hash-hydration.test.tsx | 328 ++++++++++++++++++ 3 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 .changeset/sixty-rings-find.md create mode 100644 packages/vue-router/tests/link-hash-hydration.test.tsx diff --git a/.changeset/sixty-rings-find.md b/.changeset/sixty-rings-find.md new file mode 100644 index 0000000000..44bb036553 --- /dev/null +++ b/.changeset/sixty-rings-find.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-router': patch +--- + +Fix Link hydration when the client URL has a fragment. Match the server's empty hash for initial active state and inherited or function-based hash hrefs, then update to the client hash after hydration. Client-only mounts continue to use the live hash immediately. diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 6d2fea690c..e65a032648 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -189,11 +189,27 @@ function useLinkPropsImpl( }) } + // Vue assigns vnode.el before setup only when reusing server DOM. A fresh + // client mount must use the live hash immediately, even on an SSR router. + const hydrating = Vue.ref(Vue.getCurrentInstance()?.vnode.el != null) + Vue.onMounted(() => { + hydrating.value = false + }) + + const renderLocation = Vue.computed(() => { + const location = currentLocation.value + // Fragments are not sent to the server. Reproduce its empty hash for both + // active matching and inherited/function hash hrefs until hydration ends. + return hydrating.value && location.hash + ? { ...location, hash: '' } + : location + }) + const next = Vue.computed(() => { // Rebuild when inherited search/hash or the current route context changes. const options = getOptions() - const opts = { _fromLocation: currentLocation.value, ...options } + const opts = { _fromLocation: renderLocation.value, ...options } return router.buildLocation(opts) }) @@ -230,7 +246,7 @@ function useLinkPropsImpl( return false } return getIsActive( - currentLocation.value, + renderLocation.value, next.value, options.activeOptions, router, diff --git a/packages/vue-router/tests/link-hash-hydration.test.tsx b/packages/vue-router/tests/link-hash-hydration.test.tsx new file mode 100644 index 0000000000..1622790e12 --- /dev/null +++ b/packages/vue-router/tests/link-hash-hydration.test.tsx @@ -0,0 +1,328 @@ +import * as Vue from 'vue' +import { afterEach, expect, test, vi } from 'vitest' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { + Link, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import { createRequestHandler, renderRouterToString } from '../src/ssr/server' +import type { AnyRoute, LinkOptions } from '../src' + +const cleanups: Array<() => void> = [] + +afterEach(() => { + while (cleanups.length) { + cleanups.pop()!() + } + vi.restoreAllMocks() + delete window.$_TSR + document.body.innerHTML = '' +}) + +function makeRouter( + isServer: boolean, + url: string, + component: AnyRoute['options']['component'], +) { + const root = createRootRoute() + const index = createRoute({ + getParentRoute: () => root, + path: '/', + component, + }) + const router = createRouter({ + routeTree: root.addChildren([index]), + history: createMemoryHistory({ initialEntries: [url] }), + isServer, + defaultHashScrollIntoView: false, + }) + cleanups.push(() => router.history.destroy()) + return router +} + +async function prepareHydration( + component: AnyRoute['options']['component'], + clientUrl = '/#details', +) { + const response = await createRequestHandler({ + request: new Request('http://localhost/'), + createRouter: () => makeRouter(true, '/', component), + })(({ router, responseHeaders }) => + renderRouterToString({ + router, + responseHeaders, + App: Vue.defineComponent({ + inheritAttrs: false, + setup: () => () => ( + + + +
+ +
+ + + ), + }), + }), + ) + expect(response.status).toBe(200) + const serverDocument = new DOMParser().parseFromString( + await response.text(), + 'text/html', + ) + const container = document.createElement('div') + container.innerHTML = serverDocument.getElementById('app')!.innerHTML + document.body.appendChild(container) + // Vitest's jsdom does not execute appended scripts. Evaluate only the public + // SSR handler's bootstrap, supplying the browser's currentScript for cleanup. + const currentScript = vi.spyOn(document, 'currentScript', 'get') + try { + for (const script of serverDocument.querySelectorAll('script')) { + currentScript.mockReturnValue(script) + new Function(script.textContent ?? '')() + } + } finally { + currentScript.mockRestore() + } + const router = makeRouter(false, clientUrl, component) + await hydrate(router) + window.$_TSR!.h() + const app = Vue.createSSRApp({ + setup: () => () => , + }) + return { + container, + router, + mount() { + app.mount(container) + cleanups.push(() => app.unmount()) + }, + } +} + +test('hydrates a hash-sensitive Link against the server HTML before applying the client hash', async () => { + const page = Vue.defineComponent({ + setup: () => () => ( + + {({ isActive }: { isActive: boolean }) => String(isActive)} + + ), + }) + const { container, mount } = await prepareHydration(page) + const anchor = container.querySelector('a')! + expect(anchor.getAttribute('href')).toBe('/#details') + expect(anchor.className).toBe('') + expect(anchor.getAttribute('aria-current')).toBeNull() + expect(anchor.textContent).toBe('false') + + const warn = vi.spyOn(console, 'warn') + const error = vi.spyOn(console, 'error') + mount() + expect(warn).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + expect(container.querySelector('a')).toBe(anchor) + expect(anchor.textContent).toBe('false') + + await Vue.nextTick() + expect(anchor.className).toBe('active') + expect(anchor.getAttribute('aria-current')).toBe('page') + expect(anchor.textContent).toBe('true') + expect(container.querySelector('a')).toBe(anchor) +}) + +test.each(['/#details', '/'])( + 'hydrates hashes at %s and follows hash/activeOptions changes', + checkHashHydration, +) + +async function checkHashHydration(clientUrl: string) { + const includeHash = Vue.ref(true) + const cases: Array<{ + id: string + hash?: LinkOptions['hash'] + insensitive?: boolean + server: [string, boolean] + details: [string, boolean] + other: [string, boolean] + }> = [ + { + id: 'matching', + hash: 'details', + server: ['/#details', false], + details: ['/#details', true], + other: ['/#details', false], + }, + { + id: 'nonmatching', + hash: 'other', + server: ['/#other', false], + details: ['/#other', false], + other: ['/#other', true], + }, + { + id: 'empty', + hash: '', + server: ['/', true], + details: ['/', false], + other: ['/', false], + }, + { + id: 'inherited', + hash: true, + server: ['/', true], + details: ['/#details', true], + other: ['/#other', true], + }, + { + id: 'function', + hash: (previous = '') => `${previous}-child`, + server: ['/#-child', false], + details: ['/#details-child', false], + other: ['/#other-child', false], + }, + { + id: 'inherited-insensitive', + hash: true, + insensitive: true, + server: ['/', true], + details: ['/#details', true], + other: ['/#other', true], + }, + { + id: 'ordinary', + insensitive: true, + server: ['/', true], + details: ['/', true], + other: ['/', true], + }, + ] + const page = Vue.defineComponent({ + setup: () => () => ( + + ), + }) + const { container, mount, router } = await prepareHydration(page, clientUrl) + const anchors = Array.from(container.querySelectorAll('a')) + function check(phase: 'server' | 'details' | 'other', ignoreHash = false) { + for (const [index, entry] of cases.entries()) { + const anchor = container.querySelector(`#${entry.id}`)! + const [href, active] = entry[phase] + const isActive = ignoreHash || active + expect(anchor).toBe(anchors[index]) + expect(anchor.getAttribute('href')).toBe(href) + expect(anchor.className).toBe(isActive ? 'active' : 'inactive') + expect(anchor.getAttribute('aria-current')).toBe(isActive ? 'page' : null) + expect(anchor.getAttribute('data-status')).toBe( + isActive ? 'active' : null, + ) + expect(anchor.textContent).toBe(String(isActive)) + } + } + check('server') + const warn = vi.spyOn(console, 'warn') + const error = vi.spyOn(console, 'error') + mount() + check('server') + await Vue.nextTick() + check(clientUrl === '/' ? 'server' : 'details') + + await router.navigate({ to: '/', hash: 'other' }) + await Vue.nextTick() + check('other') + includeHash.value = false + await Vue.nextTick() + check('other', true) + includeHash.value = true + await Vue.nextTick() + check('other') + expect(warn).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() +} + +test('uses the live hash on the first render of a client-only mount', async () => { + const renders: Array = [] + const page = Vue.defineComponent({ + setup: () => () => ( + + ), + }) + const router = makeRouter(false, '/#details', page) + await router.load() + const container = document.createElement('div') + document.body.appendChild(container) + const app = Vue.createApp({ + setup: () => () => , + }) + app.mount(container) + cleanups.push(() => app.unmount()) + expect(renders).toEqual([true, true]) + for (const anchor of container.querySelectorAll('a')) { + expect(anchor.getAttribute('href')).toBe('/#details') + expect(anchor.className).toBe('active') + expect(anchor.getAttribute('aria-current')).toBe('page') + } + await Vue.nextTick() + expect(renders.every(Boolean)).toBe(true) +}) + +test('new Links mounted after hydration use the live hash immediately', async () => { + const show = Vue.ref(false) + const renders: Array = [] + const page = Vue.defineComponent({ + setup: () => () => ( +
+ {show.value && ( + + {({ isActive }: { isActive: boolean }) => { + renders.push(isActive) + return String(isActive) + }} + + )} +
+ ), + }) + const { container, mount } = await prepareHydration(page) + const warn = vi.spyOn(console, 'warn') + const error = vi.spyOn(console, 'error') + mount() + await Vue.nextTick() + show.value = true + await Vue.nextTick() + const anchor = container.querySelector('a')! + expect(renders[0]).toBe(true) + expect(anchor.getAttribute('href')).toBe('/#details') + expect(anchor.className).toBe('active') + expect(anchor.getAttribute('aria-current')).toBe('page') + expect(warn).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() +}) From 9c89651958487b8372c5edf442413718c514d5d4 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 15 Sep 2026 23:30:51 +0200 Subject: [PATCH 2/3] fix(router): align hash-dependent Link hydration across adapters --- .changeset/sixty-rings-find.md | 4 +- e2e/solid-start/basic/src/routeTree.gen.ts | 21 ++ .../basic/src/routes/link-hash-hydration.tsx | 115 ++++++ .../basic/tests/link-hash-hydration.spec.ts | 139 +++++++ packages/react-router/src/ClientOnly.tsx | 11 +- packages/react-router/src/link.tsx | 34 +- .../tests/link-hash-hydration.test.tsx | 349 ++++++++++++++++++ packages/solid-router/src/link.tsx | 30 +- .../solid-router/tests/link-hash.test.tsx | 96 +++++ packages/vue-router/src/link.tsx | 36 +- .../tests/link-hash-hydration.test.tsx | 232 ++++++++++-- 11 files changed, 998 insertions(+), 69 deletions(-) create mode 100644 e2e/solid-start/basic/src/routes/link-hash-hydration.tsx create mode 100644 e2e/solid-start/basic/tests/link-hash-hydration.spec.ts create mode 100644 packages/react-router/tests/link-hash-hydration.test.tsx create mode 100644 packages/solid-router/tests/link-hash.test.tsx diff --git a/.changeset/sixty-rings-find.md b/.changeset/sixty-rings-find.md index 44bb036553..11dfc0813f 100644 --- a/.changeset/sixty-rings-find.md +++ b/.changeset/sixty-rings-find.md @@ -1,5 +1,7 @@ --- +'@tanstack/react-router': patch +'@tanstack/solid-router': patch '@tanstack/vue-router': patch --- -Fix Link hydration when the client URL has a fragment. Match the server's empty hash for initial active state and inherited or function-based hash hrefs, then update to the client hash after hydration. Client-only mounts continue to use the live hash immediately. +Compute hash-sensitive Link active states and inherited or function-derived hash hrefs from the server's empty hash during hydration. Use the live hash after hydration and immediately for client-only mounts, without a hydration update for ordinary links. diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index 07ba58b219..3f01b45c13 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as DeferredRouteImport } from './routes/deferred' import { Route as DeferredWithoutSuspenseRouteImport } from './routes/deferred-without-suspense' import { Route as ErrorNormalizationRouteImport } from './routes/error-normalization' import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' +import { Route as LinkHashHydrationRouteImport } from './routes/link-hash-hydration' import { Route as LinksRouteImport } from './routes/links' import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' import { Route as PostsRouteImport } from './routes/posts' @@ -100,6 +101,11 @@ const InlineScriptsRoute = InlineScriptsRouteImport.update({ path: '/inline-scripts', getParentRoute: () => rootRouteImport, } as any) +const LinkHashHydrationRoute = LinkHashHydrationRouteImport.update({ + id: '/link-hash-hydration', + path: '/link-hash-hydration', + getParentRoute: () => rootRouteImport, +} as any) const LinksRoute = LinksRouteImport.update({ id: '/links', path: '/links', @@ -401,6 +407,7 @@ export interface FileRoutesByFullPath { '/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute '/error-normalization': typeof ErrorNormalizationRoute '/inline-scripts': typeof InlineScriptsRoute + '/link-hash-hydration': typeof LinkHashHydrationRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/raw-stream': typeof RawStreamRouteWithChildren @@ -460,6 +467,7 @@ export interface FileRoutesByTo { '/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute '/error-normalization': typeof ErrorNormalizationRoute '/inline-scripts': typeof InlineScriptsRoute + '/link-hash-hydration': typeof LinkHashHydrationRoute '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute @@ -519,6 +527,7 @@ export interface FileRoutesById { '/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute '/error-normalization': typeof ErrorNormalizationRoute '/inline-scripts': typeof InlineScriptsRoute + '/link-hash-hydration': typeof LinkHashHydrationRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/raw-stream': typeof RawStreamRouteWithChildren @@ -583,6 +592,7 @@ export interface FileRouteTypes { | '/deferred-without-suspense' | '/error-normalization' | '/inline-scripts' + | '/link-hash-hydration' | '/links' | '/posts' | '/raw-stream' @@ -642,6 +652,7 @@ export interface FileRouteTypes { | '/deferred-without-suspense' | '/error-normalization' | '/inline-scripts' + | '/link-hash-hydration' | '/links' | '/scripts' | '/stream' @@ -700,6 +711,7 @@ export interface FileRouteTypes { | '/deferred-without-suspense' | '/error-normalization' | '/inline-scripts' + | '/link-hash-hydration' | '/links' | '/posts' | '/raw-stream' @@ -764,6 +776,7 @@ export interface RootRouteChildren { DeferredWithoutSuspenseRoute: typeof DeferredWithoutSuspenseRoute ErrorNormalizationRoute: typeof ErrorNormalizationRoute InlineScriptsRoute: typeof InlineScriptsRoute + LinkHashHydrationRoute: typeof LinkHashHydrationRoute LinksRoute: typeof LinksRoute PostsRoute: typeof PostsRouteWithChildren RawStreamRoute: typeof RawStreamRouteWithChildren @@ -824,6 +837,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof InlineScriptsRouteImport parentRoute: typeof rootRouteImport } + '/link-hash-hydration': { + id: '/link-hash-hydration' + path: '/link-hash-hydration' + fullPath: '/link-hash-hydration' + preLoaderRoute: typeof LinkHashHydrationRouteImport + parentRoute: typeof rootRouteImport + } '/links': { id: '/links' path: '/links' @@ -1420,6 +1440,7 @@ const rootRouteChildren: RootRouteChildren = { DeferredWithoutSuspenseRoute: DeferredWithoutSuspenseRoute, ErrorNormalizationRoute: ErrorNormalizationRoute, InlineScriptsRoute: InlineScriptsRoute, + LinkHashHydrationRoute: LinkHashHydrationRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, RawStreamRoute: RawStreamRouteWithChildren, diff --git a/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx b/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx new file mode 100644 index 0000000000..dc9ad90651 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx @@ -0,0 +1,115 @@ +import { createSignal, onMount } from 'solid-js' +import { + Link, + createFileRoute, + createLink, + useRouter, +} from '@tanstack/solid-router' +import type { ComponentProps } from 'solid-js' +import type { LinkOptions } from '@tanstack/solid-router' + +declare global { + interface Window { + initialHashLinks: Record + } +} + +const cases: Array<{ + id: string + hash?: LinkOptions['hash'] + insensitive?: boolean + sourceHash?: string + href?: string +}> = [ + { id: 'explicit-source', hash: true, sourceHash: 'preset' }, + { + id: 'explicit-source-function', + hash: (hash = '') => `${hash}-child`, + sourceHash: 'preset', + }, + { + id: 'explicit-href', + href: '/link-hash-hydration#fixed', + hash: () => { + throw new Error('href overrides hash') + }, + }, + { id: 'matching', hash: 'details' }, + { id: 'nonmatching', hash: 'other' }, + { id: 'empty', hash: '' }, + { id: 'omitted' }, + { id: 'inherited', hash: true }, + { id: 'identity', hash: (hash = '') => hash }, + { id: 'derived', hash: (hash) => `${hash}-child` }, + { id: 'inherited-insensitive', hash: true, insensitive: true }, + { + id: 'derived-insensitive', + hash: (hash) => `${hash}-child`, + insensitive: true, + }, + { id: 'ordinary', insensitive: true }, +] + +// Capture the first client props, before onMount can conceal a mismatch. +const ObservedLink = createLink((props: ComponentProps<'a'>) => { + if (typeof window !== 'undefined') { + window.initialHashLinks ??= {} + window.initialHashLinks[props.id!] ??= { + href: props.href, + active: props['aria-current'] === 'page', + } + } + return +}) + +export const Route = createFileRoute('/link-hash-hydration')({ + component: Page, +}) + +function Page() { + const router = useRouter() + const [mounted, setMounted] = createSignal(false) + const [includeHash, setIncludeHash] = createSignal(true) + const [show, setShow] = createSignal(false) + onMount(() => setMounted(true)) + return ( +
+ {cases.map((entry) => ( + + {({ isActive }) => + isActive ? active : inactive + } + + ))} + + + {show() && ( + + {({ isActive }) => String(isActive)} + + )} + + Other hash + +
+ ) +} diff --git a/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts b/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts new file mode 100644 index 0000000000..4d473fe09f --- /dev/null +++ b/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from '@playwright/test' + +test('hash-dependent links reproduce the server state before using the browser hash', async ({ + page, +}) => { + test.skip(process.env.MODE === 'spa', 'Requires server-rendered links') + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (/hydration|mismatch/i.test(message.text())) { + errors.push(message.text()) + } + }) + let releaseScripts!: () => void + const scripts = new Promise((resolve) => { + releaseScripts = resolve + }) + await page.route('**/*.js', async (route) => { + await scripts + await route.continue() + }) + const path = '/link-hash-hydration' + const cases = [ + { + id: 'explicit-source', + server: ['#preset', false], + client: ['#preset', false], + }, + { + id: 'explicit-source-function', + server: ['#preset-child', false], + client: ['#preset-child', false], + }, + { + id: 'explicit-href', + server: ['#fixed', false], + client: ['#fixed', false], + }, + { id: 'matching', server: ['#details', false], client: ['#details', true] }, + { id: 'nonmatching', server: ['#other', false], client: ['#other', false] }, + { id: 'empty', server: ['', true], client: ['', false] }, + { id: 'omitted', server: ['', true], client: ['', false] }, + { id: 'inherited', server: ['', true], client: ['#details', true] }, + { id: 'identity', server: ['', true], client: ['#details', true] }, + { + id: 'derived', + server: ['#-child', false], + client: ['#details-child', false], + }, + { + id: 'inherited-insensitive', + server: ['', true], + client: ['#details', true], + }, + { + id: 'derived-insensitive', + server: ['#-child', true], + client: ['#details-child', true], + }, + { id: 'ordinary', server: ['', true], client: ['', true] }, + ] as const + await page.goto(`${path}#details`, { waitUntil: 'commit' }) + try { + for (const entry of cases) { + const anchor = page.locator(`#${entry.id}`) + await expect(anchor).toHaveAttribute('href', path + entry.server[0]) + await expect(anchor).toHaveText(entry.server[1] ? 'active' : 'inactive') + if (entry.server[1]) { + await expect(anchor).toHaveAttribute('aria-current', 'page') + await expect(anchor).toHaveAttribute('data-status', 'active') + } else { + await expect(anchor).not.toHaveAttribute('aria-current') + await expect(anchor).not.toHaveAttribute('data-status') + } + } + await page.evaluate(() => { + const anchors = Array.from( + document.querySelectorAll('[data-testid="hash-links"] a'), + ) + ;(window as any).serverHashLinks = anchors + }) + } finally { + releaseScripts() + } + await expect(page.getByTestId('hash-links')).toHaveAttribute( + 'data-mounted', + 'true', + ) + const initial = await page.evaluate(() => window.initialHashLinks) + for (const entry of cases) { + expect(initial[entry.id], entry.id).toEqual({ + href: path + entry.server[0], + active: entry.server[1], + }) + const anchor = page.locator(`#${entry.id}`) + await expect(anchor).toHaveAttribute('href', path + entry.client[0]) + await expect(anchor).toContainClass(entry.client[1] ? 'active' : 'inactive') + await expect(anchor).toHaveText(entry.client[1] ? 'active' : 'inactive') + if (entry.client[1]) { + await expect(anchor).toHaveAttribute('aria-current', 'page') + await expect(anchor).toHaveAttribute('data-status', 'active') + } else { + await expect(anchor).not.toHaveAttribute('aria-current') + await expect(anchor).not.toHaveAttribute('data-status') + } + await expect( + anchor.locator(entry.client[1] ? 'strong' : 'em'), + ).toBeVisible() + } + expect( + await page.evaluate(() => + (window as any).serverHashLinks.every( + (anchor: Element) => document.getElementById(anchor.id) === anchor, + ), + ), + ).toBe(true) + await page.getByRole('button', { name: 'Mount link' }).click() + expect(await page.evaluate(() => window.initialHashLinks.later)).toEqual({ + href: `${path}#details`, + active: true, + }) + await page.getByRole('button', { name: 'Toggle hash matching' }).click() + await expect(page.locator('#empty')).toContainClass('active') + await expect(page.locator('#nonmatching')).toContainClass('active') + await page.getByRole('button', { name: 'Toggle hash matching' }).click() + await expect(page.locator('#empty')).toContainClass('inactive') + await page.locator('#navigate-other').click() + await expect(page.locator('#matching')).toContainClass('inactive') + await expect(page.locator('#nonmatching')).toContainClass('active') + await expect(page.locator('#inherited')).toHaveAttribute( + 'href', + `${path}#other`, + ) + await expect(page.locator('#derived')).toHaveAttribute( + 'href', + `${path}#other-child`, + ) + expect(errors).toEqual([]) +}) diff --git a/packages/react-router/src/ClientOnly.tsx b/packages/react-router/src/ClientOnly.tsx index c139b05d0a..c32f6a49a7 100644 --- a/packages/react-router/src/ClientOnly.tsx +++ b/packages/react-router/src/ClientOnly.tsx @@ -56,8 +56,15 @@ export function ClientOnly({ children, fallback = null }: ClientOnlyProps) { * ``` * @returns True if the JS has been hydrated already, false otherwise. */ -export function useHydrated(): boolean { - return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +export function useHydrated(): boolean +/** @internal Skip the hydration update for consumers that don't need it. */ +export function useHydrated(enabled: boolean): boolean +export function useHydrated(enabled = true): boolean { + return React.useSyncExternalStore( + subscribe, + getSnapshot, + enabled ? getServerSnapshot : getSnapshot, + ) } function subscribe() { diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 26ec05a848..58a66aa7cf 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -92,7 +92,7 @@ function resolveIsActive( next: ParsedLocation, activeOptions: ActiveOptions | undefined, basepath: string, - isHydrated: boolean, + hydrating = false, ): boolean { const currentPath = removeTrailingSlash(location.pathname, basepath) const nextPath = removeTrailingSlash(next.pathname, basepath) @@ -123,7 +123,7 @@ function resolveIsActive( } if (activeOptions?.includeHash) { - return isHydrated && location.hash === next.hash + return (hydrating ? '' : location.hash) === next.hash } return true } @@ -256,8 +256,12 @@ export function useLinkProps< onTouchStart, } = options as typeof options & { to?: string } + const hashFromLocation = + !options.href && + !options._fromLocation && + (options.hash === true || typeof options.hash === 'function') // eslint-disable-next-line react-hooks/rules-of-hooks - const isHydrated = useHydrated() + const isHydrated = useHydrated(activeOptions?.includeHash || hashFromLocation) // eslint-disable-next-line react-hooks/rules-of-hooks const [stableSearch, stableParams, stableActiveOptions] = useStableValues( @@ -277,6 +281,7 @@ export function useLinkProps< options.from, options._fromLocation, options.hash, + options.href, options.to, stableSearch, stableParams, @@ -303,7 +308,13 @@ export function useLinkProps< if (!_options._fromLocation) { dest._fromLocation = location } - const next = router.buildLocation(dest) + // Only hash-dependent destinations need the server's empty hash. Keep + // the source location identity so links share the route-match cache. + const next = router.buildLocation( + !isHydrated && hashFromLocation + ? { ...dest, hash: dest.hash === true ? '' : dest.hash('') } + : dest, + ) // Use publicHref - it contains the correct href for display // When a rewrite changes the origin, publicHref is the full URL @@ -319,11 +330,20 @@ export function useLinkProps< next, stableActiveOptions, router.basepath, - isHydrated, + !isHydrated, ), ] }, - [stableActiveOptions, disabled, isHydrated, _options, dest, router, to], + [ + stableActiveOptions, + disabled, + isHydrated, + hashFromLocation, + _options, + dest, + router, + to, + ], ) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -609,7 +629,6 @@ function getServerLinkProps( } const blockedLink = !disabled && !hrefOption - // Hash is not available on the server, so it never counts as hydrated. const isActive = !!next && !blockedLink && @@ -618,7 +637,6 @@ function getServerLinkProps( next, activeOptions, router.basepath, - false, ) return applyLinkState( props, diff --git a/packages/react-router/tests/link-hash-hydration.test.tsx b/packages/react-router/tests/link-hash-hydration.test.tsx new file mode 100644 index 0000000000..83e7dcbaa9 --- /dev/null +++ b/packages/react-router/tests/link-hash-hydration.test.tsx @@ -0,0 +1,349 @@ +import React from 'react' +import { renderToString } from 'react-dom/server' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { act } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Link, + RouterContextProvider, + createLink, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' +import type { LinkOptions } from '../src' + +const cleanups: Array<() => void> = [] + +afterEach(async () => { + await act(() => { + while (cleanups.length) { + cleanups.pop()!() + } + }) + vi.restoreAllMocks() +}) + +function makeRouter(isServer: boolean, url: string) { + const router = createRouter({ + routeTree: createRootRoute(), + history: createMemoryHistory({ initialEntries: [url] }), + isServer, + ssr: {}, + defaultHashScrollIntoView: false, + }) + cleanups.push(() => router.history.destroy()) + return router +} + +const cases: Array<{ + name: string + hash?: LinkOptions['hash'] + href?: string + sourceHash?: string + includeHash?: boolean + server: [string, boolean] + client: [string, boolean] +}> = [ + { + name: 'explicit-source', + hash: true, + sourceHash: 'preset', + includeHash: true, + server: ['/#preset', false], + client: ['/#preset', false], + }, + { + name: 'explicit-source-function', + hash: (hash = '') => `${hash}-child`, + sourceHash: 'preset', + includeHash: true, + server: ['/#preset-child', false], + client: ['/#preset-child', false], + }, + { + name: 'explicit-href', + href: '/#fixed', + hash: () => { + throw new Error('href overrides hash') + }, + includeHash: true, + server: ['/#fixed', false], + client: ['/#fixed', false], + }, + { + name: 'matching', + hash: 'details', + includeHash: true, + server: ['/#details', false], + client: ['/#details', true], + }, + { + name: 'nonmatching', + hash: 'other', + includeHash: true, + server: ['/#other', false], + client: ['/#other', false], + }, + { + name: 'empty', + hash: '', + includeHash: true, + server: ['/', true], + client: ['/', false], + }, + { + name: 'omitted', + includeHash: true, + server: ['/', true], + client: ['/', false], + }, + { + name: 'inherited', + hash: true, + includeHash: true, + server: ['/', true], + client: ['/#details', true], + }, + { + name: 'identity', + hash: (hash = '') => hash, + includeHash: true, + server: ['/', true], + client: ['/#details', true], + }, + { + name: 'derived', + hash: (hash) => `${hash}-child`, + includeHash: true, + server: ['/#-child', false], + client: ['/#details-child', false], + }, + { + name: 'inherited-insensitive', + hash: true, + server: ['/', true], + client: ['/#details', true], + }, + { + name: 'derived-insensitive', + hash: (hash) => `${hash}-child`, + server: ['/#-child', true], + client: ['/#details-child', true], + }, + { name: 'ordinary', server: ['/', true], client: ['/', true] }, +] + +test.each( + cases.flatMap((entry) => + ['/', '/#details'].map((url) => ({ ...entry, url })), + ), +)( + 'hydrates $name links at $url without changing server DOM during hydration', + async (entry) => { + const renders: Array = [] + const source = entry.sourceHash + ? makeRouter(true, `/#${entry.sourceHash}`).stores.location.get() + : undefined + const link = ( + + {({ isActive }) => { + renders.push(isActive) + return String(isActive) + }} + + ) + const container = document.createElement('div') + const server = makeRouter(true, '/') + container.innerHTML = renderToString( + {link}, + ) + const anchor = container.querySelector('a')! + function check([href, active]: [string, boolean]) { + expect(container.querySelector('a')).toBe(anchor) + expect(anchor.getAttribute('href')).toBe(href) + expect(anchor.className).toBe(active ? 'active' : 'inactive') + expect(anchor.getAttribute('aria-current')).toBe(active ? 'page' : null) + expect(anchor.getAttribute('data-status')).toBe(active ? 'active' : null) + expect(anchor.textContent).toBe(String(active)) + } + check(entry.server) + renders.length = 0 + const client = makeRouter(false, entry.url) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const recoverable = vi.fn() + await act(() => { + const root = hydrateRoot( + container, + {link}, + { onRecoverableError: recoverable }, + ) + cleanups.push(() => root.unmount()) + }) + expect(renders[0]).toBe(entry.server[1]) + check(entry.url === '/' ? entry.server : entry.client) + expect(client.stores.location.get().hash).toBe( + entry.url === '/' ? '' : 'details', + ) + expect(error).not.toHaveBeenCalled() + expect(recoverable).not.toHaveBeenCalled() + if (entry.name === 'ordinary') { + expect(renders).toEqual([true]) + } + + await act(() => client.navigate({ to: '/', hash: 'other' })) + expect(container.querySelector('a')).toBe(anchor) + const inheritsHash = entry.hash === true || typeof entry.hash === 'function' + check([ + inheritsHash + ? entry.client[0].replace('details', 'other') + : entry.client[0], + !entry.includeHash || + ['nonmatching', 'inherited', 'identity'].includes(entry.name), + ]) + await act(() => client.navigate({ to: '/', hash: '' })) + check(entry.server) + }, +) + +test.each( + [false, true].flatMap((later) => [ + { + later, + hash: 'details' as LinkOptions['hash'], + href: '/#details', + active: true, + }, + { later, hash: true as const, href: '/#details', active: true }, + { + later, + hash: (hash = '') => `${hash}-child`, + href: '/#details-child', + active: false, + }, + ]), +)( + 'uses live hashes on the first client-only render ($href, later mount: $later)', + async ({ later, hash, href, active }) => { + const router = makeRouter(false, '/#details') + const renders: Array<{ href?: string; active: boolean }> = [] + const ObservedLink = createLink( + React.forwardRef>( + (props, ref) => { + renders.push({ + href: props.href, + active: props['aria-current'] === 'page', + }) + return
+ }, + ), + ) + const container = document.createElement('div') + const tree = (show: boolean) => ( + + {show && ( + + {({ isActive }) => String(isActive)} + + )} + + ) + let root: ReturnType + await act(() => { + if (later) { + container.innerHTML = renderToString(tree(false)) + root = hydrateRoot(container, tree(false)) + } else { + root = createRoot(container) + } + cleanups.push(() => root.unmount()) + }) + await act(() => root.render(tree(true))) + expect(renders[0]).toEqual({ href, active }) + expect(container.querySelector('a')).toHaveAttribute('href', href) + }, +) + +test('hash-dependent links share current-route validation while hydrating', async () => { + const validateSearch = vi.fn((search: Record) => search) + const make = (isServer: boolean, url: string) => { + const router = createRouter({ + routeTree: createRootRoute({ validateSearch }), + history: createMemoryHistory({ initialEntries: [url] }), + isServer, + }) + cleanups.push(() => router.history.destroy()) + return router + } + const links = Array.from({ length: 10 }, (_, index) => ( + + Link + + )) + const container = document.createElement('div') + container.innerHTML = renderToString( + + {links} + , + ) + const client = make(false, '/?q=test#details') + validateSearch.mockClear() + await act(() => { + const root = hydrateRoot( + container, + {links}, + ) + cleanups.push(() => root.unmount()) + }) + expect(validateSearch).toHaveBeenCalledTimes(1) + expect(container.querySelectorAll('a')).toHaveLength(10) + for (const anchor of container.querySelectorAll('a')) { + expect(anchor).toHaveAttribute('href', '/?q=test#details') + } +}) + +test('updates hash and active options on the same hydrated link', async () => { + const tree = ( + router: ReturnType, + hash?: LinkOptions['hash'], + includeHash = false, + ) => ( + + + {({ isActive }) => String(isActive)} + + + ) + const container = document.createElement('div') + container.innerHTML = renderToString(tree(makeRouter(true, '/'))) + const anchor = container.querySelector('a')! + const router = makeRouter(false, '/#details') + let root: ReturnType + await act(() => { + root = hydrateRoot(container, tree(router)) + cleanups.push(() => root.unmount()) + }) + for (const [hash, includeHash, href, active] of [ + [undefined, true, '/', false], + [true, true, '/#details', true], + [(previous = '') => `${previous}-child`, true, '/#details-child', false], + ['other', false, '/#other', true], + [undefined, false, '/', true], + ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) { + await act(() => root.render(tree(router, hash, includeHash))) + expect(container.querySelector('a')).toBe(anchor) + expect(anchor).toHaveAttribute('href', href) + expect(anchor.textContent).toBe(String(active)) + } +}) diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index a5fc4bca57..0ed9645e8a 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -18,7 +18,6 @@ import { useRouter } from './useRouter' import { useIntersectionObserver } from './utils' -import { useHydrated } from './ClientOnly' import type { AnyRouter, Constrain, @@ -124,6 +123,13 @@ export function useLinkProps< 'href', ]) + const [hydrating, setHydrating] = Solid.createSignal( + !(isServer ?? router.isServer) && !!Solid.sharedConfig.context, + ) + if (hydrating()) { + Solid.onMount(() => setHydrating(false)) + } + const currentLocation = Solid.createMemo( () => router.stores.location.get(), undefined, @@ -134,8 +140,21 @@ export function useLinkProps< // Rebuild when inherited search/hash or the current route context changes. const _fromLocation = currentLocation() const nextOptions = { _fromLocation, ...options } as any + const hash = nextOptions.hash + const hydrateHash = + !options.href && + !options._fromLocation && + (hash === true || typeof hash === 'function') && + hydrating() // untrack because router-core will also access stores, which are signals in solid - return Solid.untrack(() => router.buildLocation(nextOptions)) + return Solid.untrack(() => { + // Keep the source location identity for shared route matching. Literal + // destinations don't depend on hydration and need no post-mount rebuild. + if (hydrateHash) { + nextOptions.hash = hash === true ? '' : hash('') + } + return router.buildLocation(nextOptions) + }) }) const hrefOption = Solid.createMemo(() => { @@ -184,9 +203,6 @@ export function useLinkProps< return _href && getUrlScheme(_href) ? _href : undefined }) - const shouldHydrateHash = !isServer && !!router.options.ssr - const hasHydrated = (isServer ?? router.isServer) ? undefined : useHydrated() - const isActive = Solid.createMemo(() => { if (externalLink() !== undefined) { return false @@ -224,9 +240,7 @@ export function useLinkProps< } if (activeOptions?.includeHash) { - const currentHash = - shouldHydrateHash && !hasHydrated?.() ? '' : current.hash - return currentHash === nextLocation.hash + return (hydrating() ? '' : current.hash) === nextLocation.hash } return true }) diff --git a/packages/solid-router/tests/link-hash.test.tsx b/packages/solid-router/tests/link-hash.test.tsx new file mode 100644 index 0000000000..39032bb926 --- /dev/null +++ b/packages/solid-router/tests/link-hash.test.tsx @@ -0,0 +1,96 @@ +import { cleanup, render } from '@solidjs/testing-library' +import { afterEach, expect, test } from 'vitest' +import { createSignal } from 'solid-js' +import { + Link, + RouterContextProvider, + createLink, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' +import type { ComponentProps } from 'solid-js' +import type { LinkOptions } from '../src' + +afterEach(cleanup) + +test.each([ + { hash: 'details' as LinkOptions['hash'], href: '/#details', active: true }, + { hash: true as const, href: '/#details', active: true }, + { + hash: (previous = '') => `${previous}-child`, + href: '/#details-child', + active: false, + }, +])( + 'client-only links on an SSR router use the live hash on their first render ($href)', + ({ hash, href, active }) => { + const router = createRouter({ + routeTree: createRootRoute(), + history: createMemoryHistory({ initialEntries: ['/#details'] }), + isServer: false, + ssr: {}, + }) + const renders: Array<{ href?: string; active: boolean }> = [] + const ObservedLink = createLink((props: ComponentProps<'a'>) => { + renders.push({ + href: props.href, + active: props['aria-current'] === 'page', + }) + return + }) + const { container } = render(() => ( + + {() => ( + + {({ isActive }) => String(isActive)} + + )} + + )) + expect(renders[0]).toEqual({ href, active }) + expect(container.querySelector('a')).toHaveAttribute('href', href) + router.history.destroy() + }, +) + +test('hash and active options stay reactive on an initially ordinary link', () => { + const router = createRouter({ + routeTree: createRootRoute(), + history: createMemoryHistory({ initialEntries: ['/#details'] }), + }) + const [hash, setHash] = createSignal() + const [includeHash, setIncludeHash] = createSignal(false) + const { container } = render(() => ( + + {() => ( + + {({ isActive }) => String(isActive)} + + )} + + )) + const anchor = container.querySelector('a')! + for (const [nextHash, include, href, active] of [ + [undefined, true, '/', false], + [true, true, '/#details', true], + [(previous = '') => `${previous}-child`, true, '/#details-child', false], + ['other', false, '/#other', true], + [undefined, false, '/', true], + ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) { + setHash(() => nextHash) + setIncludeHash(include) + expect(container.querySelector('a')).toBe(anchor) + expect(anchor).toHaveAttribute('href', href) + expect(anchor.textContent).toBe(String(active)) + } + router.history.destroy() +}) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index e65a032648..9884a15313 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -192,24 +192,28 @@ function useLinkPropsImpl( // Vue assigns vnode.el before setup only when reusing server DOM. A fresh // client mount must use the live hash immediately, even on an SSR router. const hydrating = Vue.ref(Vue.getCurrentInstance()?.vnode.el != null) - Vue.onMounted(() => { - hydrating.value = false - }) - - const renderLocation = Vue.computed(() => { - const location = currentLocation.value - // Fragments are not sent to the server. Reproduce its empty hash for both - // active matching and inherited/function hash hrefs until hydration ends. - return hydrating.value && location.hash - ? { ...location, hash: '' } - : location - }) + if (hydrating.value) { + Vue.onMounted(() => { + hydrating.value = false + }) + } const next = Vue.computed(() => { // Rebuild when inherited search/hash or the current route context changes. const options = getOptions() - const opts = { _fromLocation: renderLocation.value, ...options } + const opts = { _fromLocation: currentLocation.value, ...options } + const hash = options.hash + // Only hash-dependent destinations need the server's empty hash. Keep + // the source location identity so links share the route-match cache. + if ( + !options.href && + !options._fromLocation && + (hash === true || typeof hash === 'function') && + hydrating.value + ) { + opts.hash = hash === true ? '' : hash('') + } return router.buildLocation(opts) }) @@ -246,10 +250,11 @@ function useLinkPropsImpl( return false } return getIsActive( - renderLocation.value, + currentLocation.value, next.value, options.activeOptions, router, + options.activeOptions?.includeHash && hydrating.value, ) }) @@ -679,6 +684,7 @@ function getIsActive( }, activeOptions: LinkOptions['activeOptions'], router: AnyRouter, + hydrating = false, ) { const currentPath = removeTrailingSlash(loc.pathname, router.basepath) const nextPath = removeTrailingSlash(nextLoc.pathname, router.basepath) @@ -709,7 +715,7 @@ function getIsActive( } if (activeOptions?.includeHash) { - return loc.hash === nextLoc.hash + return (hydrating ? '' : loc.hash) === nextLoc.hash } return true } diff --git a/packages/vue-router/tests/link-hash-hydration.test.tsx b/packages/vue-router/tests/link-hash-hydration.test.tsx index 1622790e12..aaf3640f22 100644 --- a/packages/vue-router/tests/link-hash-hydration.test.tsx +++ b/packages/vue-router/tests/link-hash-hydration.test.tsx @@ -171,6 +171,12 @@ async function checkHashHydration(clientUrl: string) { details: ['/', false], other: ['/', false], }, + { + id: 'omitted', + server: ['/', true], + details: ['/', false], + other: ['/', false], + }, { id: 'inherited', hash: true, @@ -185,6 +191,21 @@ async function checkHashHydration(clientUrl: string) { details: ['/#details-child', false], other: ['/#other-child', false], }, + { + id: 'identity', + hash: (previous = '') => previous, + server: ['/', true], + details: ['/#details', true], + other: ['/#other', true], + }, + { + id: 'function-insensitive', + hash: (previous) => `${previous}-child`, + insensitive: true, + server: ['/#-child', true], + details: ['/#details-child', true], + other: ['/#other-child', true], + }, { id: 'inherited-insensitive', hash: true, @@ -259,49 +280,69 @@ async function checkHashHydration(clientUrl: string) { expect(error).not.toHaveBeenCalled() } -test('uses the live hash on the first render of a client-only mount', async () => { - const renders: Array = [] - const page = Vue.defineComponent({ - setup: () => () => ( - - ), - }) - const router = makeRouter(false, '/#details', page) - await router.load() - const container = document.createElement('div') - document.body.appendChild(container) - const app = Vue.createApp({ - setup: () => () => , - }) - app.mount(container) - cleanups.push(() => app.unmount()) - expect(renders).toEqual([true, true]) - for (const anchor of container.querySelectorAll('a')) { - expect(anchor.getAttribute('href')).toBe('/#details') - expect(anchor.className).toBe('active') - expect(anchor.getAttribute('aria-current')).toBe('page') - } - await Vue.nextTick() - expect(renders.every(Boolean)).toBe(true) -}) +test.each([false, true])( + 'uses the live hash on the first render of a client-only mount (SSR options: %s)', + async (ssr) => { + const renders: Array = [] + const hashInputs: Array = [] + const inherit = (previous = '') => { + hashInputs.push(previous) + return previous + } + const page = Vue.defineComponent({ + setup: () => () => ( + + ), + }) + const router = makeRouter(false, '/#details', page) + if (ssr) { + router.options.ssr = {} + } + await router.load() + const container = document.createElement('div') + document.body.appendChild(container) + const app = Vue.createApp({ + setup: () => () => , + }) + app.mount(container) + cleanups.push(() => app.unmount()) + expect(renders).toEqual([true, true, true]) + expect(hashInputs[0]).toBe('details') + for (const anchor of container.querySelectorAll('a')) { + expect(anchor.getAttribute('href')).toBe('/#details') + expect(anchor.className).toBe('active') + expect(anchor.getAttribute('aria-current')).toBe('page') + } + await Vue.nextTick() + expect(renders.every(Boolean)).toBe(true) + }, +) test('new Links mounted after hydration use the live hash immediately', async () => { const show = Vue.ref(false) const renders: Array = [] + const hashInputs: Array = [] const page = Vue.defineComponent({ setup: () => () => (
{show.value && ( - + { + hashInputs.push(previous) + return previous + }} + activeOptions={{ includeHash: true }} + > {({ isActive }: { isActive: boolean }) => { renders.push(isActive) return String(isActive) @@ -320,9 +361,130 @@ test('new Links mounted after hydration use the live hash immediately', async () await Vue.nextTick() const anchor = container.querySelector('a')! expect(renders[0]).toBe(true) + expect(hashInputs[0]).toBe('details') expect(anchor.getAttribute('href')).toBe('/#details') expect(anchor.className).toBe('active') expect(anchor.getAttribute('aria-current')).toBe('page') expect(warn).not.toHaveBeenCalled() expect(error).not.toHaveBeenCalled() }) + +test('hydration only rebuilds destinations that depend on the current hash', async () => { + const page = Vue.defineComponent({ + setup: () => () => ( + + ), + }) + const { router, mount, container } = await prepareHydration(page) + const build = vi.spyOn(router, 'buildLocation') + const count = (id: string) => + build.mock.calls.filter( + ([options]) => (options as { id?: string }).id === id, + ).length + mount() + const initial = ['ordinary', 'literal', 'inherited'].map(count) + expect(initial.every((calls) => calls > 0)).toBe(true) + await Vue.nextTick() + expect(count('ordinary')).toBe(initial[0]) + expect(count('literal')).toBe(initial[1]) + expect(count('inherited')).toBeGreaterThan(initial[2]!) + expect(container.querySelector('#literal')).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(container.querySelector('#inherited')).toHaveAttribute( + 'href', + '/#details', + ) +}) + +test('hash options stay reactive when an ordinary link becomes hash-dependent', async () => { + const hash = Vue.ref() + const activeOptions = Vue.reactive({ includeHash: false }) + const page = Vue.defineComponent({ + setup: () => () => ( + + {({ isActive }: { isActive: boolean }) => String(isActive)} + + ), + }) + const { container, mount } = await prepareHydration(page) + const anchor = container.querySelector('a')! + mount() + await Vue.nextTick() + for (const [nextHash, includeHash, href, active] of [ + [undefined, true, '/', false], + [true, true, '/#details', true], + [(previous = '') => `${previous}-child`, true, '/#details-child', false], + ['other', false, '/#other', true], + [undefined, false, '/', true], + ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) { + hash.value = nextHash + activeOptions.includeHash = includeHash + await Vue.nextTick() + expect(container.querySelector('a')).toBe(anchor) + expect(anchor).toHaveAttribute('href', href) + expect(anchor.textContent).toBe(String(active)) + } +}) + +test.each(['inherit', 'function', 'href'] as const)( + 'preserves explicit source/href precedence during hydration (%s)', + async (kind) => { + const source = makeRouter(true, '/#preset', undefined).stores.location.get() + const updater = vi.fn((previous = '') => `${previous}-child`) + const page = Vue.defineComponent({ + setup: () => () => ( + + {({ isActive }: { isActive: boolean }) => String(isActive)} + + ), + }) + const { container, mount } = await prepareHydration(page) + const anchor = container.querySelector('a')! + const href = + kind === 'href' + ? '/#fixed' + : kind === 'inherit' + ? '/#preset' + : '/#preset-child' + expect(anchor).toHaveAttribute('href', href) + expect(anchor.textContent).toBe('false') + const warn = vi.spyOn(console, 'warn') + const error = vi.spyOn(console, 'error') + mount() + await Vue.nextTick() + expect(container.querySelector('a')).toBe(anchor) + expect(anchor).toHaveAttribute('href', href) + expect(anchor.textContent).toBe('false') + expect(warn).not.toHaveBeenCalled() + expect(error).not.toHaveBeenCalled() + if (kind === 'href') { + expect(updater).not.toHaveBeenCalled() + } else if (kind === 'function') { + expect(updater).toHaveBeenCalledWith('preset') + expect(updater).not.toHaveBeenCalledWith('') + } + }, +) From e50bc39a3a424bee940c73649a13431006ae29f1 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 16 Sep 2026 00:05:50 +0200 Subject: [PATCH 3/3] chore: split React and Solid hydration fixes into #8442 --- .changeset/sixty-rings-find.md | 2 - e2e/solid-start/basic/src/routeTree.gen.ts | 21 -- .../basic/src/routes/link-hash-hydration.tsx | 115 ------ .../basic/tests/link-hash-hydration.spec.ts | 139 ------- packages/react-router/src/ClientOnly.tsx | 11 +- packages/react-router/src/link.tsx | 34 +- .../tests/link-hash-hydration.test.tsx | 349 ------------------ packages/solid-router/src/link.tsx | 30 +- .../solid-router/tests/link-hash.test.tsx | 96 ----- 9 files changed, 18 insertions(+), 779 deletions(-) delete mode 100644 e2e/solid-start/basic/src/routes/link-hash-hydration.tsx delete mode 100644 e2e/solid-start/basic/tests/link-hash-hydration.spec.ts delete mode 100644 packages/react-router/tests/link-hash-hydration.test.tsx delete mode 100644 packages/solid-router/tests/link-hash.test.tsx diff --git a/.changeset/sixty-rings-find.md b/.changeset/sixty-rings-find.md index 11dfc0813f..1bb820415d 100644 --- a/.changeset/sixty-rings-find.md +++ b/.changeset/sixty-rings-find.md @@ -1,6 +1,4 @@ --- -'@tanstack/react-router': patch -'@tanstack/solid-router': patch '@tanstack/vue-router': patch --- diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index 3f01b45c13..07ba58b219 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -15,7 +15,6 @@ import { Route as DeferredRouteImport } from './routes/deferred' import { Route as DeferredWithoutSuspenseRouteImport } from './routes/deferred-without-suspense' import { Route as ErrorNormalizationRouteImport } from './routes/error-normalization' import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' -import { Route as LinkHashHydrationRouteImport } from './routes/link-hash-hydration' import { Route as LinksRouteImport } from './routes/links' import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' import { Route as PostsRouteImport } from './routes/posts' @@ -101,11 +100,6 @@ const InlineScriptsRoute = InlineScriptsRouteImport.update({ path: '/inline-scripts', getParentRoute: () => rootRouteImport, } as any) -const LinkHashHydrationRoute = LinkHashHydrationRouteImport.update({ - id: '/link-hash-hydration', - path: '/link-hash-hydration', - getParentRoute: () => rootRouteImport, -} as any) const LinksRoute = LinksRouteImport.update({ id: '/links', path: '/links', @@ -407,7 +401,6 @@ export interface FileRoutesByFullPath { '/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute '/error-normalization': typeof ErrorNormalizationRoute '/inline-scripts': typeof InlineScriptsRoute - '/link-hash-hydration': typeof LinkHashHydrationRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/raw-stream': typeof RawStreamRouteWithChildren @@ -467,7 +460,6 @@ export interface FileRoutesByTo { '/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute '/error-normalization': typeof ErrorNormalizationRoute '/inline-scripts': typeof InlineScriptsRoute - '/link-hash-hydration': typeof LinkHashHydrationRoute '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute @@ -527,7 +519,6 @@ export interface FileRoutesById { '/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute '/error-normalization': typeof ErrorNormalizationRoute '/inline-scripts': typeof InlineScriptsRoute - '/link-hash-hydration': typeof LinkHashHydrationRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/raw-stream': typeof RawStreamRouteWithChildren @@ -592,7 +583,6 @@ export interface FileRouteTypes { | '/deferred-without-suspense' | '/error-normalization' | '/inline-scripts' - | '/link-hash-hydration' | '/links' | '/posts' | '/raw-stream' @@ -652,7 +642,6 @@ export interface FileRouteTypes { | '/deferred-without-suspense' | '/error-normalization' | '/inline-scripts' - | '/link-hash-hydration' | '/links' | '/scripts' | '/stream' @@ -711,7 +700,6 @@ export interface FileRouteTypes { | '/deferred-without-suspense' | '/error-normalization' | '/inline-scripts' - | '/link-hash-hydration' | '/links' | '/posts' | '/raw-stream' @@ -776,7 +764,6 @@ export interface RootRouteChildren { DeferredWithoutSuspenseRoute: typeof DeferredWithoutSuspenseRoute ErrorNormalizationRoute: typeof ErrorNormalizationRoute InlineScriptsRoute: typeof InlineScriptsRoute - LinkHashHydrationRoute: typeof LinkHashHydrationRoute LinksRoute: typeof LinksRoute PostsRoute: typeof PostsRouteWithChildren RawStreamRoute: typeof RawStreamRouteWithChildren @@ -837,13 +824,6 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof InlineScriptsRouteImport parentRoute: typeof rootRouteImport } - '/link-hash-hydration': { - id: '/link-hash-hydration' - path: '/link-hash-hydration' - fullPath: '/link-hash-hydration' - preLoaderRoute: typeof LinkHashHydrationRouteImport - parentRoute: typeof rootRouteImport - } '/links': { id: '/links' path: '/links' @@ -1440,7 +1420,6 @@ const rootRouteChildren: RootRouteChildren = { DeferredWithoutSuspenseRoute: DeferredWithoutSuspenseRoute, ErrorNormalizationRoute: ErrorNormalizationRoute, InlineScriptsRoute: InlineScriptsRoute, - LinkHashHydrationRoute: LinkHashHydrationRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, RawStreamRoute: RawStreamRouteWithChildren, diff --git a/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx b/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx deleted file mode 100644 index dc9ad90651..0000000000 --- a/e2e/solid-start/basic/src/routes/link-hash-hydration.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { createSignal, onMount } from 'solid-js' -import { - Link, - createFileRoute, - createLink, - useRouter, -} from '@tanstack/solid-router' -import type { ComponentProps } from 'solid-js' -import type { LinkOptions } from '@tanstack/solid-router' - -declare global { - interface Window { - initialHashLinks: Record - } -} - -const cases: Array<{ - id: string - hash?: LinkOptions['hash'] - insensitive?: boolean - sourceHash?: string - href?: string -}> = [ - { id: 'explicit-source', hash: true, sourceHash: 'preset' }, - { - id: 'explicit-source-function', - hash: (hash = '') => `${hash}-child`, - sourceHash: 'preset', - }, - { - id: 'explicit-href', - href: '/link-hash-hydration#fixed', - hash: () => { - throw new Error('href overrides hash') - }, - }, - { id: 'matching', hash: 'details' }, - { id: 'nonmatching', hash: 'other' }, - { id: 'empty', hash: '' }, - { id: 'omitted' }, - { id: 'inherited', hash: true }, - { id: 'identity', hash: (hash = '') => hash }, - { id: 'derived', hash: (hash) => `${hash}-child` }, - { id: 'inherited-insensitive', hash: true, insensitive: true }, - { - id: 'derived-insensitive', - hash: (hash) => `${hash}-child`, - insensitive: true, - }, - { id: 'ordinary', insensitive: true }, -] - -// Capture the first client props, before onMount can conceal a mismatch. -const ObservedLink = createLink((props: ComponentProps<'a'>) => { - if (typeof window !== 'undefined') { - window.initialHashLinks ??= {} - window.initialHashLinks[props.id!] ??= { - href: props.href, - active: props['aria-current'] === 'page', - } - } - return -}) - -export const Route = createFileRoute('/link-hash-hydration')({ - component: Page, -}) - -function Page() { - const router = useRouter() - const [mounted, setMounted] = createSignal(false) - const [includeHash, setIncludeHash] = createSignal(true) - const [show, setShow] = createSignal(false) - onMount(() => setMounted(true)) - return ( -
- {cases.map((entry) => ( - - {({ isActive }) => - isActive ? active : inactive - } - - ))} - - - {show() && ( - - {({ isActive }) => String(isActive)} - - )} - - Other hash - -
- ) -} diff --git a/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts b/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts deleted file mode 100644 index 4d473fe09f..0000000000 --- a/e2e/solid-start/basic/tests/link-hash-hydration.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { expect, test } from '@playwright/test' - -test('hash-dependent links reproduce the server state before using the browser hash', async ({ - page, -}) => { - test.skip(process.env.MODE === 'spa', 'Requires server-rendered links') - const errors: Array = [] - page.on('pageerror', (error) => errors.push(error.message)) - page.on('console', (message) => { - if (/hydration|mismatch/i.test(message.text())) { - errors.push(message.text()) - } - }) - let releaseScripts!: () => void - const scripts = new Promise((resolve) => { - releaseScripts = resolve - }) - await page.route('**/*.js', async (route) => { - await scripts - await route.continue() - }) - const path = '/link-hash-hydration' - const cases = [ - { - id: 'explicit-source', - server: ['#preset', false], - client: ['#preset', false], - }, - { - id: 'explicit-source-function', - server: ['#preset-child', false], - client: ['#preset-child', false], - }, - { - id: 'explicit-href', - server: ['#fixed', false], - client: ['#fixed', false], - }, - { id: 'matching', server: ['#details', false], client: ['#details', true] }, - { id: 'nonmatching', server: ['#other', false], client: ['#other', false] }, - { id: 'empty', server: ['', true], client: ['', false] }, - { id: 'omitted', server: ['', true], client: ['', false] }, - { id: 'inherited', server: ['', true], client: ['#details', true] }, - { id: 'identity', server: ['', true], client: ['#details', true] }, - { - id: 'derived', - server: ['#-child', false], - client: ['#details-child', false], - }, - { - id: 'inherited-insensitive', - server: ['', true], - client: ['#details', true], - }, - { - id: 'derived-insensitive', - server: ['#-child', true], - client: ['#details-child', true], - }, - { id: 'ordinary', server: ['', true], client: ['', true] }, - ] as const - await page.goto(`${path}#details`, { waitUntil: 'commit' }) - try { - for (const entry of cases) { - const anchor = page.locator(`#${entry.id}`) - await expect(anchor).toHaveAttribute('href', path + entry.server[0]) - await expect(anchor).toHaveText(entry.server[1] ? 'active' : 'inactive') - if (entry.server[1]) { - await expect(anchor).toHaveAttribute('aria-current', 'page') - await expect(anchor).toHaveAttribute('data-status', 'active') - } else { - await expect(anchor).not.toHaveAttribute('aria-current') - await expect(anchor).not.toHaveAttribute('data-status') - } - } - await page.evaluate(() => { - const anchors = Array.from( - document.querySelectorAll('[data-testid="hash-links"] a'), - ) - ;(window as any).serverHashLinks = anchors - }) - } finally { - releaseScripts() - } - await expect(page.getByTestId('hash-links')).toHaveAttribute( - 'data-mounted', - 'true', - ) - const initial = await page.evaluate(() => window.initialHashLinks) - for (const entry of cases) { - expect(initial[entry.id], entry.id).toEqual({ - href: path + entry.server[0], - active: entry.server[1], - }) - const anchor = page.locator(`#${entry.id}`) - await expect(anchor).toHaveAttribute('href', path + entry.client[0]) - await expect(anchor).toContainClass(entry.client[1] ? 'active' : 'inactive') - await expect(anchor).toHaveText(entry.client[1] ? 'active' : 'inactive') - if (entry.client[1]) { - await expect(anchor).toHaveAttribute('aria-current', 'page') - await expect(anchor).toHaveAttribute('data-status', 'active') - } else { - await expect(anchor).not.toHaveAttribute('aria-current') - await expect(anchor).not.toHaveAttribute('data-status') - } - await expect( - anchor.locator(entry.client[1] ? 'strong' : 'em'), - ).toBeVisible() - } - expect( - await page.evaluate(() => - (window as any).serverHashLinks.every( - (anchor: Element) => document.getElementById(anchor.id) === anchor, - ), - ), - ).toBe(true) - await page.getByRole('button', { name: 'Mount link' }).click() - expect(await page.evaluate(() => window.initialHashLinks.later)).toEqual({ - href: `${path}#details`, - active: true, - }) - await page.getByRole('button', { name: 'Toggle hash matching' }).click() - await expect(page.locator('#empty')).toContainClass('active') - await expect(page.locator('#nonmatching')).toContainClass('active') - await page.getByRole('button', { name: 'Toggle hash matching' }).click() - await expect(page.locator('#empty')).toContainClass('inactive') - await page.locator('#navigate-other').click() - await expect(page.locator('#matching')).toContainClass('inactive') - await expect(page.locator('#nonmatching')).toContainClass('active') - await expect(page.locator('#inherited')).toHaveAttribute( - 'href', - `${path}#other`, - ) - await expect(page.locator('#derived')).toHaveAttribute( - 'href', - `${path}#other-child`, - ) - expect(errors).toEqual([]) -}) diff --git a/packages/react-router/src/ClientOnly.tsx b/packages/react-router/src/ClientOnly.tsx index c32f6a49a7..c139b05d0a 100644 --- a/packages/react-router/src/ClientOnly.tsx +++ b/packages/react-router/src/ClientOnly.tsx @@ -56,15 +56,8 @@ export function ClientOnly({ children, fallback = null }: ClientOnlyProps) { * ``` * @returns True if the JS has been hydrated already, false otherwise. */ -export function useHydrated(): boolean -/** @internal Skip the hydration update for consumers that don't need it. */ -export function useHydrated(enabled: boolean): boolean -export function useHydrated(enabled = true): boolean { - return React.useSyncExternalStore( - subscribe, - getSnapshot, - enabled ? getServerSnapshot : getSnapshot, - ) +export function useHydrated(): boolean { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } function subscribe() { diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 58a66aa7cf..26ec05a848 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -92,7 +92,7 @@ function resolveIsActive( next: ParsedLocation, activeOptions: ActiveOptions | undefined, basepath: string, - hydrating = false, + isHydrated: boolean, ): boolean { const currentPath = removeTrailingSlash(location.pathname, basepath) const nextPath = removeTrailingSlash(next.pathname, basepath) @@ -123,7 +123,7 @@ function resolveIsActive( } if (activeOptions?.includeHash) { - return (hydrating ? '' : location.hash) === next.hash + return isHydrated && location.hash === next.hash } return true } @@ -256,12 +256,8 @@ export function useLinkProps< onTouchStart, } = options as typeof options & { to?: string } - const hashFromLocation = - !options.href && - !options._fromLocation && - (options.hash === true || typeof options.hash === 'function') // eslint-disable-next-line react-hooks/rules-of-hooks - const isHydrated = useHydrated(activeOptions?.includeHash || hashFromLocation) + const isHydrated = useHydrated() // eslint-disable-next-line react-hooks/rules-of-hooks const [stableSearch, stableParams, stableActiveOptions] = useStableValues( @@ -281,7 +277,6 @@ export function useLinkProps< options.from, options._fromLocation, options.hash, - options.href, options.to, stableSearch, stableParams, @@ -308,13 +303,7 @@ export function useLinkProps< if (!_options._fromLocation) { dest._fromLocation = location } - // Only hash-dependent destinations need the server's empty hash. Keep - // the source location identity so links share the route-match cache. - const next = router.buildLocation( - !isHydrated && hashFromLocation - ? { ...dest, hash: dest.hash === true ? '' : dest.hash('') } - : dest, - ) + const next = router.buildLocation(dest) // Use publicHref - it contains the correct href for display // When a rewrite changes the origin, publicHref is the full URL @@ -330,20 +319,11 @@ export function useLinkProps< next, stableActiveOptions, router.basepath, - !isHydrated, + isHydrated, ), ] }, - [ - stableActiveOptions, - disabled, - isHydrated, - hashFromLocation, - _options, - dest, - router, - to, - ], + [stableActiveOptions, disabled, isHydrated, _options, dest, router, to], ) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -629,6 +609,7 @@ function getServerLinkProps( } const blockedLink = !disabled && !hrefOption + // Hash is not available on the server, so it never counts as hydrated. const isActive = !!next && !blockedLink && @@ -637,6 +618,7 @@ function getServerLinkProps( next, activeOptions, router.basepath, + false, ) return applyLinkState( props, diff --git a/packages/react-router/tests/link-hash-hydration.test.tsx b/packages/react-router/tests/link-hash-hydration.test.tsx deleted file mode 100644 index 83e7dcbaa9..0000000000 --- a/packages/react-router/tests/link-hash-hydration.test.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import React from 'react' -import { renderToString } from 'react-dom/server' -import { createRoot, hydrateRoot } from 'react-dom/client' -import { act } from '@testing-library/react' -import { afterEach, expect, test, vi } from 'vitest' -import { - Link, - RouterContextProvider, - createLink, - createMemoryHistory, - createRootRoute, - createRouter, -} from '../src' -import type { LinkOptions } from '../src' - -const cleanups: Array<() => void> = [] - -afterEach(async () => { - await act(() => { - while (cleanups.length) { - cleanups.pop()!() - } - }) - vi.restoreAllMocks() -}) - -function makeRouter(isServer: boolean, url: string) { - const router = createRouter({ - routeTree: createRootRoute(), - history: createMemoryHistory({ initialEntries: [url] }), - isServer, - ssr: {}, - defaultHashScrollIntoView: false, - }) - cleanups.push(() => router.history.destroy()) - return router -} - -const cases: Array<{ - name: string - hash?: LinkOptions['hash'] - href?: string - sourceHash?: string - includeHash?: boolean - server: [string, boolean] - client: [string, boolean] -}> = [ - { - name: 'explicit-source', - hash: true, - sourceHash: 'preset', - includeHash: true, - server: ['/#preset', false], - client: ['/#preset', false], - }, - { - name: 'explicit-source-function', - hash: (hash = '') => `${hash}-child`, - sourceHash: 'preset', - includeHash: true, - server: ['/#preset-child', false], - client: ['/#preset-child', false], - }, - { - name: 'explicit-href', - href: '/#fixed', - hash: () => { - throw new Error('href overrides hash') - }, - includeHash: true, - server: ['/#fixed', false], - client: ['/#fixed', false], - }, - { - name: 'matching', - hash: 'details', - includeHash: true, - server: ['/#details', false], - client: ['/#details', true], - }, - { - name: 'nonmatching', - hash: 'other', - includeHash: true, - server: ['/#other', false], - client: ['/#other', false], - }, - { - name: 'empty', - hash: '', - includeHash: true, - server: ['/', true], - client: ['/', false], - }, - { - name: 'omitted', - includeHash: true, - server: ['/', true], - client: ['/', false], - }, - { - name: 'inherited', - hash: true, - includeHash: true, - server: ['/', true], - client: ['/#details', true], - }, - { - name: 'identity', - hash: (hash = '') => hash, - includeHash: true, - server: ['/', true], - client: ['/#details', true], - }, - { - name: 'derived', - hash: (hash) => `${hash}-child`, - includeHash: true, - server: ['/#-child', false], - client: ['/#details-child', false], - }, - { - name: 'inherited-insensitive', - hash: true, - server: ['/', true], - client: ['/#details', true], - }, - { - name: 'derived-insensitive', - hash: (hash) => `${hash}-child`, - server: ['/#-child', true], - client: ['/#details-child', true], - }, - { name: 'ordinary', server: ['/', true], client: ['/', true] }, -] - -test.each( - cases.flatMap((entry) => - ['/', '/#details'].map((url) => ({ ...entry, url })), - ), -)( - 'hydrates $name links at $url without changing server DOM during hydration', - async (entry) => { - const renders: Array = [] - const source = entry.sourceHash - ? makeRouter(true, `/#${entry.sourceHash}`).stores.location.get() - : undefined - const link = ( - - {({ isActive }) => { - renders.push(isActive) - return String(isActive) - }} - - ) - const container = document.createElement('div') - const server = makeRouter(true, '/') - container.innerHTML = renderToString( - {link}, - ) - const anchor = container.querySelector('a')! - function check([href, active]: [string, boolean]) { - expect(container.querySelector('a')).toBe(anchor) - expect(anchor.getAttribute('href')).toBe(href) - expect(anchor.className).toBe(active ? 'active' : 'inactive') - expect(anchor.getAttribute('aria-current')).toBe(active ? 'page' : null) - expect(anchor.getAttribute('data-status')).toBe(active ? 'active' : null) - expect(anchor.textContent).toBe(String(active)) - } - check(entry.server) - renders.length = 0 - const client = makeRouter(false, entry.url) - const error = vi.spyOn(console, 'error').mockImplementation(() => {}) - const recoverable = vi.fn() - await act(() => { - const root = hydrateRoot( - container, - {link}, - { onRecoverableError: recoverable }, - ) - cleanups.push(() => root.unmount()) - }) - expect(renders[0]).toBe(entry.server[1]) - check(entry.url === '/' ? entry.server : entry.client) - expect(client.stores.location.get().hash).toBe( - entry.url === '/' ? '' : 'details', - ) - expect(error).not.toHaveBeenCalled() - expect(recoverable).not.toHaveBeenCalled() - if (entry.name === 'ordinary') { - expect(renders).toEqual([true]) - } - - await act(() => client.navigate({ to: '/', hash: 'other' })) - expect(container.querySelector('a')).toBe(anchor) - const inheritsHash = entry.hash === true || typeof entry.hash === 'function' - check([ - inheritsHash - ? entry.client[0].replace('details', 'other') - : entry.client[0], - !entry.includeHash || - ['nonmatching', 'inherited', 'identity'].includes(entry.name), - ]) - await act(() => client.navigate({ to: '/', hash: '' })) - check(entry.server) - }, -) - -test.each( - [false, true].flatMap((later) => [ - { - later, - hash: 'details' as LinkOptions['hash'], - href: '/#details', - active: true, - }, - { later, hash: true as const, href: '/#details', active: true }, - { - later, - hash: (hash = '') => `${hash}-child`, - href: '/#details-child', - active: false, - }, - ]), -)( - 'uses live hashes on the first client-only render ($href, later mount: $later)', - async ({ later, hash, href, active }) => { - const router = makeRouter(false, '/#details') - const renders: Array<{ href?: string; active: boolean }> = [] - const ObservedLink = createLink( - React.forwardRef>( - (props, ref) => { - renders.push({ - href: props.href, - active: props['aria-current'] === 'page', - }) - return
- }, - ), - ) - const container = document.createElement('div') - const tree = (show: boolean) => ( - - {show && ( - - {({ isActive }) => String(isActive)} - - )} - - ) - let root: ReturnType - await act(() => { - if (later) { - container.innerHTML = renderToString(tree(false)) - root = hydrateRoot(container, tree(false)) - } else { - root = createRoot(container) - } - cleanups.push(() => root.unmount()) - }) - await act(() => root.render(tree(true))) - expect(renders[0]).toEqual({ href, active }) - expect(container.querySelector('a')).toHaveAttribute('href', href) - }, -) - -test('hash-dependent links share current-route validation while hydrating', async () => { - const validateSearch = vi.fn((search: Record) => search) - const make = (isServer: boolean, url: string) => { - const router = createRouter({ - routeTree: createRootRoute({ validateSearch }), - history: createMemoryHistory({ initialEntries: [url] }), - isServer, - }) - cleanups.push(() => router.history.destroy()) - return router - } - const links = Array.from({ length: 10 }, (_, index) => ( - - Link - - )) - const container = document.createElement('div') - container.innerHTML = renderToString( - - {links} - , - ) - const client = make(false, '/?q=test#details') - validateSearch.mockClear() - await act(() => { - const root = hydrateRoot( - container, - {links}, - ) - cleanups.push(() => root.unmount()) - }) - expect(validateSearch).toHaveBeenCalledTimes(1) - expect(container.querySelectorAll('a')).toHaveLength(10) - for (const anchor of container.querySelectorAll('a')) { - expect(anchor).toHaveAttribute('href', '/?q=test#details') - } -}) - -test('updates hash and active options on the same hydrated link', async () => { - const tree = ( - router: ReturnType, - hash?: LinkOptions['hash'], - includeHash = false, - ) => ( - - - {({ isActive }) => String(isActive)} - - - ) - const container = document.createElement('div') - container.innerHTML = renderToString(tree(makeRouter(true, '/'))) - const anchor = container.querySelector('a')! - const router = makeRouter(false, '/#details') - let root: ReturnType - await act(() => { - root = hydrateRoot(container, tree(router)) - cleanups.push(() => root.unmount()) - }) - for (const [hash, includeHash, href, active] of [ - [undefined, true, '/', false], - [true, true, '/#details', true], - [(previous = '') => `${previous}-child`, true, '/#details-child', false], - ['other', false, '/#other', true], - [undefined, false, '/', true], - ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) { - await act(() => root.render(tree(router, hash, includeHash))) - expect(container.querySelector('a')).toBe(anchor) - expect(anchor).toHaveAttribute('href', href) - expect(anchor.textContent).toBe(String(active)) - } -}) diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 0ed9645e8a..a5fc4bca57 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -18,6 +18,7 @@ import { useRouter } from './useRouter' import { useIntersectionObserver } from './utils' +import { useHydrated } from './ClientOnly' import type { AnyRouter, Constrain, @@ -123,13 +124,6 @@ export function useLinkProps< 'href', ]) - const [hydrating, setHydrating] = Solid.createSignal( - !(isServer ?? router.isServer) && !!Solid.sharedConfig.context, - ) - if (hydrating()) { - Solid.onMount(() => setHydrating(false)) - } - const currentLocation = Solid.createMemo( () => router.stores.location.get(), undefined, @@ -140,21 +134,8 @@ export function useLinkProps< // Rebuild when inherited search/hash or the current route context changes. const _fromLocation = currentLocation() const nextOptions = { _fromLocation, ...options } as any - const hash = nextOptions.hash - const hydrateHash = - !options.href && - !options._fromLocation && - (hash === true || typeof hash === 'function') && - hydrating() // untrack because router-core will also access stores, which are signals in solid - return Solid.untrack(() => { - // Keep the source location identity for shared route matching. Literal - // destinations don't depend on hydration and need no post-mount rebuild. - if (hydrateHash) { - nextOptions.hash = hash === true ? '' : hash('') - } - return router.buildLocation(nextOptions) - }) + return Solid.untrack(() => router.buildLocation(nextOptions)) }) const hrefOption = Solid.createMemo(() => { @@ -203,6 +184,9 @@ export function useLinkProps< return _href && getUrlScheme(_href) ? _href : undefined }) + const shouldHydrateHash = !isServer && !!router.options.ssr + const hasHydrated = (isServer ?? router.isServer) ? undefined : useHydrated() + const isActive = Solid.createMemo(() => { if (externalLink() !== undefined) { return false @@ -240,7 +224,9 @@ export function useLinkProps< } if (activeOptions?.includeHash) { - return (hydrating() ? '' : current.hash) === nextLocation.hash + const currentHash = + shouldHydrateHash && !hasHydrated?.() ? '' : current.hash + return currentHash === nextLocation.hash } return true }) diff --git a/packages/solid-router/tests/link-hash.test.tsx b/packages/solid-router/tests/link-hash.test.tsx deleted file mode 100644 index 39032bb926..0000000000 --- a/packages/solid-router/tests/link-hash.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { cleanup, render } from '@solidjs/testing-library' -import { afterEach, expect, test } from 'vitest' -import { createSignal } from 'solid-js' -import { - Link, - RouterContextProvider, - createLink, - createMemoryHistory, - createRootRoute, - createRouter, -} from '../src' -import type { ComponentProps } from 'solid-js' -import type { LinkOptions } from '../src' - -afterEach(cleanup) - -test.each([ - { hash: 'details' as LinkOptions['hash'], href: '/#details', active: true }, - { hash: true as const, href: '/#details', active: true }, - { - hash: (previous = '') => `${previous}-child`, - href: '/#details-child', - active: false, - }, -])( - 'client-only links on an SSR router use the live hash on their first render ($href)', - ({ hash, href, active }) => { - const router = createRouter({ - routeTree: createRootRoute(), - history: createMemoryHistory({ initialEntries: ['/#details'] }), - isServer: false, - ssr: {}, - }) - const renders: Array<{ href?: string; active: boolean }> = [] - const ObservedLink = createLink((props: ComponentProps<'a'>) => { - renders.push({ - href: props.href, - active: props['aria-current'] === 'page', - }) - return - }) - const { container } = render(() => ( - - {() => ( - - {({ isActive }) => String(isActive)} - - )} - - )) - expect(renders[0]).toEqual({ href, active }) - expect(container.querySelector('a')).toHaveAttribute('href', href) - router.history.destroy() - }, -) - -test('hash and active options stay reactive on an initially ordinary link', () => { - const router = createRouter({ - routeTree: createRootRoute(), - history: createMemoryHistory({ initialEntries: ['/#details'] }), - }) - const [hash, setHash] = createSignal() - const [includeHash, setIncludeHash] = createSignal(false) - const { container } = render(() => ( - - {() => ( - - {({ isActive }) => String(isActive)} - - )} - - )) - const anchor = container.querySelector('a')! - for (const [nextHash, include, href, active] of [ - [undefined, true, '/', false], - [true, true, '/#details', true], - [(previous = '') => `${previous}-child`, true, '/#details-child', false], - ['other', false, '/#other', true], - [undefined, false, '/', true], - ] satisfies Array<[LinkOptions['hash'], boolean, string, boolean]>) { - setHash(() => nextHash) - setIncludeHash(include) - expect(container.querySelector('a')).toBe(anchor) - expect(anchor).toHaveAttribute('href', href) - expect(anchor.textContent).toBe(String(active)) - } - router.history.destroy() -})