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
16 changes: 16 additions & 0 deletions docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,22 @@ 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.

### Pending links

While a page loads, `Link` adds `data-void-pending` to every link to it. Use it to show that a click is being handled:

```css
a[data-void-pending] {
opacity: 0.6;
/* Skip visits that finish quickly, such as to a prefetched page */
transition: opacity 0.15s 0.1s;
}
```

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.

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

### Prefetching

Add `prefetch` to a `Link` to load its page before the user clicks, so the visit doesn't wait for the server:
Expand Down
82 changes: 41 additions & 41 deletions docs/reference/api/pages.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion packages/react/src/runtime/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import type {
import {
handleLinkClick,
linkActiveAttributes,
linkPendingAttributes,
linkPrefetchStrategies,
observeLinkVisible,
prefetchLink,
resolveLink,
type BaseLinkProps,
} from 'void/pages-client';
import { RouterContext } from './context.ts';
import { NavigationContext, RouterContext } from './context.ts';

export type LinkProps = BaseLinkProps &
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof BaseLinkProps> &
Expand Down Expand Up @@ -47,6 +48,7 @@ export function Link({
...rest
}: LinkProps): JSX.Element {
const router = useContext(RouterContext);
const navigation = useContext(NavigationContext);
const elementRef = useRef<HTMLElement | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const link = useMemo(
Expand All @@ -64,6 +66,10 @@ export function Link({
() => linkActiveAttributes(router, link, activeMatch),
[activeMatch, link, router],
);
const pendingAttributes = useMemo(
() => linkPendingAttributes(router, navigation, link),
[link, navigation, router],
);
const hoverDelay = router?._hoverDelay ?? 75;

const doPrefetch = useCallback(
Expand Down Expand Up @@ -237,6 +243,7 @@ export function Link({
href={link.href}
style={resolvedStyle}
{...activeAttributes}
{...pendingAttributes}
{...rest}
{...eventHandlers}
>
Expand All @@ -250,6 +257,7 @@ export function Link({
ref={elementRef as RefObject<HTMLButtonElement>}
type="button"
style={resolvedStyle}
{...pendingAttributes}
{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}
{...eventHandlers}
>
Expand Down
48 changes: 46 additions & 2 deletions packages/react/test/unit/link.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vite-plus/test';
import { createSsrRouter } from 'void/pages-client';
import { RouterContext } from '../../src/runtime/context.ts';
import { createSsrRouter, type NavigationMethod, type NavigationState } from 'void/pages-client';
import { NavigationContext, RouterContext } from '../../src/runtime/context.ts';
import { Link, type LinkProps } from '../../src/runtime/link.tsx';

function renderAt(url: string | null, props: LinkProps): string {
Expand All @@ -15,6 +15,30 @@ function renderAt(url: string | null, props: LinkProps): string {
);
}

/** Renders a Link on `/` while a visit to `destination` loads. */
function renderVisiting(
destination: string,
props: LinkProps,
method: NavigationMethod = 'GET',
): string {
const navigation: NavigationState = {
state: method === 'GET' ? 'loading' : 'submitting',
location: new URL(destination, 'http://localhost'),
method,
};
return renderToStaticMarkup(
createElement(
RouterContext.Provider,
{ value: createSsrRouter('/') },
createElement(
NavigationContext.Provider,
{ value: navigation },
createElement(Link, props, 'Users'),
),
),
);
}

describe('Link', () => {
it('marks the link to the current page', () => {
const current = renderAt('/users', { href: '/users' });
Expand All @@ -35,6 +59,26 @@ describe('Link', () => {
expect(markup).toContain('data-void-active=""');
});

it('marks a link while a visit to its destination loads', () => {
expect(renderVisiting('/users', { href: '/users' })).toContain('data-void-pending=""');
expect(renderVisiting('/users?page=2', { href: '/users' })).not.toContain('data-void-pending');
expect(renderAt('/', { href: '/users' })).not.toContain('data-void-pending');
});

it('marks a non-GET link while its own method is submitting', () => {
const markup = renderVisiting('/logout', { href: '/logout', method: 'POST' }, 'POST');
expect(markup).toMatch(/^<button[^>]* data-void-pending=""/);
expect(renderVisiting('/logout', { href: '/logout', method: 'POST' })).not.toContain(
'data-void-pending',
);
});

it('lets a passed data-void-pending override the pending state', () => {
// As in JSX: <Link href="/users" data-void-pending={undefined}>
const props = { href: '/users' as const, 'data-void-pending': undefined };
expect(renderVisiting('/users', props)).not.toContain('data-void-pending');
});

it('preserves dot-segment relative hrefs when merging GET data', () => {
const markup = renderToStaticMarkup(
createElement(
Expand Down
8 changes: 7 additions & 1 deletion packages/solid/src/runtime/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import type { JSX } from 'solid-js';
import {
handleLinkClick,
linkActiveAttributes,
linkPendingAttributes,
linkPrefetchStrategies,
observeLinkVisible,
prefetchLink,
resolveLink,
type BaseLinkProps,
} from 'void/pages-client';
import { RouterContext } from './context.ts';
import { NavigationContext, RouterContext } from './context.ts';

type CommonProps = BaseLinkProps & {
children?: JSX.Element;
Expand Down Expand Up @@ -64,6 +65,7 @@ export function Link(props: LinkProps): JSX.Element {
'onTouchStart',
]);
const router = useContext(RouterContext);
const navigation = useContext(NavigationContext);

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

function doPrefetch() {
prefetchLink(router, link().href, local.cacheFor);
Expand Down Expand Up @@ -178,6 +182,7 @@ export function Link(props: LinkProps): JSX.Element {
}}
href={link().href}
{...activeAttributes()}
{...pendingAttributes()}
{...rest}
style={resolvedStyle()}
{...eventHandlers()}
Expand All @@ -190,6 +195,7 @@ export function Link(props: LinkProps): JSX.Element {
el = r;
}}
type="button"
{...pendingAttributes()}
{...(rest as JSX.ButtonHTMLAttributes<HTMLButtonElement>)}
style={resolvedStyle()}
{...eventHandlers()}
Expand Down
7 changes: 7 additions & 0 deletions packages/svelte/src/runtime/Link.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import {
handleLinkClick,
linkActiveAttributes,
linkPendingAttributes,
linkPrefetchStrategies,
observeLinkVisible,
prefetchLink,
resolveLink,
type NavigationState,
type VoidRouter,
} from "void/pages-client";
import type { LinkProps } from "./link-props.ts";
Expand Down Expand Up @@ -36,6 +38,7 @@
}: LinkProps = $props();

const router = getContext<VoidRouter>("__void_router");
const navigation = getContext<{ value: NavigationState } | undefined>("__void_navigation");

function doPrefetch() {
prefetchLink(router, link.href, cacheFor);
Expand Down Expand Up @@ -126,6 +129,8 @@
);
// Reading router.url tracks the route URL, so this follows navigations.
const activeAttributes = $derived(linkActiveAttributes(router, link, activeMatch));
// Reading navigation.value tracks visits as they start and end.
const pendingAttributes = $derived(linkPendingAttributes(router, navigation?.value, link));
const resolvedStyle = $derived(
style ? `touch-action: manipulation; ${style}` : "touch-action: manipulation",
);
Expand All @@ -136,6 +141,7 @@
bind:this={el}
href={link.href}
{...activeAttributes}
{...pendingAttributes}
{...rest}
style={resolvedStyle}
onclick={onClick}
Expand All @@ -153,6 +159,7 @@
<button
bind:this={el}
type="button"
{...pendingAttributes}
{...rest}
style={resolvedStyle}
onclick={onClick}
Expand Down
4 changes: 3 additions & 1 deletion packages/void/skills/void/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ the app's existing saved destination, including when the target project is new.
`void/env`; follow `docs/guide/env-vars.md` for local and production values.
- In Pages navigation, style the current link with `Link`'s `aria-current` and `data-void-active`
attributes (`activeMatch="prefix"` for sections) instead of comparing `useRouter().path`; see
`docs/guide/pages-routing/overview.md#active-links`.
`docs/guide/pages-routing/overview.md#active-links`. Show a loading state on
the clicked link with `data-void-pending` instead of matching `useNavigation().location`
yourself; see `docs/guide/pages-routing/overview.md#pending-links`.
- For platform setup, follow the pages linked from
`docs/guide/self-hosted-platform.md`. Use `void platform list` to find the
installation slug for maintenance. For recovery, use its documented resume
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/pages/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export { getValidationErrors, isModifiedEvent, mergeDataIntoHref } from './adapt
export {
handleLinkClick,
linkActiveAttributes,
linkPendingAttributes,
linkPrefetchStrategies,
observeLinkVisible,
prefetchLink,
Expand All @@ -34,6 +35,7 @@ export {
type LinkActiveAttributes,
type LinkActiveMatch,
type LinkNavigateEvent,
type LinkPendingAttributes,
type LinkPrefetchValue,
type LinkPrefetchStrategy,
type ResolvedLink,
Expand Down
71 changes: 61 additions & 10 deletions packages/void/src/pages/link.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference lib="dom" />
import type { Href } from 'void/routes';
import { isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts';
import type { NavigationMethod, VoidRouter } from './client.ts';
import type { NavigationMethod, NavigationState, VoidRouter } from './client.ts';
import type { PrefetchOptions } from './navigation-engine.ts';
import type { CacheForInput, LinkPrefetchValue, LinkPrefetchStrategy } from './prefetch.ts';
import { decodePagePath } from './url.ts';
Expand All @@ -16,6 +16,11 @@ export type LinkActiveAttributes = {
'data-void-active'?: '';
};

/** Present only while a visit to the link's destination is loading. */
export type LinkPendingAttributes = {
'data-void-pending'?: '';
};

/** Props every adapter's Link accepts. Adapters add element attributes and children. */
export type BaseLinkProps = {
href: Href;
Expand Down Expand Up @@ -132,15 +137,8 @@ export function linkActiveAttributes(
}

const current = router.url;
let target: URL;
try {
target = new URL(link.href, current);
} catch {
// A malformed href still renders; it just never matches.
return {};
}
// Protocol and host rather than `origin`, which is opaque for custom schemes such as capacitor:.
if (target.protocol !== current.protocol || target.host !== current.host) {
const target = resolveSameOrigin(link.href, current);
if (!target) {
return {};
}

Expand All @@ -160,6 +158,59 @@ export function linkActiveAttributes(
return {};
}

/**
* Marks a link while a visit to its destination is loading: the visit uses the link's
* method and goes to its path, query and hash. Unlike active links, the query counts,
* so `?page=2` isn't pending during a visit to `?page=3`. Every link to the destination
* is marked, not only the one that was clicked.
* Spread the result before the user's attributes so theirs win.
*/
export function linkPendingAttributes(
router: VoidRouter | null | undefined,
navigation: NavigationState | null | undefined,
link: ResolvedLink,
): LinkPendingAttributes {
if (
!router ||
!navigation?.location ||
navigation.state === 'idle' ||
navigation.method !== link.method
) {
return {};
}

const destination = navigation.location;
const target = resolveSameOrigin(link.href, router.url);
if (
target &&
target.host === destination.host &&
target.protocol === destination.protocol &&
decodePagePath(target.pathname) === decodePagePath(destination.pathname) &&
target.search === destination.search &&
target.hash === destination.hash
) {
return { 'data-void-pending': '' };
}

return {};
}

/** Resolves `href` against `base`, or returns `null` for a malformed or other-origin href. */
function resolveSameOrigin(href: string, base: URL): URL | null {
let target: URL;
try {
target = new URL(href, base);
} catch {
// A malformed href still renders; it just never matches.
return null;
}
// Protocol and host rather than `origin`, which is opaque for custom schemes such as capacitor:.
if (target.protocol !== base.protocol || target.host !== base.host) {
return null;
}
return target;
}

export function prefetchLink(
router: VoidRouter | null | undefined,
href: Href,
Expand Down
Loading
Loading