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('') + } + }, +)