diff --git a/.changeset/sixty-rings-find.md b/.changeset/sixty-rings-find.md
new file mode 100644
index 0000000000..1bb820415d
--- /dev/null
+++ b/.changeset/sixty-rings-find.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/vue-router': patch
+---
+
+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/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx
index 6d2fea690c..9884a15313 100644
--- a/packages/vue-router/src/link.tsx
+++ b/packages/vue-router/src/link.tsx
@@ -189,11 +189,31 @@ 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)
+ 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: 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)
})
@@ -234,6 +254,7 @@ function useLinkPropsImpl(
next.value,
options.activeOptions,
router,
+ options.activeOptions?.includeHash && hydrating.value,
)
})
@@ -663,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)
@@ -693,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
new file mode 100644
index 0000000000..aaf3640f22
--- /dev/null
+++ b/packages/vue-router/tests/link-hash-hydration.test.tsx
@@ -0,0 +1,490 @@
+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: 'omitted',
+ 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: '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,
+ 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.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)
+ }}
+
+ )}
+
+ ),
+ })
+ 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(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('')
+ }
+ },
+)