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),