From 11a9aff5082493593a5250338a9753413bbd72fb Mon Sep 17 00:00:00 2001 From: Yangshun Date: Mon, 2 Nov 2026 19:57:49 +0800 Subject: [PATCH 1/2] refactor(pages): share the Link contract across framework adapters Each adapter declared its own copy of the Link props, prefetch strategies and method union, and repeated the prefetch, click and visibility logic. Move them into `void/pages-client` next to the shared Form helpers: - `BaseLinkProps`, `LinkPrefetchStrategy`, `LinkNavigateEvent`, `ResolvedLink` - `resolveLink`, `linkPrefetchStrategies`, `prefetchLink`, `handleLinkClick`, `observeLinkVisible` Adapters keep reactivity, event naming, user handler composition, style merging and rendering. Vue's runtime prop check now compares against the shared props. This also removes drift between the copies: Svelte's `prefetch` accepts readonly arrays like the other adapters, and Vue clears a pending hover timer before starting a new one. Co-Authored-By: Claude Opus 5.5 --- packages/react/src/runtime/link.tsx | 159 ++++------------ packages/solid/src/runtime/link.tsx | 141 ++------------ packages/svelte/src/runtime/Link.svelte | 105 ++-------- packages/svelte/src/runtime/link-props.ts | 18 +- packages/void/src/pages/client.ts | 11 ++ packages/void/src/pages/link.ts | 180 +++++++++++++++++ packages/void/test/unit/pages-link.test.ts | 212 +++++++++++++++++++++ packages/vue/src/runtime/link.ts | 154 ++++----------- 8 files changed, 514 insertions(+), 466 deletions(-) create mode 100644 packages/void/src/pages/link.ts create mode 100644 packages/void/test/unit/pages-link.test.ts diff --git a/packages/react/src/runtime/link.tsx b/packages/react/src/runtime/link.tsx index 2da361f53..0e583d2c1 100644 --- a/packages/react/src/runtime/link.tsx +++ b/packages/react/src/runtime/link.tsx @@ -8,31 +8,19 @@ import type { CSSProperties, RefObject, } from 'react'; -import { isModifiedEvent, mergeDataIntoHref } from 'void/pages-client'; -import type { Href } from 'void/routes'; +import { + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, + type BaseLinkProps, +} from 'void/pages-client'; import { RouterContext } from './context.ts'; -type PrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; -type LinkMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; -type PrefetchOptions = { cacheFor?: LinkProps['cacheFor'] }; - -type CommonProps = { - href: Href; - method?: LinkMethod | Lowercase; - data?: Record; - preserveScroll?: boolean; - preserveState?: boolean; - replace?: boolean; - reloadDocument?: boolean; - viewTransition?: boolean; - prefetch?: boolean | PrefetchStrategy | ReadonlyArray; - cacheFor?: number | string | [string, string]; - onNavigate?: (event: { preventDefault(): void }) => void; -}; - -export type LinkProps = CommonProps & - Omit, keyof CommonProps> & - Omit, keyof CommonProps>; +export type LinkProps = BaseLinkProps & + Omit, keyof BaseLinkProps> & + Omit, keyof BaseLinkProps>; export function Link({ href, @@ -43,7 +31,7 @@ export function Link({ replace = false, reloadDocument = false, viewTransition, - prefetch: prefetchProp = false, + prefetch = false, cacheFor, onNavigate, children, @@ -59,110 +47,45 @@ export function Link({ const router = useContext(RouterContext); const elementRef = useRef(null); const hoverTimerRef = useRef | null>(null); - const normalizedMethod = useMemo(() => method.toUpperCase() as LinkMethod, [method]); - const isGet = normalizedMethod === 'GET'; - const hrefWithData = useMemo( - () => (isGet ? mergeDataIntoHref(href, data) : href), - [data, href, isGet], + const link = useMemo( + () => resolveLink({ href, method, data, prefetch, reloadDocument }), + [data, href, method, prefetch, reloadDocument], + ); + const strategies = useMemo( + () => linkPrefetchStrategies({ prefetch, reloadDocument }), + [prefetch, reloadDocument], ); - const strategies = useMemo>(() => { - if (prefetchProp === false || reloadDocument) { - return []; - } - if (prefetchProp === true) { - return ['hover']; - } - if (typeof prefetchProp === 'string') { - return [prefetchProp]; - } - return prefetchProp; - }, [prefetchProp, reloadDocument]); const hoverDelay = router?._hoverDelay ?? 75; - if (prefetchProp !== false && !isGet) { - throw new Error( - 'Link: prefetch only supports GET links. Remove `prefetch` or use method="GET".', - ); - } - if (reloadDocument && !isGet) { - throw new Error('Link: reloadDocument only supports GET links.'); - } - - const doPrefetch = useCallback(() => { - if (!router?.prefetch) { - return; - } - const current = window.location.pathname + window.location.search; - if (hrefWithData === current) { - return; - } - const options: PrefetchOptions = {}; - if (cacheFor !== undefined) { - options.cacheFor = cacheFor; - } - router.prefetch(hrefWithData, options); - }, [cacheFor, hrefWithData, router]); - - const shouldHandleClick = useCallback( - (event: MouseEvent): boolean => { - if (reloadDocument || !router || event.defaultPrevented || isModifiedEvent(event)) { - return false; - } - if ((event.currentTarget as HTMLElement).hasAttribute('download')) { - return false; - } - - const url = new URL(hrefWithData, window.location.href); - if (url.origin !== window.location.origin) { - return false; - } - - return true; - }, - [hrefWithData, reloadDocument, router], + const doPrefetch = useCallback( + () => prefetchLink(router, link.href, cacheFor), + [cacheFor, link.href, router], ); const onClick = useCallback( (event: MouseEvent) => { // oxlint-disable-next-line no-explicit-any -- React merges anchor/button handler types awkwardly here onClickProp?.(event as any); - if (!shouldHandleClick(event)) { - return; - } - - let navigatePrevented = false; - onNavigate?.({ - preventDefault() { - navigatePrevented = true; - }, - }); - if (navigatePrevented) { - event.preventDefault(); - return; - } - - event.preventDefault(); - router?.visit(hrefWithData, { - method: normalizedMethod, - data: isGet ? undefined : data, + handleLinkClick(event, router, link, { + data, preserveScroll, preserveState, replace, + reloadDocument, viewTransition, + onNavigate, }); }, [ data, - hrefWithData, - isGet, - normalizedMethod, + link, onClickProp, onNavigate, preserveScroll, preserveState, + reloadDocument, replace, router, - shouldHandleClick, viewTransition, ], ); @@ -236,29 +159,17 @@ export function Link({ doPrefetch(); } - let observer: IntersectionObserver | null = null; - if (strategies.includes('visible') && elementRef.current) { - observer = new IntersectionObserver( - (entries) => { - if (entries.some((e) => e.isIntersecting)) { - doPrefetch(); - observer?.disconnect(); - observer = null; - } - }, - { rootMargin: '0px' }, - ); - observer.observe(elementRef.current); - } + const stopObserving = + strategies.includes('visible') && elementRef.current + ? observeLinkVisible(elementRef.current, doPrefetch) + : null; return () => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } - if (observer) { - observer.disconnect(); - } + stopObserving?.(); }; }, [doPrefetch, strategies]); @@ -310,11 +221,11 @@ export function Link({ [style], ); - if (isGet) { + if (link.isGet) { return ( } - href={hrefWithData} + href={link.href} style={resolvedStyle} {...rest} {...eventHandlers} diff --git a/packages/solid/src/runtime/link.tsx b/packages/solid/src/runtime/link.tsx index 6789e0fda..3cc53f507 100644 --- a/packages/solid/src/runtime/link.tsx +++ b/packages/solid/src/runtime/link.tsx @@ -1,26 +1,16 @@ import { createMemo, onCleanup, onMount, splitProps, useContext } from 'solid-js'; import type { JSX } from 'solid-js'; -import { isModifiedEvent, mergeDataIntoHref } from 'void/pages-client'; -import type { Href } from 'void/routes'; +import { + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, + type BaseLinkProps, +} from 'void/pages-client'; import { RouterContext } from './context.ts'; -type PrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; -type LinkMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; -type NavigateEvent = { preventDefault(): void }; -type PrefetchOptions = { cacheFor?: LinkProps['cacheFor'] }; - -type CommonProps = { - href: Href; - method?: LinkMethod | Lowercase; - data?: Record; - preserveScroll?: boolean; - preserveState?: boolean; - replace?: boolean; - reloadDocument?: boolean; - viewTransition?: boolean; - prefetch?: boolean | PrefetchStrategy | ReadonlyArray; - cacheFor?: number | string | [string, string]; - onNavigate?: (event: NavigateEvent) => void; +type CommonProps = BaseLinkProps & { children?: JSX.Element; }; @@ -73,103 +63,21 @@ export function Link(props: LinkProps): JSX.Element { ]); const router = useContext(RouterContext); - const linkState = createMemo(() => { - const method = (local.method || 'GET').toUpperCase() as LinkMethod; - const isGet = method === 'GET'; - - if (local.prefetch !== false && local.prefetch !== undefined && !isGet) { - throw new Error( - 'Link: prefetch only supports GET links. Remove `prefetch` or use method="GET".', - ); - } - if (local.reloadDocument && !isGet) { - throw new Error('Link: reloadDocument only supports GET links.'); - } - - return { - href: isGet ? mergeDataIntoHref(local.href, local.data) : local.href, - isGet, - method, - }; - }); - const normalizedMethod = () => linkState().method; - const isGet = () => linkState().isGet; - const hrefWithData = () => linkState().href; - const strategies = createMemo>(() => { - if (local.prefetch === false || local.prefetch === undefined || local.reloadDocument) { - return []; - } - if (local.prefetch === true) { - return ['hover']; - } - if (typeof local.prefetch === 'string') { - return [local.prefetch]; - } - return local.prefetch; - }); + const link = createMemo(() => resolveLink(local)); + const strategies = createMemo(() => linkPrefetchStrategies(local)); const resolvedStyle = createMemo(() => mergeStyle(local.style)); function doPrefetch() { - if (!router?.prefetch) { - return; - } - const current = window.location.pathname + window.location.search; - if (hrefWithData() === current) { - return; - } - const options: PrefetchOptions = {}; - if (local.cacheFor !== undefined) { - options.cacheFor = local.cacheFor; - } - router.prefetch(hrefWithData(), options); - } - - function shouldHandleClick(event: MouseEvent): boolean { - if (local.reloadDocument || !router || event.defaultPrevented || isModifiedEvent(event)) { - return false; - } - if ((event.currentTarget as HTMLElement).hasAttribute('download')) { - return false; - } - - const url = new URL(hrefWithData(), window.location.href); - if (url.origin !== window.location.origin) { - return false; - } - - return true; + prefetchLink(router, link().href, local.cacheFor); } function onClick(event: MouseEvent) { callHandler(local.onClick, event); - if (!shouldHandleClick(event)) { - return; - } - - let navigatePrevented = false; - local.onNavigate?.({ - preventDefault() { - navigatePrevented = true; - }, - }); - if (navigatePrevented) { - event.preventDefault(); - return; - } - - event.preventDefault(); - router?.visit(hrefWithData(), { - method: normalizedMethod(), - data: isGet() ? undefined : local.data, - preserveScroll: local.preserveScroll, - preserveState: local.preserveState, - replace: local.replace, - viewTransition: local.viewTransition, - }); + handleLinkClick(event, router, link(), local); } let hoverTimer: ReturnType | null = null; - let observer: IntersectionObserver | null = null; + let stopObserving: (() => void) | null = null; let el: HTMLElement | undefined; function onMouseEnter(event: MouseEvent) { @@ -217,17 +125,7 @@ export function Link(props: LinkProps): JSX.Element { doPrefetch(); } if (strategies().includes('visible') && el) { - observer = new IntersectionObserver( - (entries) => { - if (entries.some((e) => e.isIntersecting)) { - doPrefetch(); - observer?.disconnect(); - observer = null; - } - }, - { rootMargin: '0px' }, - ); - observer.observe(el); + stopObserving = observeLinkVisible(el, doPrefetch); } }); @@ -235,10 +133,7 @@ export function Link(props: LinkProps): JSX.Element { if (hoverTimer) { clearTimeout(hoverTimer); } - if (observer) { - observer.disconnect(); - observer = null; - } + stopObserving?.(); }); const eventHandlers = createMemo(() => { @@ -268,12 +163,12 @@ export function Link(props: LinkProps): JSX.Element { return ( <> - {isGet() ? ( + {link().isGet ? ( { el = r; }} - href={hrefWithData()} + href={link().href} {...rest} style={resolvedStyle()} {...eventHandlers()} diff --git a/packages/svelte/src/runtime/Link.svelte b/packages/svelte/src/runtime/Link.svelte index 7f16da3f1..3752361ed 100644 --- a/packages/svelte/src/runtime/Link.svelte +++ b/packages/svelte/src/runtime/Link.svelte @@ -1,12 +1,14 @@ -{#if isGet} +{#if link.isGet} = (event: T) => void; -export type LinkProps = { - href: Href; - method?: LinkMethod | Lowercase; - data?: Record; - preserveScroll?: boolean; - preserveState?: boolean; - replace?: boolean; - reloadDocument?: boolean; - viewTransition?: boolean; - prefetch?: boolean | PrefetchStrategy | Array; - cacheFor?: number | string | [string, string]; - onNavigate?: (event: NavigateEvent) => void; +export type LinkProps = BaseLinkProps & { children?: Snippet; onclick?: EventHandler; onmouseenter?: EventHandler; diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index 86d748ebc..77311f53e 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -22,6 +22,17 @@ import { ISLAND_FRONTMATTER_ID } from './serialize.ts'; import { isSameOrigin } from './url.ts'; export { getValidationErrors, isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts'; +export { + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, + type BaseLinkProps, + type LinkNavigateEvent, + type LinkPrefetchStrategy, + type ResolvedLink, +} from './link.ts'; export { VoidActionError, categorizeActionError, diff --git a/packages/void/src/pages/link.ts b/packages/void/src/pages/link.ts new file mode 100644 index 000000000..6f11dfe1d --- /dev/null +++ b/packages/void/src/pages/link.ts @@ -0,0 +1,180 @@ +/// +import type { Href } from 'void/routes'; +import { isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts'; +import type { NavigationMethod, VoidRouter } from './client.ts'; +import type { PrefetchOptions } from './navigation-engine.ts'; +import type { CacheForInput } from './prefetch.ts'; + +export type LinkPrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; +export type LinkNavigateEvent = { preventDefault(): void }; + +/** Props every adapter's Link accepts. Adapters add element attributes and children. */ +export type BaseLinkProps = { + href: Href; + method?: NavigationMethod | Lowercase; + data?: Record; + preserveScroll?: boolean; + preserveState?: boolean; + replace?: boolean; + reloadDocument?: boolean; + viewTransition?: boolean; + prefetch?: boolean | LinkPrefetchStrategy | ReadonlyArray; + cacheFor?: CacheForInput; + onNavigate?: (event: LinkNavigateEvent) => void; +}; + +export type ResolvedLink = { + method: NavigationMethod; + isGet: boolean; + /** The destination, with GET data merged into its query. */ + href: Href; +}; + +type LinkClickEvent = Pick< + MouseEvent, + | 'currentTarget' + | 'ctrlKey' + | 'metaKey' + | 'shiftKey' + | 'altKey' + | 'button' + | 'defaultPrevented' + | 'preventDefault' +>; + +type LinkClickProps = Pick< + BaseLinkProps, + | 'data' + | 'preserveScroll' + | 'preserveState' + | 'replace' + | 'reloadDocument' + | 'viewTransition' + | 'onNavigate' +>; + +/** Normalizes the method and destination, rejecting options that only GET links support. */ +export function resolveLink( + props: Pick, +): ResolvedLink { + const method = (props.method ?? 'GET').toUpperCase() as NavigationMethod; + const isGet = method === 'GET'; + + if (props.prefetch !== undefined && props.prefetch !== false && !isGet) { + throw new Error( + 'Link: prefetch only supports GET links. Remove `prefetch` or use method="GET".', + ); + } + if (props.reloadDocument && !isGet) { + throw new Error('Link: reloadDocument only supports GET links.'); + } + + return { + method, + isGet, + href: isGet ? mergeDataIntoHref(props.href, props.data) : props.href, + }; +} + +export function linkPrefetchStrategies( + props: Pick, +): ReadonlyArray { + const { prefetch } = props; + if (prefetch === undefined || prefetch === false || props.reloadDocument) { + return []; + } + + if (prefetch === true) { + return ['hover']; + } + + if (typeof prefetch === 'string') { + return [prefetch]; + } + + return prefetch; +} + +export function prefetchLink( + router: VoidRouter | null | undefined, + href: Href, + cacheFor: CacheForInput | undefined, +): void { + if (!router?.prefetch) { + return; + } + + const current = window.location.pathname + window.location.search; + if (href === current) { + return; + } + + const options: PrefetchOptions = {}; + if (cacheFor !== undefined) { + options.cacheFor = cacheFor; + } + + router.prefetch(href, options); +} + +/** + * Runs a client-side visit for a plain same-origin click. Modified clicks, downloads, + * cross-origin destinations and `reloadDocument` links are left to the browser. + * Call it after the user's own click handler so its `preventDefault()` is respected. + */ +export function handleLinkClick( + event: LinkClickEvent, + router: VoidRouter | null | undefined, + link: ResolvedLink, + props: LinkClickProps, +): void { + if (props.reloadDocument || !router || event.defaultPrevented || isModifiedEvent(event)) { + return; + } + + if ((event.currentTarget as HTMLElement).hasAttribute('download')) { + return; + } + + const url = new URL(link.href, window.location.href); + if (url.origin !== window.location.origin) { + return; + } + + let navigatePrevented = false; + props.onNavigate?.({ + preventDefault() { + navigatePrevented = true; + }, + }); + + event.preventDefault(); + if (navigatePrevented) { + return; + } + + router.visit(link.href, { + method: link.method, + data: link.isGet ? undefined : props.data, + preserveScroll: props.preserveScroll, + preserveState: props.preserveState, + replace: props.replace, + viewTransition: props.viewTransition, + }); +} + +/** Calls `onVisible` once, the first time `element` enters the viewport. Returns a cleanup. */ +export function observeLinkVisible(element: Element, onVisible: () => void): () => void { + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + onVisible(); + observer.disconnect(); + } + }, + { rootMargin: '0px' }, + ); + observer.observe(element); + + return () => observer.disconnect(); +} diff --git a/packages/void/test/unit/pages-link.test.ts b/packages/void/test/unit/pages-link.test.ts new file mode 100644 index 000000000..c7fa046cf --- /dev/null +++ b/packages/void/test/unit/pages-link.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { VoidRouter } from '../../src/pages/client.ts'; +import { + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, +} from '../../src/pages/link.ts'; + +function createRouter() { + return { + visit: vi.fn(() => Promise.resolve()), + prefetch: vi.fn(() => Promise.resolve()), + } as unknown as VoidRouter & { + visit: ReturnType; + prefetch: ReturnType; + }; +} + +function click( + overrides: Partial> = {}, + attributes: Record = {}, +) { + const preventDefault = vi.fn(); + const event = { + currentTarget: { + getAttribute: (name: string) => attributes[name] ?? null, + hasAttribute: (name: string) => name in attributes, + } as unknown as EventTarget, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + button: 0, + defaultPrevented: false, + preventDefault, + ...overrides, + } as Parameters[0]; + return { event, preventDefault }; +} + +beforeEach(() => { + vi.stubGlobal('window', { + location: new URL('https://app.test/users?page=1'), + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('resolveLink', () => { + it('merges GET data into the destination', () => { + expect(resolveLink({ href: '/users', method: 'get', data: { page: 2 } })).toEqual({ + method: 'GET', + isGet: true, + href: '/users?page=2', + }); + }); + + it('keeps data out of the destination for other methods', () => { + expect(resolveLink({ href: '/users', method: 'post', data: { name: 'Ada' } })).toEqual({ + method: 'POST', + isGet: false, + href: '/users', + }); + }); + + it('rejects prefetch on non-GET links', () => { + expect(() => resolveLink({ href: '/users', method: 'POST', prefetch: 'mount' })).toThrow( + 'Link: prefetch only supports GET links', + ); + expect(() => resolveLink({ href: '/users', method: 'POST', prefetch: false })).not.toThrow(); + }); + + it('rejects reloadDocument on non-GET links', () => { + expect(() => resolveLink({ href: '/users', method: 'DELETE', reloadDocument: true })).toThrow( + 'Link: reloadDocument only supports GET links.', + ); + }); +}); + +describe('linkPrefetchStrategies', () => { + it.each([ + [undefined, []], + [false, []], + [true, ['hover']], + ['visible', ['visible']], + [['mount', 'click'] as const, ['mount', 'click']], + ] as const)('maps prefetch=%j to %j', (prefetch, expected) => { + expect(linkPrefetchStrategies({ prefetch })).toEqual(expected); + }); + + it('disables prefetch for document reloads', () => { + expect(linkPrefetchStrategies({ prefetch: 'mount', reloadDocument: true })).toEqual([]); + }); +}); + +describe('prefetchLink', () => { + it('prefetches other destinations with cacheFor only when given', () => { + const router = createRouter(); + prefetchLink(router, '/users/42', undefined); + prefetchLink(router, '/users/43', '30s'); + + expect(router.prefetch.mock.calls).toEqual([ + ['/users/42', {}], + ['/users/43', { cacheFor: '30s' }], + ]); + }); + + it('skips the current page and missing routers', () => { + const router = createRouter(); + prefetchLink(router, '/users?page=1', undefined); + prefetchLink(null, '/users/42', undefined); + + expect(router.prefetch).not.toHaveBeenCalled(); + }); +}); + +describe('handleLinkClick', () => { + it('visits same-origin destinations with the navigation options', () => { + const router = createRouter(); + const { event, preventDefault } = click(); + handleLinkClick(event, router, resolveLink({ href: '/users/42' }), { + data: { ignored: true }, + preserveScroll: true, + replace: true, + }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(router.visit).toHaveBeenCalledWith('/users/42', { + method: 'GET', + data: undefined, + preserveScroll: true, + preserveState: undefined, + replace: true, + viewTransition: undefined, + }); + }); + + it('sends data as the body for other methods', () => { + const router = createRouter(); + const { event } = click(); + const data = { name: 'Ada' }; + handleLinkClick(event, router, resolveLink({ href: '/users', method: 'post', data }), { + data, + }); + + expect(router.visit).toHaveBeenCalledWith( + '/users', + expect.objectContaining({ method: 'POST', data }), + ); + }); + + it.each([ + ['a modified click', click({ metaKey: true }).event, '/users/42', {}], + ['a new-tab target', click({}, { target: '_blank' }).event, '/users/42', {}], + ['a download', click({}, { download: '' }).event, '/users/42', {}], + ['a prevented click', click({ defaultPrevented: true }).event, '/users/42', {}], + ['a cross-origin destination', click().event, 'https://example.com/', {}], + ['a document reload', click().event, '/users/42', { reloadDocument: true }], + ] as const)('leaves %s to the browser', (_name, event, href, props) => { + const router = createRouter(); + handleLinkClick(event, router, resolveLink({ href }), props); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(router.visit).not.toHaveBeenCalled(); + }); + + it('cancels the visit when onNavigate prevents it', () => { + const router = createRouter(); + const { event, preventDefault } = click(); + handleLinkClick(event, router, resolveLink({ href: '/users/42' }), { + onNavigate: (navigation) => navigation.preventDefault(), + }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(router.visit).not.toHaveBeenCalled(); + }); +}); + +describe('observeLinkVisible', () => { + it('calls back once when the element becomes visible', () => { + let notify: (entries: Array<{ isIntersecting: boolean }>) => void = () => {}; + const disconnect = vi.fn(); + const observe = vi.fn(); + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: typeof notify) { + notify = callback; + } + observe = observe; + disconnect = disconnect; + }, + ); + const element = {} as Element; + const onVisible = vi.fn(); + const stop = observeLinkVisible(element, onVisible); + + notify([{ isIntersecting: false }]); + expect(onVisible).not.toHaveBeenCalled(); + notify([{ isIntersecting: true }]); + + expect(observe).toHaveBeenCalledWith(element); + expect(onVisible).toHaveBeenCalledOnce(); + expect(disconnect).toHaveBeenCalledOnce(); + stop(); + expect(disconnect).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/vue/src/runtime/link.ts b/packages/vue/src/runtime/link.ts index f88d9fb76..58554ab5c 100644 --- a/packages/vue/src/runtime/link.ts +++ b/packages/vue/src/runtime/link.ts @@ -1,7 +1,12 @@ import { - isModifiedEvent, - mergeDataIntoHref, - type PrefetchOptions, + handleLinkClick, + linkPrefetchStrategies, + observeLinkVisible, + prefetchLink, + resolveLink, + type BaseLinkProps, + type LinkNavigateEvent, + type LinkPrefetchStrategy, type VoidRouter, } from 'void/pages-client'; import type { Href } from 'void/routes'; @@ -20,42 +25,29 @@ import { type PropType, } from 'vue'; -type PrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; -type LinkMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; -type NavigateEvent = { preventDefault(): void }; - -type VoidLinkProps = { - href: Href; - method?: LinkMethod | Lowercase; - data?: Record; - preserveScroll?: boolean; - preserveState?: boolean; - replace?: boolean; - reloadDocument?: boolean; - viewTransition?: boolean; - onNavigate?: (event: NavigateEvent) => void; - prefetch?: boolean | PrefetchStrategy | ReadonlyArray; - cacheFor?: number | string | [string, string]; -}; +type LinkMethod = NonNullable; // Attributes and listeners fall through to the rendered or