Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Link href="/dashboard" prefetch>
Dashboard
</Link>
```

`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:
Expand Down
48 changes: 40 additions & 8 deletions docs/reference/api/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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'
};
}
```
Expand Down Expand Up @@ -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'
};
}
```
Expand Down Expand Up @@ -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'
};
}
```
Expand Down Expand Up @@ -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'
};
}
```
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -208,6 +208,7 @@ function generateClientEntry(
viewTransitions: ${!!viewTransitions},
prefetchHoverDelay: ${hoverDelay},
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}),
prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)},
});
export { router };
`;
Expand Down
8 changes: 5 additions & 3 deletions packages/react/src/runtime/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function Link({
replace = false,
reloadDocument = false,
viewTransition,
prefetch = false,
prefetch,
cacheFor,
activeMatch,
onNavigate,
Expand All @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/runtime/pages-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }>;
Expand Down Expand Up @@ -282,6 +283,7 @@ function appReducer(state: AppState, action: AppAction): AppState {
function createRouter(engine: Awaited<ReturnType<typeof createPageNavigationEngine>>): VoidRouter {
const router: VoidRouter = {
_hoverDelay: engine._hoverDelay,
_prefetchDefaultStrategy: engine._prefetchDefaultStrategy,
get url() {
return new URL(currentUrl(), window.location.origin);
},
Expand Down Expand Up @@ -746,6 +748,7 @@ export async function startReactPages(config: ReactPagesConfig): Promise<ReactPa
viewTransitions: config.viewTransitions,
prefetchHoverDelay: config.prefetchHoverDelay,
prefetchDefaultCacheFor: config.prefetchDefaultCacheFor,
prefetchDefaultStrategy: config.prefetchDefaultStrategy,
cacheVisitedPageData: config.cacheVisitedPageData,
});
const router = createRouter(engine);
Expand Down
107 changes: 107 additions & 0 deletions packages/react/test/unit/link-prefetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom

import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
import type { LinkPrefetchValue, VoidRouter } from 'void/pages-client';
import { RouterContext } from '../../src/runtime/context.ts';
import { Link, type LinkProps } from '../../src/runtime/link.tsx';

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];

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();
});
});
3 changes: 2 additions & 1 deletion packages/solid/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
},
Expand Down
4 changes: 3 additions & 1 deletion packages/solid/src/runtime/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions packages/svelte/src/runtime/Link.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
replace = false,
reloadDocument = false,
viewTransition = undefined,
prefetch: prefetchProp = false,
prefetch: prefetchProp = undefined,
cacheFor = undefined,
activeMatch = undefined,
onNavigate = undefined,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading