From 025935369fb65a2d9449c468e58be372f5f94404 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Wed, 4 Nov 2026 08:24:07 +0800 Subject: [PATCH] feat(pages): add an app-wide default prefetch strategy for Link Adapter plugins accept `prefetch.defaultStrategy`, which takes the same values as Link's `prefetch` prop and defaults to `false`. A Link without its own `prefetch` uses it; a Link's own value replaces it, and `prefetch={false}` opts out. The default applies only to GET links that don't use `reloadDocument`, so mutation links keep working. An invalid strategy fails when the plugin is configured. Links to other sites are no longer prefetched. The router matches only the path, so they would have prefetched a same-path page of this app. The Pages guide gains a short Prefetching section, and the Pages API reference documents every strategy, `cacheFor`, and the plugin options. Co-Authored-By: Claude Opus 5.5 --- docs/guide/pages-routing/overview.md | 23 ++++ docs/reference/api/pages.md | 48 ++++++-- packages/react/src/plugin-pages.ts | 3 +- packages/react/src/runtime/link.tsx | 8 +- packages/react/src/runtime/pages-client.ts | 5 +- .../react/test/unit/link-prefetch.test.ts | 107 ++++++++++++++++++ packages/solid/src/plugin-pages.ts | 3 +- packages/solid/src/runtime/link.tsx | 4 +- packages/svelte/src/plugin-pages.ts | 3 +- packages/svelte/src/runtime/Link.svelte | 10 +- packages/void/src/pages/client.ts | 9 ++ packages/void/src/pages/link.ts | 26 ++++- packages/void/src/pages/navigation-engine.ts | 7 +- packages/void/src/pages/plugin.ts | 17 ++- packages/void/src/pages/prefetch.ts | 34 ++++++ packages/void/test/unit/pages-client.test.ts | 14 +++ packages/void/test/unit/pages-link.test.ts | 41 ++++++- .../test/unit/pages-plugin-adapters.test.ts | 3 +- packages/void/test/unit/pages-plugin.test.ts | 25 +++- packages/vue/src/plugin-pages.ts | 3 +- packages/vue/src/runtime/link.ts | 13 ++- 21 files changed, 368 insertions(+), 38 deletions(-) create mode 100644 packages/react/test/unit/link-prefetch.test.ts diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index 08ad304eb..53ea826a8 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -265,6 +265,29 @@ export default function PostPage() { } ``` +### 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 b577e75ee..e30c01545 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 ca184ab04..84899bb58 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -190,7 +190,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"; @@ -207,6 +207,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 0e583d2c1..e0217cf3d 100644 --- a/packages/react/src/runtime/link.tsx +++ b/packages/react/src/runtime/link.tsx @@ -31,7 +31,7 @@ export function Link({ replace = false, reloadDocument = false, viewTransition, - prefetch = false, + prefetch, cacheFor, onNavigate, children, @@ -51,9 +51,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], ); const hoverDelay = router?._hoverDelay ?? 75; 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 8b423ea8e..f1a88cf3d 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 3cc53f507..65f193b1f 100644 --- a/packages/solid/src/runtime/link.tsx +++ b/packages/solid/src/runtime/link.tsx @@ -64,7 +64,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)); function doPrefetch() { diff --git a/packages/svelte/src/plugin-pages.ts b/packages/svelte/src/plugin-pages.ts index 5a7c9fd27..561624585 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 3752361ed..53e5df767 100644 --- a/packages/svelte/src/runtime/Link.svelte +++ b/packages/svelte/src/runtime/Link.svelte @@ -19,7 +19,7 @@ replace = false, reloadDocument = false, viewTransition = undefined, - prefetch: prefetchProp = false, + prefetch: prefetchProp = undefined, cacheFor = undefined, onNavigate = undefined, children, @@ -115,7 +115,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, + ), + ); const resolvedStyle = $derived( style ? `touch-action: manipulation; ${style}` : "touch-action: manipulation", ); diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index 285cf80df..3fae3c6cf 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'; @@ -30,6 +31,7 @@ export { resolveLink, type BaseLinkProps, type LinkNavigateEvent, + type LinkPrefetchValue, type LinkPrefetchStrategy, type ResolvedLink, } from './link.ts'; @@ -77,6 +79,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; @@ -169,6 +173,10 @@ export function createRouterFacade( return router._hoverDelay; }, + get _prefetchDefaultStrategy() { + return router._prefetchDefaultStrategy; + }, + get url() { return toUrl(getCurrentUrl()); }, @@ -484,6 +492,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 6f11dfe1d..01232ba9f 100644 --- a/packages/void/src/pages/link.ts +++ b/packages/void/src/pages/link.ts @@ -3,9 +3,9 @@ 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'; -export type LinkPrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; +export type { LinkPrefetchValue, LinkPrefetchStrategy }; export type LinkNavigateEvent = { preventDefault(): void }; /** Props every adapter's Link accepts. Adapters add element attributes and children. */ @@ -18,7 +18,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; onNavigate?: (event: LinkNavigateEvent) => void; }; @@ -76,11 +77,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 []; } @@ -104,6 +115,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 c7fa046cf..d0e7e53a5 100644 --- a/packages/void/test/unit/pages-link.test.ts +++ b/packages/void/test/unit/pages-link.test.ts @@ -82,6 +82,9 @@ describe('resolveLink', () => { }); describe('linkPrefetchStrategies', () => { + const get = { isGet: true }; + const post = { isGet: false }; + it.each([ [undefined, []], [false, []], @@ -89,11 +92,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([]); }); }); @@ -116,6 +144,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 ed1fbcb51..3cc945fda 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 0a04ce520..2d490c44c 100644 --- a/packages/vue/src/runtime/link.ts +++ b/packages/vue/src/runtime/link.ts @@ -6,7 +6,7 @@ import { resolveLink, type BaseLinkProps, type LinkNavigateEvent, - type LinkPrefetchStrategy, + type LinkPrefetchValue, type VoidRouter, } from 'void/pages-client'; import type { Href } from 'void/routes'; @@ -45,11 +45,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, @@ -85,7 +84,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), + ); function doPrefetch() { prefetchLink(router, link.value.href, props.cacheFor);