diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index 004b9a3ef..aba050f38 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -324,6 +324,29 @@ A link is active only on its own path by default. Set `activeMatch="prefix"` to Paths match the way pages are routed: letter case and trailing slashes count, while the query string and hash are ignored. Links to other origins and non-GET links are never active. An `aria-current` or `data-void-active` you pass to `Link` replaces the computed value. +### Prefetching + +Add `prefetch` to a `Link` to load its page before the user clicks, so the visit doesn't wait for the server: + +```tsx + + Dashboard + +``` + +`prefetch` on its own loads the page on hover, keyboard focus, or touch. A `Link` can also prefetch on click, as soon as it renders, or when it scrolls into view, and `cacheFor` sets how long a prefetched page is reused. See [Prefetching](../../reference/api/pages.md#prefetching) for every option. + +To give every `Link` a prefetch strategy, set `prefetch.defaultStrategy` in the adapter plugin. A `Link` that sets its own `prefetch` keeps it: + +```ts +// vite.config.ts +voidReact({ prefetch: { defaultStrategy: 'hover' } }); +``` + +Prefer `'hover'` or `'click'` as the default. With `'mount'` or `'visible'`, every `Link` loads its page as soon as it renders or scrolls into view, including links in long lists. + +Prefetching runs the destination's loader, so keep loaders free of side effects such as counting views. + ## Scroll Restoration Void manages scroll position for Pages automatically: diff --git a/docs/reference/api/pages.md b/docs/reference/api/pages.md index dd10cd0fd..1e26ac2ed 100644 --- a/docs/reference/api/pages.md +++ b/docs/reference/api/pages.md @@ -16,6 +16,34 @@ With [`routing.typedRoutes`](../config.md#routing-typedroutes), `href` and route See [Typed navigation](../../guide/pages-routing/overview.md#typed-navigation) for framework examples. +## Prefetching {#prefetching} + +`Link`'s `prefetch` prop takes `true`, `false`, a strategy, or an array of strategies: + +```ts +type LinkPrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; +``` + +| Value | Loads the page | +| ------------------- | --------------------------------------------------------------------------------------------- | +| `'hover'` or `true` | When the pointer rests on the link for `hoverDelay`, when it gets keyboard focus, or on touch | +| `'click'` | When the mouse button or finger goes down, just before the click | +| `'mount'` | As soon as the link renders | +| `'visible'` | The first time the link scrolls into view | +| `false` | Never | + +With an array such as `['visible', 'hover']`, the first strategy to happen loads the page, and later ones reuse it. `prefetch` works only on GET links, and links to other sites are never prefetched. + +`cacheFor` sets how long a prefetched page is reused, in milliseconds or as a duration such as `'5m'`. A pair such as `['30s', '5m']` treats the page as fresh for 30 seconds, then still shows it for up to 5 minutes while loading a fresh copy in the background. + +Each adapter plugin's `prefetch` option sets app-wide values: + +| Option | Default | Description | +| ----------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defaultStrategy` | `false` | The `prefetch` value for every `Link` that doesn't set its own. A `Link`'s own `prefetch` replaces it, and `prefetch={false}` turns it off. Links with a non-GET `method` or `reloadDocument` don't use it. | +| `hoverDelay` | `75` | Milliseconds the pointer rests on a link before `'hover'` prefetches. | +| `cacheFor` | `'30s'` | The `cacheFor` value for every `Link` that doesn't set its own. | + ## `@void/vue` {#void-vue} ### `voidVue(options?)` {#voidvue-options} @@ -43,8 +71,9 @@ interface VoidVueOptions { vue?: VuePluginOptions; // passed through to @vitejs/plugin-vue viewTransitions?: boolean; // enable View Transitions API for navigations prefetch?: { - hoverDelay?: number; - cacheFor?: number | string | [string, string]; + defaultStrategy?: boolean | LinkPrefetchStrategy | LinkPrefetchStrategy[]; // default: false + hoverDelay?: number; // default: 75 (ms) + cacheFor?: number | string | [string, string]; // default: '30s' }; } ``` @@ -95,8 +124,9 @@ interface VoidReactOptions { react?: ReactPluginOptions; // passed through to @vitejs/plugin-react viewTransitions?: boolean; // enable View Transitions API for navigations prefetch?: { - hoverDelay?: number; - cacheFor?: number | string | [string, string]; + defaultStrategy?: boolean | LinkPrefetchStrategy | LinkPrefetchStrategy[]; // default: false + hoverDelay?: number; // default: 75 (ms) + cacheFor?: number | string | [string, string]; // default: '30s' }; } ``` @@ -174,8 +204,9 @@ interface VoidSvelteOptions { svelte?: SveltePluginOptions; // passed through to @sveltejs/vite-plugin-svelte viewTransitions?: boolean; // enable View Transitions API prefetch?: { - hoverDelay?: number; - cacheFor?: number | string | [string, string]; + defaultStrategy?: boolean | LinkPrefetchStrategy | LinkPrefetchStrategy[]; // default: false + hoverDelay?: number; // default: 75 (ms) + cacheFor?: number | string | [string, string]; // default: '30s' }; } ``` @@ -224,8 +255,9 @@ interface VoidSolidOptions { solid?: SolidPluginOptions; // passed through to vite-plugin-solid viewTransitions?: boolean; // enable View Transitions API prefetch?: { - hoverDelay?: number; - cacheFor?: number | string | [string, string]; + defaultStrategy?: boolean | LinkPrefetchStrategy | LinkPrefetchStrategy[]; // default: false + hoverDelay?: number; // default: 75 (ms) + cacheFor?: number | string | [string, string]; // default: '30s' }; } ``` diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index 4a8105cdd..c87555ace 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -191,7 +191,7 @@ function generateClientEntry( prefetch?: PrefetchConfig, staticPageData?: StaticPageDataBuildOptions, ): string { - const { hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch); + const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch); return ` import { startReactPages } from "@void/react/pages-client"; import { parseCacheFor } from "void/pages-prefetch"; @@ -208,6 +208,7 @@ function generateClientEntry( viewTransitions: ${!!viewTransitions}, prefetchHoverDelay: ${hoverDelay}, prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}), + prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)}, }); export { router }; `; diff --git a/packages/react/src/runtime/link.tsx b/packages/react/src/runtime/link.tsx index 3c73e02bb..bab4608d5 100644 --- a/packages/react/src/runtime/link.tsx +++ b/packages/react/src/runtime/link.tsx @@ -32,7 +32,7 @@ export function Link({ replace = false, reloadDocument = false, viewTransition, - prefetch = false, + prefetch, cacheFor, activeMatch, onNavigate, @@ -53,9 +53,11 @@ export function Link({ () => resolveLink({ href, method, data, prefetch, reloadDocument }), [data, href, method, prefetch, reloadDocument], ); + const { isGet } = link; + const defaultStrategy = router?._prefetchDefaultStrategy; const strategies = useMemo( - () => linkPrefetchStrategies({ prefetch, reloadDocument }), - [prefetch, reloadDocument], + () => linkPrefetchStrategies({ prefetch, reloadDocument }, { isGet }, defaultStrategy), + [defaultStrategy, isGet, prefetch, reloadDocument], ); // The router context changes with the route URL, so this follows navigations. const activeAttributes = useMemo( diff --git a/packages/react/src/runtime/pages-client.ts b/packages/react/src/runtime/pages-client.ts index f24a0a813..dcb85a69c 100644 --- a/packages/react/src/runtime/pages-client.ts +++ b/packages/react/src/runtime/pages-client.ts @@ -27,7 +27,7 @@ import { type VisitOptions, type VoidRouter, } from 'void/pages-client'; -import type { CacheForResult } from 'void/pages-prefetch'; +import type { CacheForResult, LinkPrefetchValue } from 'void/pages-prefetch'; import type { PageObject } from 'void/pages-protocol'; import type { RouteUrlInput } from 'void/routes'; import { setActionRouter } from './action.ts'; @@ -57,6 +57,7 @@ export type ReactPagesConfig = Readonly<{ viewTransitions: boolean; prefetchHoverDelay: number; prefetchDefaultCacheFor: CacheForResult; + prefetchDefaultStrategy?: LinkPrefetchValue; cacheVisitedPageData?: boolean; /** Show this fallback for client visits whose destination remains pending at the next paint. */ navigationFallback?: ComponentType<{ initialPageData: PageObject }>; @@ -282,6 +283,7 @@ function appReducer(state: AppState, action: AppAction): AppState { function createRouter(engine: Awaited>): VoidRouter { const router: VoidRouter = { _hoverDelay: engine._hoverDelay, + _prefetchDefaultStrategy: engine._prefetchDefaultStrategy, get url() { return new URL(currentUrl(), window.location.origin); }, @@ -746,6 +748,7 @@ export async function startReactPages(config: ReactPagesConfig): Promise = []; + +afterEach(() => { + for (const { root, container } of mountedRoots.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +function createRouter(defaultStrategy?: LinkPrefetchValue) { + const prefetch = vi.fn(() => Promise.resolve()); + const router: VoidRouter = { + _hoverDelay: 75, + _prefetchDefaultStrategy: defaultStrategy, + get url() { + return new URL(window.location.href); + }, + get path() { + return window.location.pathname; + }, + get query() { + return new URLSearchParams(window.location.search); + }, + get params() { + return {}; + }, + visit: vi.fn(), + refresh: vi.fn(), + prefetch, + flush: vi.fn(), + flushAll: vi.fn(), + }; + return { router, prefetch }; +} + +async function mountLink(router: VoidRouter, props: LinkProps) { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + mountedRoots.push({ root, container }); + await act(async () => { + root.render( + createElement(RouterContext.Provider, { value: router }, createElement(Link, props, 'Go')), + ); + }); + return container.firstElementChild as HTMLElement; +} + +describe('Link default prefetch strategy', () => { + it('prefetches a Link without a prefetch prop with the app default', async () => { + const { router, prefetch } = createRouter('mount'); + await mountLink(router, { href: '/users' }); + + expect(prefetch).toHaveBeenCalledWith('/users', {}); + }); + + it('lets the Link prop replace the default', async () => { + const { router, prefetch } = createRouter('mount'); + const link = await mountLink(router, { href: '/users', prefetch: 'hover' }); + expect(prefetch).not.toHaveBeenCalled(); + + await act(async () => { + link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + }); + expect(prefetch).toHaveBeenCalledWith('/users', {}); + }); + + it('turns the default off with prefetch={false}', async () => { + const { router, prefetch } = createRouter('mount'); + await mountLink(router, { href: '/users', prefetch: false }); + + expect(prefetch).not.toHaveBeenCalled(); + }); + + it('skips non-GET links without throwing', async () => { + const { router, prefetch } = createRouter('mount'); + const button = await mountLink(router, { href: '/users', method: 'POST' }); + + expect(button.tagName).toBe('BUTTON'); + expect(prefetch).not.toHaveBeenCalled(); + }); + + it('skips links to other sites', async () => { + const { router, prefetch } = createRouter('mount'); + await mountLink(router, { href: 'https://example.com/users' }); + + expect(prefetch).not.toHaveBeenCalled(); + }); + + it('does not prefetch without an app default', async () => { + const { router, prefetch } = createRouter(); + await mountLink(router, { href: '/users' }); + + expect(prefetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index 550dd318b..c07e942ec 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -137,7 +137,7 @@ function generateClientEntry( prefetch?: PrefetchConfig, staticPageData?: StaticPageDataBuildOptions, ): string { - const { hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch); + const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch); return ` import { createSignal, createComponent, createMemo, mergeProps, untrack, batch } from "solid-js"; import { createMutable } from "solid-js/store"; @@ -201,6 +201,7 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit viewTransitions: ${!!viewTransitions}, prefetchHoverDelay: ${hoverDelay}, prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}), + prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)}, onRouteChange(url) { setRouteUrl(url); }, diff --git a/packages/solid/src/runtime/link.tsx b/packages/solid/src/runtime/link.tsx index e71b7b5d5..d0624e96e 100644 --- a/packages/solid/src/runtime/link.tsx +++ b/packages/solid/src/runtime/link.tsx @@ -66,7 +66,9 @@ export function Link(props: LinkProps): JSX.Element { const router = useContext(RouterContext); const link = createMemo(() => resolveLink(local)); - const strategies = createMemo(() => linkPrefetchStrategies(local)); + const strategies = createMemo(() => + linkPrefetchStrategies(local, link(), router?._prefetchDefaultStrategy), + ); const resolvedStyle = createMemo(() => mergeStyle(local.style)); // Reading router.url tracks the route URL, so this follows navigations. const activeAttributes = createMemo(() => diff --git a/packages/svelte/src/plugin-pages.ts b/packages/svelte/src/plugin-pages.ts index 63bf382dd..a9c69ac46 100644 --- a/packages/svelte/src/plugin-pages.ts +++ b/packages/svelte/src/plugin-pages.ts @@ -143,7 +143,7 @@ function generateClientEntry( prefetch?: PrefetchConfig, staticPageData?: StaticPageDataBuildOptions, ): string { - const { hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch); + const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch); return ` import { hydrate, mount, tick } from "svelte"; import { writable } from "svelte/store"; @@ -189,6 +189,7 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit viewTransitions: ${!!viewTransitions}, prefetchHoverDelay: ${hoverDelay}, prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}), + prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)}, onRouteChange(url) { routeUrl = url; routeStore.set(url); diff --git a/packages/svelte/src/runtime/Link.svelte b/packages/svelte/src/runtime/Link.svelte index 3a7015601..066db80a0 100644 --- a/packages/svelte/src/runtime/Link.svelte +++ b/packages/svelte/src/runtime/Link.svelte @@ -20,7 +20,7 @@ replace = false, reloadDocument = false, viewTransition = undefined, - prefetch: prefetchProp = false, + prefetch: prefetchProp = undefined, cacheFor = undefined, activeMatch = undefined, onNavigate = undefined, @@ -117,7 +117,13 @@ const link = $derived( resolveLink({ href, method, data, prefetch: prefetchProp, reloadDocument }), ); - const strategies = $derived(linkPrefetchStrategies({ prefetch: prefetchProp, reloadDocument })); + const strategies = $derived( + linkPrefetchStrategies( + { prefetch: prefetchProp, reloadDocument }, + link, + router?._prefetchDefaultStrategy, + ), + ); // Reading router.url tracks the route URL, so this follows navigations. const activeAttributes = $derived(linkActiveAttributes(router, link, activeMatch)); const resolvedStyle = $derived( diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index 3d02c8c96..a0ec4534c 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -18,6 +18,7 @@ import { type VoidRouterAdapter as PageNavigationAdapter, type PrefetchOptions, } from './navigation-engine.ts'; +import type { LinkPrefetchValue } from './prefetch.ts'; import { ISLAND_FRONTMATTER_ID } from './serialize.ts'; import { isSameOrigin } from './url.ts'; @@ -33,6 +34,7 @@ export { type LinkActiveAttributes, type LinkActiveMatch, type LinkNavigateEvent, + type LinkPrefetchValue, type LinkPrefetchStrategy, type ResolvedLink, } from './link.ts'; @@ -80,6 +82,8 @@ export type NavigationState = { export type VoidRouter = { _hoverDelay: number; + /** How Links without a `prefetch` prop prefetch. Absent during SSR. */ + _prefetchDefaultStrategy?: LinkPrefetchValue; readonly url: URL; readonly path: string; readonly query: URLSearchParams; @@ -172,6 +176,10 @@ export function createRouterFacade( return router._hoverDelay; }, + get _prefetchDefaultStrategy() { + return router._prefetchDefaultStrategy; + }, + get url() { return new URL(getCurrentUrl() || '/', router.url); }, @@ -491,6 +499,7 @@ export async function createVoidRouter(config: VoidRouterConfig): Promise<{ const router: VoidRouter = { _hoverDelay: engine._hoverDelay, + _prefetchDefaultStrategy: engine._prefetchDefaultStrategy, get url() { return toUrl(routeUrl); diff --git a/packages/void/src/pages/link.ts b/packages/void/src/pages/link.ts index 8a978194f..7d55394bb 100644 --- a/packages/void/src/pages/link.ts +++ b/packages/void/src/pages/link.ts @@ -3,10 +3,10 @@ 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'; +import type { CacheForInput, LinkPrefetchValue, LinkPrefetchStrategy } from './prefetch.ts'; import { decodePagePath } from './url.ts'; -export type LinkPrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; +export type { LinkPrefetchValue, LinkPrefetchStrategy }; export type LinkNavigateEvent = { preventDefault(): void }; export type LinkActiveMatch = 'exact' | 'prefix'; @@ -26,7 +26,8 @@ export type BaseLinkProps = { replace?: boolean; reloadDocument?: boolean; viewTransition?: boolean; - prefetch?: boolean | LinkPrefetchStrategy | ReadonlyArray; + /** If omitted, the Link uses the app's `prefetch.defaultStrategy`. */ + prefetch?: LinkPrefetchValue; cacheFor?: CacheForInput; activeMatch?: LinkActiveMatch; onNavigate?: (event: LinkNavigateEvent) => void; @@ -85,11 +86,21 @@ export function resolveLink( }; } +/** + * The events that prefetch a Link. A Link without a `prefetch` prop uses the app's + * `defaultStrategy`, but only when it is a GET link, the only kind that can prefetch. + */ export function linkPrefetchStrategies( props: Pick, + link: Pick, + defaultStrategy: LinkPrefetchValue = false, ): ReadonlyArray { - const { prefetch } = props; - if (prefetch === undefined || prefetch === false || props.reloadDocument) { + if (props.reloadDocument) { + return []; + } + + const prefetch = props.prefetch ?? (link.isGet ? defaultStrategy : false); + if (prefetch === false) { return []; } @@ -158,6 +169,11 @@ export function prefetchLink( return; } + // The router matches only the path, which on another site names a different page. + if (new URL(href, window.location.href).origin !== window.location.origin) { + return; + } + const current = window.location.pathname + window.location.search; if (href === current) { return; diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index c8919f196..218667324 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -5,7 +5,7 @@ import { createActionErrorFromResponse, isAbortError } from './action-result.ts' import { initClientHead, applyClientHead } from './head-client.ts'; import type { HeadDescriptor } from './head.ts'; import { PrefetchCache, parseCacheFor } from './prefetch.ts'; -import type { CacheForInput, CacheForResult } from './prefetch.ts'; +import type { CacheForInput, CacheForResult, LinkPrefetchValue } from './prefetch.ts'; import type { PageObject } from './protocol.ts'; import { createScrollRestoration } from './scroll-restoration.ts'; import { decodePagePath, isSameOrigin } from './url.ts'; @@ -87,6 +87,8 @@ export type PageNavigationEngineConfig = { viewTransitions: boolean; prefetchHoverDelay: number; prefetchDefaultCacheFor: CacheForResult; + /** How Links without a `prefetch` prop prefetch. Defaults to `false`. */ + prefetchDefaultStrategy?: LinkPrefetchValue; /** Reuse successful GET page shells on later visits, refreshing stale shells in the background. */ cacheVisitedPageData?: boolean; }; @@ -114,6 +116,7 @@ export type PageNavigationResource = { export type PageNavigationEngine = { _hoverDelay: number; + _prefetchDefaultStrategy: LinkPrefetchValue; initialPageData: PageData; initialComponent: unknown; initialLayouts: Array; @@ -248,6 +251,7 @@ export async function createPageNavigationEngine( staticPageDataFastPath: STATIC_PAGE_DATA_FAST_PATH = STATIC_PAGE_DATA_ENABLED, prefetchHoverDelay: PREFETCH_HOVER_DELAY, prefetchDefaultCacheFor: PREFETCH_DEFAULT_CACHE_FOR, + prefetchDefaultStrategy: PREFETCH_DEFAULT_STRATEGY = false, cacheVisitedPageData: CACHE_VISITED_PAGE_DATA = false, } = config; @@ -986,6 +990,7 @@ export async function createPageNavigationEngine( const engine: PageNavigationEngine = { _hoverDelay: PREFETCH_HOVER_DELAY, + _prefetchDefaultStrategy: PREFETCH_DEFAULT_STRATEGY, initialPageData, initialComponent, initialLayouts, diff --git a/packages/void/src/pages/plugin.ts b/packages/void/src/pages/plugin.ts index d2cd24651..bebe3c9bf 100644 --- a/packages/void/src/pages/plugin.ts +++ b/packages/void/src/pages/plugin.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { dirname } from 'pathslash'; import { normalizePath, type Plugin, type ResolvedConfig, type UserConfig } from 'vite'; -import type { CacheForInput } from './prefetch.ts'; +import { assertLinkPrefetchValue, type CacheForInput, type LinkPrefetchValue } from './prefetch.ts'; import { scanPages } from './scan.ts'; import { resolveStaticPageDataBuildOptions, @@ -19,6 +19,8 @@ type PagesPluginContext = { /** The `prefetch` option of a framework adapter plugin. */ export type PrefetchConfig = { + /** How a Link without its own `prefetch` prop prefetches. Accepts the same values. */ + defaultStrategy?: LinkPrefetchValue; hoverDelay?: number; cacheFor?: CacheForInput; }; @@ -30,11 +32,20 @@ export type PagesPluginOptions = { prefetch?: PrefetchConfig; }; -/** `prefetch` with its defaults: a 75 ms hover delay and a 30 second cache. */ +/** + * `prefetch` with its defaults: Links prefetch only when they ask to, with a 75 ms + * hover delay and a 30 second cache. + */ export function resolvePrefetchConfig( prefetch: PrefetchConfig | undefined, ): Required { - return { hoverDelay: prefetch?.hoverDelay ?? 75, cacheFor: prefetch?.cacheFor ?? '30s' }; + const defaultStrategy = prefetch?.defaultStrategy ?? false; + assertLinkPrefetchValue(defaultStrategy, 'prefetch.defaultStrategy'); + return { + defaultStrategy, + hoverDelay: prefetch?.hoverDelay ?? 75, + cacheFor: prefetch?.cacheFor ?? '30s', + }; } type CreatePagesPluginOptions = { diff --git a/packages/void/src/pages/prefetch.ts b/packages/void/src/pages/prefetch.ts index 3fa43e1af..f0c662939 100644 --- a/packages/void/src/pages/prefetch.ts +++ b/packages/void/src/pages/prefetch.ts @@ -1,3 +1,13 @@ +const LINK_PREFETCH_STRATEGIES = ['hover', 'click', 'mount', 'visible'] as const; + +export type LinkPrefetchStrategy = (typeof LINK_PREFETCH_STRATEGIES)[number]; + +/** A Link's `prefetch` prop, and the `prefetch.defaultStrategy` of an adapter plugin. */ +export type LinkPrefetchValue = + | boolean + | LinkPrefetchStrategy + | ReadonlyArray; + export type CacheForResult = { stale: number; expires: number; @@ -5,6 +15,30 @@ export type CacheForResult = { export type CacheForInput = number | string | [string, string]; +function isLinkPrefetchStrategy(value: unknown): value is LinkPrefetchStrategy { + return (LINK_PREFETCH_STRATEGIES as ReadonlyArray).includes(value); +} + +/** Rejects an invalid `prefetch.defaultStrategy` when the plugin is configured. */ +export function assertLinkPrefetchValue( + value: unknown, + name: string, +): asserts value is LinkPrefetchValue { + if (typeof value === 'boolean') { + return; + } + const strategies = Array.isArray(value) ? value : [value]; + for (const strategy of strategies) { + if (!isLinkPrefetchStrategy(strategy)) { + const allowed = LINK_PREFETCH_STRATEGIES.map((s) => `'${s}'`).join(', '); + throw new Error( + `${name}: Invalid prefetch strategy ${JSON.stringify(strategy)}. ` + + `Use true, false, ${allowed}, or an array of strategies.`, + ); + } + } +} + const UNITS: Record = { ms: 1, s: 1_000, diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index 8bc8e9b65..740155846 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import type { RouteDestination } from 'void/routes'; import { + createRouterFacade, createVoidRouter, mergeDataIntoHref, ssrProxy, @@ -124,6 +125,19 @@ afterEach(() => { }); describe('createVoidRouter', () => { + it('exposes the default prefetch strategy to Links', async () => { + stubClientGlobals(); + const { router } = await createTestRouter({ prefetchDefaultStrategy: ['hover', 'click'] }); + expect(router._prefetchDefaultStrategy).toEqual(['hover', 'click']); + expect(createRouterFacade(router, () => '/')._prefetchDefaultStrategy).toEqual([ + 'hover', + 'click', + ]); + + const { router: plain } = await createTestRouter(); + expect(plain._prefetchDefaultStrategy).toBe(false); + }); + it('uses the same typed destination for visits, prefetching and cache invalidation', async () => { stubClientGlobals(); const pageData = { component: 'home', props: { title: 'Typed destination' } }; diff --git a/packages/void/test/unit/pages-link.test.ts b/packages/void/test/unit/pages-link.test.ts index be6b4ca64..9e2654ed4 100644 --- a/packages/void/test/unit/pages-link.test.ts +++ b/packages/void/test/unit/pages-link.test.ts @@ -85,6 +85,9 @@ describe('resolveLink', () => { }); describe('linkPrefetchStrategies', () => { + const get = { isGet: true }; + const post = { isGet: false }; + it.each([ [undefined, []], [false, []], @@ -92,11 +95,36 @@ describe('linkPrefetchStrategies', () => { ['visible', ['visible']], [['mount', 'click'] as const, ['mount', 'click']], ] as const)('maps prefetch=%j to %j', (prefetch, expected) => { - expect(linkPrefetchStrategies({ prefetch })).toEqual(expected); + expect(linkPrefetchStrategies({ prefetch }, get)).toEqual(expected); }); it('disables prefetch for document reloads', () => { - expect(linkPrefetchStrategies({ prefetch: 'mount', reloadDocument: true })).toEqual([]); + expect(linkPrefetchStrategies({ prefetch: 'mount', reloadDocument: true }, get)).toEqual([]); + expect(linkPrefetchStrategies({ reloadDocument: true }, get, 'hover')).toEqual([]); + }); + + it.each([ + [true, ['hover']], + ['visible', ['visible']], + [['hover', 'click'] as const, ['hover', 'click']], + [false, []], + ] as const)( + 'uses defaultStrategy=%j for a Link without a prefetch prop', + (fallback, expected) => { + expect(linkPrefetchStrategies({}, get, fallback)).toEqual(expected); + }, + ); + + it('replaces the default with the Link prop', () => { + expect(linkPrefetchStrategies({ prefetch: 'visible' }, get, ['hover', 'click'])).toEqual([ + 'visible', + ]); + expect(linkPrefetchStrategies({ prefetch: true }, get, 'mount')).toEqual(['hover']); + expect(linkPrefetchStrategies({ prefetch: false }, get, 'hover')).toEqual([]); + }); + + it('applies the default only to GET links', () => { + expect(linkPrefetchStrategies({}, post, 'hover')).toEqual([]); }); }); @@ -205,6 +233,15 @@ describe('prefetchLink', () => { expect(router.prefetch).not.toHaveBeenCalled(); }); + + it('prefetches same-origin URLs and skips other sites and schemes', () => { + const router = createRouter(); + prefetchLink(router, 'https://app.test/users/42', undefined); + prefetchLink(router, 'https://example.com/users/42', undefined); + prefetchLink(router, 'mailto:ada@example.com', undefined); + + expect(router.prefetch.mock.calls).toEqual([['https://app.test/users/42', {}]]); + }); }); describe('handleLinkClick', () => { diff --git a/packages/void/test/unit/pages-plugin-adapters.test.ts b/packages/void/test/unit/pages-plugin-adapters.test.ts index 69ae3c08d..825a74e94 100644 --- a/packages/void/test/unit/pages-plugin-adapters.test.ts +++ b/packages/void/test/unit/pages-plugin-adapters.test.ts @@ -45,7 +45,7 @@ describe.each(adapters)('$name Pages plugin contract', (adapter) => { const plugins = adapter.create(pagesDir, { root, viewTransitions: true, - prefetch: { hoverDelay: 123, cacheFor: '45s' }, + prefetch: { defaultStrategy: ['hover', 'click'], hoverDelay: 123, cacheFor: '45s' }, }); const plugin = (Array.isArray(plugins) ? plugins : [plugins]).find( (candidate: Plugin) => candidate.name === `void-${adapter.name}:pages`, @@ -146,6 +146,7 @@ describe.each(adapters)('$name Pages plugin contract', (adapter) => { expect(client).toContain('viewTransitions: true'); expect(client).toContain('prefetchHoverDelay: 123'); expect(client).toContain('parseCacheFor("45s")'); + expect(client).toContain('prefetchDefaultStrategy: ["hover","click"]'); plugin.configResolved({ publicDir: false }); expect(await handler(plugin.load)('\0virtual:void-pages-client')).toContain( diff --git a/packages/void/test/unit/pages-plugin.test.ts b/packages/void/test/unit/pages-plugin.test.ts index 03b172aa7..a41e06eeb 100644 --- a/packages/void/test/unit/pages-plugin.test.ts +++ b/packages/void/test/unit/pages-plugin.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { normalizePath } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { createPagesPlugin } from '../../src/pages/plugin.ts'; +import { createPagesPlugin, resolvePrefetchConfig } from '../../src/pages/plugin.ts'; import { scanPages } from '../../src/pages/scan.ts'; import type { PageScanResult } from '../../src/pages/types.ts'; @@ -137,3 +137,26 @@ describe('shared Pages plugin scan lifecycle', () => { expect(scanPages).toHaveBeenCalledTimes(1); }); }); + +describe('resolvePrefetchConfig', () => { + it('leaves Links without a prefetch prop off by default', () => { + expect(resolvePrefetchConfig(undefined)).toEqual({ + defaultStrategy: false, + hoverDelay: 75, + cacheFor: '30s', + }); + }); + + it.each([true, 'visible', ['hover', 'click'], []] as const)( + 'accepts defaultStrategy %j', + (defaultStrategy) => { + expect(resolvePrefetchConfig({ defaultStrategy }).defaultStrategy).toEqual(defaultStrategy); + }, + ); + + it.each(['hovr', ['hover', 'never'], 1])('rejects defaultStrategy %j', (defaultStrategy) => { + expect(() => resolvePrefetchConfig({ defaultStrategy: defaultStrategy as never })).toThrow( + /prefetch\.defaultStrategy: Invalid prefetch strategy/, + ); + }); +}); diff --git a/packages/vue/src/plugin-pages.ts b/packages/vue/src/plugin-pages.ts index 39542dd44..662c5a3cb 100644 --- a/packages/vue/src/plugin-pages.ts +++ b/packages/vue/src/plugin-pages.ts @@ -257,7 +257,7 @@ function generateClientEntry( prefetch?: PrefetchConfig, staticPageData?: StaticPageDataBuildOptions, ): string { - const { hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch); + const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch); return ` import { createApp, createSSRApp, h, reactive, provide, shallowRef, nextTick, ref } from "vue"; import { createDeferred, resolveDeferred, rejectDeferred } from "@void/vue/deferred"; @@ -306,6 +306,7 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit viewTransitions: ${!!viewTransitions}, prefetchHoverDelay: ${hoverDelay}, prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}), + prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)}, onRouteChange(url) { routeUrl.value = url; }, diff --git a/packages/vue/src/runtime/link.ts b/packages/vue/src/runtime/link.ts index 12d1c62ed..4c50f5a6e 100644 --- a/packages/vue/src/runtime/link.ts +++ b/packages/vue/src/runtime/link.ts @@ -8,7 +8,7 @@ import { type BaseLinkProps, type LinkActiveMatch, type LinkNavigateEvent, - type LinkPrefetchStrategy, + type LinkPrefetchValue, type VoidRouter, } from 'void/pages-client'; import type { Href } from 'void/routes'; @@ -47,11 +47,10 @@ const linkPropOptions = { type: Function as PropType<(event: LinkNavigateEvent) => void>, default: undefined, }, + // undefined, not false: an omitted prop uses the app default. prefetch: { - type: [Boolean, String, Array] as PropType< - boolean | LinkPrefetchStrategy | ReadonlyArray - >, - default: false, + type: [Boolean, String, Array] as PropType, + default: undefined, }, cacheFor: { type: [Number, String, Array] as PropType, @@ -88,7 +87,9 @@ const LinkComponent = defineComponent({ const router = inject('__void_router', null); const elRef = ref(null); const link = computed(() => resolveLink(props)); - const strategies = computed(() => linkPrefetchStrategies(props)); + const strategies = computed(() => + linkPrefetchStrategies(props, link.value, router?._prefetchDefaultStrategy), + ); // Reading router.url tracks the route URL, so this follows navigations. const activeAttributes = computed(() => linkActiveAttributes(router, link.value, props.activeMatch),