Skip to content

Commit c42173e

Browse files
yangshunclaude
andauthored
feat(pages): mark Links to the page that is loading (#698)
`Link` now adds `data-void-pending` while a visit to its destination loads, so a slow navigation shows feedback on the link itself. - A link is pending when the visit's path, query, hash and method all match its own. Unlike `data-void-active`, the query counts. - Every link to the destination is marked, not just the clicked one. - Non-GET Links, which render a `<button>`, are pending while their own request runs. **Worth knowing** - `router.refresh()` marks links to the current page while it runs. - A prefetched page can show the attribute for a moment. The guide's CSS example adds a transition delay to hide this. - React `Link`s now re-render when a visit starts and ends. **Tests:** the browser contract passes for all four adapters in dev and preview, along with `vp run check` and the full `vp run test`. The one failure in the full run, a WebSocket playground timeout under load, passes when run alone. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7c33d23 commit c42173e

20 files changed

Lines changed: 364 additions & 58 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,22 @@ 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+
### Pending links
328+
329+
While a page loads, `Link` adds `data-void-pending` to every link to it. Use it to show that a click is being handled:
330+
331+
```css
332+
a[data-void-pending] {
333+
opacity: 0.6;
334+
/* Skip visits that finish quickly, such as to a prefetched page */
335+
transition: opacity 0.15s 0.1s;
336+
}
337+
```
338+
339+
A link is pending only while its exact destination loads. The path, query and hash must all match, so a link to `/users?page=2` isn't pending while `/users?page=3` loads. A non-GET link, which renders a `<button>`, is pending while a request with its method to its `href` runs. `router.refresh()` reloads the current page, so links to it are pending during a refresh.
340+
341+
A `data-void-pending` you pass to `Link` replaces the computed value. For a loading indicator that covers the whole page, use [`useNavigation()`](../../reference/api/pages.md).
342+
327343
### Prefetching
328344

329345
Add `prefetch` to a `Link` to load its page before the user clicks, so the visit doesn't wait for the server:

‎docs/reference/api/pages.md‎

Lines changed: 41 additions & 41 deletions
Large diffs are not rendered by default.

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ import type {
1111
import {
1212
handleLinkClick,
1313
linkActiveAttributes,
14+
linkPendingAttributes,
1415
linkPrefetchStrategies,
1516
observeLinkVisible,
1617
prefetchLink,
1718
resolveLink,
1819
type BaseLinkProps,
1920
} from 'void/pages-client';
20-
import { RouterContext } from './context.ts';
21+
import { NavigationContext, RouterContext } from './context.ts';
2122

2223
export type LinkProps = BaseLinkProps &
2324
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof BaseLinkProps> &
@@ -47,6 +48,7 @@ export function Link({
4748
...rest
4849
}: LinkProps): JSX.Element {
4950
const router = useContext(RouterContext);
51+
const navigation = useContext(NavigationContext);
5052
const elementRef = useRef<HTMLElement | null>(null);
5153
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
5254
const link = useMemo(
@@ -64,6 +66,10 @@ export function Link({
6466
() => linkActiveAttributes(router, link, activeMatch),
6567
[activeMatch, link, router],
6668
);
69+
const pendingAttributes = useMemo(
70+
() => linkPendingAttributes(router, navigation, link),
71+
[link, navigation, router],
72+
);
6773
const hoverDelay = router?._hoverDelay ?? 75;
6874

6975
const doPrefetch = useCallback(
@@ -237,6 +243,7 @@ export function Link({
237243
href={link.href}
238244
style={resolvedStyle}
239245
{...activeAttributes}
246+
{...pendingAttributes}
240247
{...rest}
241248
{...eventHandlers}
242249
>
@@ -250,6 +257,7 @@ export function Link({
250257
ref={elementRef as RefObject<HTMLButtonElement>}
251258
type="button"
252259
style={resolvedStyle}
260+
{...pendingAttributes}
253261
{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}
254262
{...eventHandlers}
255263
>

‎packages/react/test/unit/link.test.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { createElement } from 'react';
22
import { renderToStaticMarkup } from 'react-dom/server';
33
import { describe, expect, it } from 'vite-plus/test';
4-
import { createSsrRouter } from 'void/pages-client';
5-
import { RouterContext } from '../../src/runtime/context.ts';
4+
import { createSsrRouter, type NavigationMethod, type NavigationState } from 'void/pages-client';
5+
import { NavigationContext, RouterContext } from '../../src/runtime/context.ts';
66
import { Link, type LinkProps } from '../../src/runtime/link.tsx';
77

88
function renderAt(url: string | null, props: LinkProps): string {
@@ -15,6 +15,30 @@ function renderAt(url: string | null, props: LinkProps): string {
1515
);
1616
}
1717

18+
/** Renders a Link on `/` while a visit to `destination` loads. */
19+
function renderVisiting(
20+
destination: string,
21+
props: LinkProps,
22+
method: NavigationMethod = 'GET',
23+
): string {
24+
const navigation: NavigationState = {
25+
state: method === 'GET' ? 'loading' : 'submitting',
26+
location: new URL(destination, 'http://localhost'),
27+
method,
28+
};
29+
return renderToStaticMarkup(
30+
createElement(
31+
RouterContext.Provider,
32+
{ value: createSsrRouter('/') },
33+
createElement(
34+
NavigationContext.Provider,
35+
{ value: navigation },
36+
createElement(Link, props, 'Users'),
37+
),
38+
),
39+
);
40+
}
41+
1842
describe('Link', () => {
1943
it('marks the link to the current page', () => {
2044
const current = renderAt('/users', { href: '/users' });
@@ -35,6 +59,26 @@ describe('Link', () => {
3559
expect(markup).toContain('data-void-active=""');
3660
});
3761

62+
it('marks a link while a visit to its destination loads', () => {
63+
expect(renderVisiting('/users', { href: '/users' })).toContain('data-void-pending=""');
64+
expect(renderVisiting('/users?page=2', { href: '/users' })).not.toContain('data-void-pending');
65+
expect(renderAt('/', { href: '/users' })).not.toContain('data-void-pending');
66+
});
67+
68+
it('marks a non-GET link while its own method is submitting', () => {
69+
const markup = renderVisiting('/logout', { href: '/logout', method: 'POST' }, 'POST');
70+
expect(markup).toMatch(/^<button[^>]* data-void-pending=""/);
71+
expect(renderVisiting('/logout', { href: '/logout', method: 'POST' })).not.toContain(
72+
'data-void-pending',
73+
);
74+
});
75+
76+
it('lets a passed data-void-pending override the pending state', () => {
77+
// As in JSX: <Link href="/users" data-void-pending={undefined}>
78+
const props = { href: '/users' as const, 'data-void-pending': undefined };
79+
expect(renderVisiting('/users', props)).not.toContain('data-void-pending');
80+
});
81+
3882
it('preserves dot-segment relative hrefs when merging GET data', () => {
3983
const markup = renderToStaticMarkup(
4084
createElement(

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import type { JSX } from 'solid-js';
33
import {
44
handleLinkClick,
55
linkActiveAttributes,
6+
linkPendingAttributes,
67
linkPrefetchStrategies,
78
observeLinkVisible,
89
prefetchLink,
910
resolveLink,
1011
type BaseLinkProps,
1112
} from 'void/pages-client';
12-
import { RouterContext } from './context.ts';
13+
import { NavigationContext, RouterContext } from './context.ts';
1314

1415
type CommonProps = BaseLinkProps & {
1516
children?: JSX.Element;
@@ -64,6 +65,7 @@ export function Link(props: LinkProps): JSX.Element {
6465
'onTouchStart',
6566
]);
6667
const router = useContext(RouterContext);
68+
const navigation = useContext(NavigationContext);
6769

6870
const link = createMemo(() => resolveLink(local));
6971
const strategies = createMemo(() =>
@@ -74,6 +76,8 @@ export function Link(props: LinkProps): JSX.Element {
7476
const activeAttributes = createMemo(() =>
7577
linkActiveAttributes(router, link(), local.activeMatch),
7678
);
79+
// Calling navigation() tracks visits as they start and end.
80+
const pendingAttributes = createMemo(() => linkPendingAttributes(router, navigation?.(), link()));
7781

7882
function doPrefetch() {
7983
prefetchLink(router, link().href, local.cacheFor);
@@ -178,6 +182,7 @@ export function Link(props: LinkProps): JSX.Element {
178182
}}
179183
href={link().href}
180184
{...activeAttributes()}
185+
{...pendingAttributes()}
181186
{...rest}
182187
style={resolvedStyle()}
183188
{...eventHandlers()}
@@ -190,6 +195,7 @@ export function Link(props: LinkProps): JSX.Element {
190195
el = r;
191196
}}
192197
type="button"
198+
{...pendingAttributes()}
193199
{...(rest as JSX.ButtonHTMLAttributes<HTMLButtonElement>)}
194200
style={resolvedStyle()}
195201
{...eventHandlers()}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import {
44
handleLinkClick,
55
linkActiveAttributes,
6+
linkPendingAttributes,
67
linkPrefetchStrategies,
78
observeLinkVisible,
89
prefetchLink,
910
resolveLink,
11+
type NavigationState,
1012
type VoidRouter,
1113
} from "void/pages-client";
1214
import type { LinkProps } from "./link-props.ts";
@@ -36,6 +38,7 @@
3638
}: LinkProps = $props();
3739
3840
const router = getContext<VoidRouter>("__void_router");
41+
const navigation = getContext<{ value: NavigationState } | undefined>("__void_navigation");
3942
4043
function doPrefetch() {
4144
prefetchLink(router, link.href, cacheFor);
@@ -126,6 +129,8 @@
126129
);
127130
// Reading router.url tracks the route URL, so this follows navigations.
128131
const activeAttributes = $derived(linkActiveAttributes(router, link, activeMatch));
132+
// Reading navigation.value tracks visits as they start and end.
133+
const pendingAttributes = $derived(linkPendingAttributes(router, navigation?.value, link));
129134
const resolvedStyle = $derived(
130135
style ? `touch-action: manipulation; ${style}` : "touch-action: manipulation",
131136
);
@@ -136,6 +141,7 @@
136141
bind:this={el}
137142
href={link.href}
138143
{...activeAttributes}
144+
{...pendingAttributes}
139145
{...rest}
140146
style={resolvedStyle}
141147
onclick={onClick}
@@ -153,6 +159,7 @@
153159
<button
154160
bind:this={el}
155161
type="button"
162+
{...pendingAttributes}
156163
{...rest}
157164
style={resolvedStyle}
158165
onclick={onClick}

‎packages/void/skills/void/SKILL.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ the app's existing saved destination, including when the target project is new.
8686
`void/env`; follow `docs/guide/env-vars.md` for local and production values.
8787
- In Pages navigation, style the current link with `Link`'s `aria-current` and `data-void-active`
8888
attributes (`activeMatch="prefix"` for sections) instead of comparing `useRouter().path`; see
89-
`docs/guide/pages-routing/overview.md#active-links`.
89+
`docs/guide/pages-routing/overview.md#active-links`. Show a loading state on
90+
the clicked link with `data-void-pending` instead of matching `useNavigation().location`
91+
yourself; see `docs/guide/pages-routing/overview.md#pending-links`.
9092
- For platform setup, follow the pages linked from
9193
`docs/guide/self-hosted-platform.md`. Use `void platform list` to find the
9294
installation slug for maintenance. For recovery, use its documented resume

‎packages/void/src/pages/client.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export { getValidationErrors, isModifiedEvent, mergeDataIntoHref } from './adapt
2626
export {
2727
handleLinkClick,
2828
linkActiveAttributes,
29+
linkPendingAttributes,
2930
linkPrefetchStrategies,
3031
observeLinkVisible,
3132
prefetchLink,
@@ -34,6 +35,7 @@ export {
3435
type LinkActiveAttributes,
3536
type LinkActiveMatch,
3637
type LinkNavigateEvent,
38+
type LinkPendingAttributes,
3739
type LinkPrefetchValue,
3840
type LinkPrefetchStrategy,
3941
type ResolvedLink,

‎packages/void/src/pages/link.ts‎

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/// <reference lib="dom" />
22
import type { Href } from 'void/routes';
33
import { isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts';
4-
import type { NavigationMethod, VoidRouter } from './client.ts';
4+
import type { NavigationMethod, NavigationState, VoidRouter } from './client.ts';
55
import type { PrefetchOptions } from './navigation-engine.ts';
66
import type { CacheForInput, LinkPrefetchValue, LinkPrefetchStrategy } from './prefetch.ts';
77
import { decodePagePath } from './url.ts';
@@ -16,6 +16,11 @@ export type LinkActiveAttributes = {
1616
'data-void-active'?: '';
1717
};
1818

19+
/** Present only while a visit to the link's destination is loading. */
20+
export type LinkPendingAttributes = {
21+
'data-void-pending'?: '';
22+
};
23+
1924
/** Props every adapter's Link accepts. Adapters add element attributes and children. */
2025
export type BaseLinkProps = {
2126
href: Href;
@@ -132,15 +137,8 @@ export function linkActiveAttributes(
132137
}
133138

134139
const current = router.url;
135-
let target: URL;
136-
try {
137-
target = new URL(link.href, current);
138-
} catch {
139-
// A malformed href still renders; it just never matches.
140-
return {};
141-
}
142-
// Protocol and host rather than `origin`, which is opaque for custom schemes such as capacitor:.
143-
if (target.protocol !== current.protocol || target.host !== current.host) {
140+
const target = resolveSameOrigin(link.href, current);
141+
if (!target) {
144142
return {};
145143
}
146144

@@ -160,6 +158,59 @@ export function linkActiveAttributes(
160158
return {};
161159
}
162160

161+
/**
162+
* Marks a link while a visit to its destination is loading: the visit uses the link's
163+
* method and goes to its path, query and hash. Unlike active links, the query counts,
164+
* so `?page=2` isn't pending during a visit to `?page=3`. Every link to the destination
165+
* is marked, not only the one that was clicked.
166+
* Spread the result before the user's attributes so theirs win.
167+
*/
168+
export function linkPendingAttributes(
169+
router: VoidRouter | null | undefined,
170+
navigation: NavigationState | null | undefined,
171+
link: ResolvedLink,
172+
): LinkPendingAttributes {
173+
if (
174+
!router ||
175+
!navigation?.location ||
176+
navigation.state === 'idle' ||
177+
navigation.method !== link.method
178+
) {
179+
return {};
180+
}
181+
182+
const destination = navigation.location;
183+
const target = resolveSameOrigin(link.href, router.url);
184+
if (
185+
target &&
186+
target.host === destination.host &&
187+
target.protocol === destination.protocol &&
188+
decodePagePath(target.pathname) === decodePagePath(destination.pathname) &&
189+
target.search === destination.search &&
190+
target.hash === destination.hash
191+
) {
192+
return { 'data-void-pending': '' };
193+
}
194+
195+
return {};
196+
}
197+
198+
/** Resolves `href` against `base`, or returns `null` for a malformed or other-origin href. */
199+
function resolveSameOrigin(href: string, base: URL): URL | null {
200+
let target: URL;
201+
try {
202+
target = new URL(href, base);
203+
} catch {
204+
// A malformed href still renders; it just never matches.
205+
return null;
206+
}
207+
// Protocol and host rather than `origin`, which is opaque for custom schemes such as capacitor:.
208+
if (target.protocol !== base.protocol || target.host !== base.host) {
209+
return null;
210+
}
211+
return target;
212+
}
213+
163214
export function prefetchLink(
164215
router: VoidRouter | null | undefined,
165216
href: Href,

0 commit comments

Comments
 (0)