Skip to content

Commit f10f68a

Browse files
yangshunclaude
andauthored
feat(pages): add an app-wide default prefetch strategy for Link (#694)
## What this adds Until now, a `Link` prefetched only when it set `prefetch` itself, so an app that wanted hover prefetching everywhere had to add the prop to every `Link`. Adapter plugins now take `prefetch.defaultStrategy`, next to the existing `hoverDelay` and `cacheFor`: ```ts voidReact({ prefetch: { defaultStrategy: 'hover' } }); ``` - **Same values as the prop:** `true`, `false`, `'hover' | 'click' | 'mount' | 'visible'`, or an array of them. The default is `false`, so existing apps don't change, and `void init` doesn't turn it on. **I propose changing the default for new apps. See below**. - **A Link's own `prefetch` replaces the default.** Values aren't merged, so a Link can drop a strategy the default includes. `prefetch={false}` opts that one Link out, and `true` still means `'hover'`. - **Only GET links use the default.** Links with a non-GET `method` or `reloadDocument` skip it rather than throwing. An explicit `prefetch` on a POST Link still throws, as before. - **Invalid values fail when the plugin is configured**, with an error that names `prefetch.defaultStrategy` and lists the accepted values. ## Behavior change: no prefetch for other sites `prefetchLink` now skips URLs on another origin. The router matches only the path, so a prefetching Link to `https://example.com/users` would have loaded this app's `/users` page. This already affected explicit `prefetch`. With an app-wide default it would have affected most external links. ## Open questions 1. `prefetch: { defaultStrategy: true }` in the config can look a bit weird, but it's aligned with the `Link` instance `prefetch` values. Lemme know if I should remove `true` as an accepted value for the config. 1. Should we make `prefetch.defaultStrategy: 'hover'` for new apps? I personally think this is the most balanced default. What other frameworks do: - Astro sets to hover if you turn prefetching on - Next.js defaulted to visible for production and it gives them good business. I didn't like it and there was no global-way to set it. ## Testing - Unit tests: - `linkPrefetchStrategies`: the default, replacement by the prop, opt-out, and non-GET links - `prefetchLink`: skipping other origins and non-HTTP schemes - `resolvePrefetchConfig`: defaults and validation - the router and its facade exposing the default - The adapter contract test checks that all four generated client entries pass the default. - A new happy-dom React test mounts a real `Link` under a router default. Vue, Svelte and Solid have no DOM test setup, so their Links are covered by typecheck, the shared unit tests and the generated-entry test. - `vp run check` passes, and `vp run test` passes for all 536 affected test files. - In the full `vp test run`, three files timed out under load: `tanstack-start` dev (the server needed 19 s against a 20 s limit), `plugin-cloudflare-v2` and `worker-build-node-require` (5 s each). None touch Link or prefetch code, and all three pass when rerun on their own. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5900120 commit f10f68a

21 files changed

Lines changed: 368 additions & 38 deletions

File tree

‎docs/guide/pages-routing/overview.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,29 @@ A link is active only on its own path by default. Set `activeMatch="prefix"` to
324324

325325
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.
326326

327+
### Prefetching
328+
329+
Add `prefetch` to a `Link` to load its page before the user clicks, so the visit doesn't wait for the server:
330+
331+
```tsx
332+
<Link href="/dashboard" prefetch>
333+
Dashboard
334+
</Link>
335+
```
336+
337+
`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.
338+
339+
To give every `Link` a prefetch strategy, set `prefetch.defaultStrategy` in the adapter plugin. A `Link` that sets its own `prefetch` keeps it:
340+
341+
```ts
342+
// vite.config.ts
343+
voidReact({ prefetch: { defaultStrategy: 'hover' } });
344+
```
345+
346+
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.
347+
348+
Prefetching runs the destination's loader, so keep loaders free of side effects such as counting views.
349+
327350
## Scroll Restoration
328351

329352
Void manages scroll position for Pages automatically:

‎docs/reference/api/pages.md‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,34 @@ With [`routing.typedRoutes`](../config.md#routing-typedroutes), `href` and route
1616

1717
See [Typed navigation](../../guide/pages-routing/overview.md#typed-navigation) for framework examples.
1818

19+
## Prefetching {#prefetching}
20+
21+
`Link`'s `prefetch` prop takes `true`, `false`, a strategy, or an array of strategies:
22+
23+
```ts
24+
type LinkPrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible';
25+
```
26+
27+
| Value | Loads the page |
28+
| ------------------- | --------------------------------------------------------------------------------------------- |
29+
| `'hover'` or `true` | When the pointer rests on the link for `hoverDelay`, when it gets keyboard focus, or on touch |
30+
| `'click'` | When the mouse button or finger goes down, just before the click |
31+
| `'mount'` | As soon as the link renders |
32+
| `'visible'` | The first time the link scrolls into view |
33+
| `false` | Never |
34+
35+
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.
36+
37+
`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.
38+
39+
Each adapter plugin's `prefetch` option sets app-wide values:
40+
41+
| Option | Default | Description |
42+
| ----------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43+
| `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. |
44+
| `hoverDelay` | `75` | Milliseconds the pointer rests on a link before `'hover'` prefetches. |
45+
| `cacheFor` | `'30s'` | The `cacheFor` value for every `Link` that doesn't set its own. |
46+
1947
## `@void/vue` {#void-vue}
2048

2149
### `voidVue(options?)` {#voidvue-options}
@@ -43,8 +71,9 @@ interface VoidVueOptions {
4371
vue?: VuePluginOptions; // passed through to @vitejs/plugin-vue
4472
viewTransitions?: boolean; // enable View Transitions API for navigations
4573
prefetch?: {
46-
hoverDelay?: number;
47-
cacheFor?: number | string | [string, string];
74+
defaultStrategy?: boolean | LinkPrefetchStrategy | LinkPrefetchStrategy[]; // default: false
75+
hoverDelay?: number; // default: 75 (ms)
76+
cacheFor?: number | string | [string, string]; // default: '30s'
4877
};
4978
}
5079
```
@@ -95,8 +124,9 @@ interface VoidReactOptions {
95124
react?: ReactPluginOptions; // passed through to @vitejs/plugin-react
96125
viewTransitions?: boolean; // enable View Transitions API for navigations
97126
prefetch?: {
98-
hoverDelay?: number;
99-
cacheFor?: number | string | [string, string];
127+
defaultStrategy?: boolean | LinkPrefetchStrategy | LinkPrefetchStrategy[]; // default: false
128+
hoverDelay?: number; // default: 75 (ms)
129+
cacheFor?: number | string | [string, string]; // default: '30s'
100130
};
101131
}
102132
```
@@ -174,8 +204,9 @@ interface VoidSvelteOptions {
174204
svelte?: SveltePluginOptions; // passed through to @sveltejs/vite-plugin-svelte
175205
viewTransitions?: boolean; // enable View Transitions API
176206
prefetch?: {
177-
hoverDelay?: number;
178-
cacheFor?: number | string | [string, string];
207+
defaultStrategy?: boolean | LinkPrefetchStrategy | LinkPrefetchStrategy[]; // default: false
208+
hoverDelay?: number; // default: 75 (ms)
209+
cacheFor?: number | string | [string, string]; // default: '30s'
179210
};
180211
}
181212
```
@@ -224,8 +255,9 @@ interface VoidSolidOptions {
224255
solid?: SolidPluginOptions; // passed through to vite-plugin-solid
225256
viewTransitions?: boolean; // enable View Transitions API
226257
prefetch?: {
227-
hoverDelay?: number;
228-
cacheFor?: number | string | [string, string];
258+
defaultStrategy?: boolean | LinkPrefetchStrategy | LinkPrefetchStrategy[]; // default: false
259+
hoverDelay?: number; // default: 75 (ms)
260+
cacheFor?: number | string | [string, string]; // default: '30s'
229261
};
230262
}
231263
```

‎packages/react/src/plugin-pages.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ function generateClientEntry(
191191
prefetch?: PrefetchConfig,
192192
staticPageData?: StaticPageDataBuildOptions,
193193
): string {
194-
const { hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
194+
const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
195195
return `
196196
import { startReactPages } from "@void/react/pages-client";
197197
import { parseCacheFor } from "void/pages-prefetch";
@@ -208,6 +208,7 @@ function generateClientEntry(
208208
viewTransitions: ${!!viewTransitions},
209209
prefetchHoverDelay: ${hoverDelay},
210210
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}),
211+
prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)},
211212
});
212213
export { router };
213214
`;

‎packages/react/src/runtime/link.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function Link({
3232
replace = false,
3333
reloadDocument = false,
3434
viewTransition,
35-
prefetch = false,
35+
prefetch,
3636
cacheFor,
3737
activeMatch,
3838
onNavigate,
@@ -53,9 +53,11 @@ export function Link({
5353
() => resolveLink({ href, method, data, prefetch, reloadDocument }),
5454
[data, href, method, prefetch, reloadDocument],
5555
);
56+
const { isGet } = link;
57+
const defaultStrategy = router?._prefetchDefaultStrategy;
5658
const strategies = useMemo(
57-
() => linkPrefetchStrategies({ prefetch, reloadDocument }),
58-
[prefetch, reloadDocument],
59+
() => linkPrefetchStrategies({ prefetch, reloadDocument }, { isGet }, defaultStrategy),
60+
[defaultStrategy, isGet, prefetch, reloadDocument],
5961
);
6062
// The router context changes with the route URL, so this follows navigations.
6163
const activeAttributes = useMemo(

‎packages/react/src/runtime/pages-client.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
type VisitOptions,
2828
type VoidRouter,
2929
} from 'void/pages-client';
30-
import type { CacheForResult } from 'void/pages-prefetch';
30+
import type { CacheForResult, LinkPrefetchValue } from 'void/pages-prefetch';
3131
import type { PageObject } from 'void/pages-protocol';
3232
import type { RouteUrlInput } from 'void/routes';
3333
import { setActionRouter } from './action.ts';
@@ -57,6 +57,7 @@ export type ReactPagesConfig = Readonly<{
5757
viewTransitions: boolean;
5858
prefetchHoverDelay: number;
5959
prefetchDefaultCacheFor: CacheForResult;
60+
prefetchDefaultStrategy?: LinkPrefetchValue;
6061
cacheVisitedPageData?: boolean;
6162
/** Show this fallback for client visits whose destination remains pending at the next paint. */
6263
navigationFallback?: ComponentType<{ initialPageData: PageObject }>;
@@ -282,6 +283,7 @@ function appReducer(state: AppState, action: AppAction): AppState {
282283
function createRouter(engine: Awaited<ReturnType<typeof createPageNavigationEngine>>): VoidRouter {
283284
const router: VoidRouter = {
284285
_hoverDelay: engine._hoverDelay,
286+
_prefetchDefaultStrategy: engine._prefetchDefaultStrategy,
285287
get url() {
286288
return new URL(currentUrl(), window.location.origin);
287289
},
@@ -746,6 +748,7 @@ export async function startReactPages(config: ReactPagesConfig): Promise<ReactPa
746748
viewTransitions: config.viewTransitions,
747749
prefetchHoverDelay: config.prefetchHoverDelay,
748750
prefetchDefaultCacheFor: config.prefetchDefaultCacheFor,
751+
prefetchDefaultStrategy: config.prefetchDefaultStrategy,
749752
cacheVisitedPageData: config.cacheVisitedPageData,
750753
});
751754
const router = createRouter(engine);
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// @vitest-environment happy-dom
2+
3+
import { act, createElement } from 'react';
4+
import { createRoot, type Root } from 'react-dom/client';
5+
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
6+
import type { LinkPrefetchValue, VoidRouter } from 'void/pages-client';
7+
import { RouterContext } from '../../src/runtime/context.ts';
8+
import { Link, type LinkProps } from '../../src/runtime/link.tsx';
9+
10+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11+
12+
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
13+
14+
afterEach(() => {
15+
for (const { root, container } of mountedRoots.splice(0)) {
16+
act(() => root.unmount());
17+
container.remove();
18+
}
19+
});
20+
21+
function createRouter(defaultStrategy?: LinkPrefetchValue) {
22+
const prefetch = vi.fn(() => Promise.resolve());
23+
const router: VoidRouter = {
24+
_hoverDelay: 75,
25+
_prefetchDefaultStrategy: defaultStrategy,
26+
get url() {
27+
return new URL(window.location.href);
28+
},
29+
get path() {
30+
return window.location.pathname;
31+
},
32+
get query() {
33+
return new URLSearchParams(window.location.search);
34+
},
35+
get params() {
36+
return {};
37+
},
38+
visit: vi.fn(),
39+
refresh: vi.fn(),
40+
prefetch,
41+
flush: vi.fn(),
42+
flushAll: vi.fn(),
43+
};
44+
return { router, prefetch };
45+
}
46+
47+
async function mountLink(router: VoidRouter, props: LinkProps) {
48+
const container = document.createElement('div');
49+
document.body.append(container);
50+
const root = createRoot(container);
51+
mountedRoots.push({ root, container });
52+
await act(async () => {
53+
root.render(
54+
createElement(RouterContext.Provider, { value: router }, createElement(Link, props, 'Go')),
55+
);
56+
});
57+
return container.firstElementChild as HTMLElement;
58+
}
59+
60+
describe('Link default prefetch strategy', () => {
61+
it('prefetches a Link without a prefetch prop with the app default', async () => {
62+
const { router, prefetch } = createRouter('mount');
63+
await mountLink(router, { href: '/users' });
64+
65+
expect(prefetch).toHaveBeenCalledWith('/users', {});
66+
});
67+
68+
it('lets the Link prop replace the default', async () => {
69+
const { router, prefetch } = createRouter('mount');
70+
const link = await mountLink(router, { href: '/users', prefetch: 'hover' });
71+
expect(prefetch).not.toHaveBeenCalled();
72+
73+
await act(async () => {
74+
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
75+
});
76+
expect(prefetch).toHaveBeenCalledWith('/users', {});
77+
});
78+
79+
it('turns the default off with prefetch={false}', async () => {
80+
const { router, prefetch } = createRouter('mount');
81+
await mountLink(router, { href: '/users', prefetch: false });
82+
83+
expect(prefetch).not.toHaveBeenCalled();
84+
});
85+
86+
it('skips non-GET links without throwing', async () => {
87+
const { router, prefetch } = createRouter('mount');
88+
const button = await mountLink(router, { href: '/users', method: 'POST' });
89+
90+
expect(button.tagName).toBe('BUTTON');
91+
expect(prefetch).not.toHaveBeenCalled();
92+
});
93+
94+
it('skips links to other sites', async () => {
95+
const { router, prefetch } = createRouter('mount');
96+
await mountLink(router, { href: 'https://example.com/users' });
97+
98+
expect(prefetch).not.toHaveBeenCalled();
99+
});
100+
101+
it('does not prefetch without an app default', async () => {
102+
const { router, prefetch } = createRouter();
103+
await mountLink(router, { href: '/users' });
104+
105+
expect(prefetch).not.toHaveBeenCalled();
106+
});
107+
});

‎packages/solid/src/plugin-pages.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ function generateClientEntry(
137137
prefetch?: PrefetchConfig,
138138
staticPageData?: StaticPageDataBuildOptions,
139139
): string {
140-
const { hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
140+
const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
141141
return `
142142
import { createSignal, createComponent, createMemo, mergeProps, untrack, batch } from "solid-js";
143143
import { createMutable } from "solid-js/store";
@@ -201,6 +201,7 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit
201201
viewTransitions: ${!!viewTransitions},
202202
prefetchHoverDelay: ${hoverDelay},
203203
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}),
204+
prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)},
204205
onRouteChange(url) {
205206
setRouteUrl(url);
206207
},

‎packages/solid/src/runtime/link.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ export function Link(props: LinkProps): JSX.Element {
6666
const router = useContext(RouterContext);
6767

6868
const link = createMemo(() => resolveLink(local));
69-
const strategies = createMemo(() => linkPrefetchStrategies(local));
69+
const strategies = createMemo(() =>
70+
linkPrefetchStrategies(local, link(), router?._prefetchDefaultStrategy),
71+
);
7072
const resolvedStyle = createMemo(() => mergeStyle(local.style));
7173
// Reading router.url tracks the route URL, so this follows navigations.
7274
const activeAttributes = createMemo(() =>

‎packages/svelte/src/plugin-pages.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ function generateClientEntry(
143143
prefetch?: PrefetchConfig,
144144
staticPageData?: StaticPageDataBuildOptions,
145145
): string {
146-
const { hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
146+
const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
147147
return `
148148
import { hydrate, mount, tick } from "svelte";
149149
import { writable } from "svelte/store";
@@ -189,6 +189,7 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit
189189
viewTransitions: ${!!viewTransitions},
190190
prefetchHoverDelay: ${hoverDelay},
191191
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}),
192+
prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)},
192193
onRouteChange(url) {
193194
routeUrl = url;
194195
routeStore.set(url);

‎packages/svelte/src/runtime/Link.svelte‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
replace = false,
2121
reloadDocument = false,
2222
viewTransition = undefined,
23-
prefetch: prefetchProp = false,
23+
prefetch: prefetchProp = undefined,
2424
cacheFor = undefined,
2525
activeMatch = undefined,
2626
onNavigate = undefined,
@@ -117,7 +117,13 @@
117117
const link = $derived(
118118
resolveLink({ href, method, data, prefetch: prefetchProp, reloadDocument }),
119119
);
120-
const strategies = $derived(linkPrefetchStrategies({ prefetch: prefetchProp, reloadDocument }));
120+
const strategies = $derived(
121+
linkPrefetchStrategies(
122+
{ prefetch: prefetchProp, reloadDocument },
123+
link,
124+
router?._prefetchDefaultStrategy,
125+
),
126+
);
121127
// Reading router.url tracks the route URL, so this follows navigations.
122128
const activeAttributes = $derived(linkActiveAttributes(router, link, activeMatch));
123129
const resolvedStyle = $derived(

0 commit comments

Comments
 (0)