Skip to content

Commit 03661c0

Browse files
yangshunclaude
andauthored
feat(pages): prefetch a visible Link only after it stays in view (#701)
A `'visible'` Link now prefetches only after it has been in view for 300 ms. Scrolling it out of view cancels the wait, and coming back restarts it. 300 ms skips links scrolled past quickly in long lists, now that `'visible'` can be the app-wide default. Precedent: [Astro](https://github.com/withastro/astro/blob/main/packages/astro/src/prefetch/index.ts) debounces viewport prefetches by the same 300 ms, and [TanStack Router](https://github.com/TanStack/router/blob/main/packages/react-router/src/link.tsx) applies `preloadDelay` to viewport preloads, cancelling when the link leaves the viewport. Tests: fake-timer unit cases for the delay, cancel, restart, firing once, and cleanup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ec13839 commit 03661c0

4 files changed

Lines changed: 139 additions & 12 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ Add `prefetch` to a `Link` to load its page before the user clicks, so the visit
350350
</Link>
351351
```
352352

353-
`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.
353+
`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 once it stays in view, and `cacheFor` sets how long a prefetched page is reused. See [Prefetching](../../reference/api/pages.md#prefetching) for every option.
354354

355355
To give every `Link` a prefetch strategy, set `prefetch.defaultStrategy` in the adapter plugin. A `Link` that sets its own `prefetch` keeps it:
356356

@@ -359,7 +359,7 @@ To give every `Link` a prefetch strategy, set `prefetch.defaultStrategy` in the
359359
voidReact({ prefetch: { defaultStrategy: 'hover' } });
360360
```
361361

362-
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.
362+
Prefer `'hover'` or `'click'` as the default. With `'mount'` or `'visible'`, every `Link` loads its page once it renders or stays in view, which adds up in long lists.
363363

364364
Prefetching runs the destination's loader, so keep loaders free of side effects such as counting views.
365365

‎docs/reference/api/pages.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type LinkPrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible';
2929
| `'hover'` or `true` | When the pointer rests on the link for `hoverDelay`, when it gets keyboard focus, or on touch |
3030
| `'click'` | When the mouse button or finger goes down, just before the click |
3131
| `'mount'` | As soon as the link renders |
32-
| `'visible'` | The first time the link scrolls into view |
32+
| `'visible'` | Once the link has stayed in view for 300 ms. Links scrolled quickly past are skipped |
3333
| `false` | Never |
3434

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

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

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -284,18 +284,51 @@ export function handleLinkClick(
284284
});
285285
}
286286

287-
/** Calls `onVisible` once, the first time `element` enters the viewport. Returns a cleanup. */
287+
/**
288+
* How long a `'visible'` link must stay in the viewport before it prefetches. Long enough
289+
* that a link scrolled past during a quick scroll or fling is skipped, short enough that
290+
* the prefetch still lands well before the user reads the link and clicks. Astro's
291+
* viewport prefetch uses the same dwell. Separate from `prefetch.hoverDelay`, whose 75 ms
292+
* would only filter the fastest flings.
293+
*/
294+
export const LINK_VISIBLE_PREFETCH_DELAY = 300;
295+
296+
/**
297+
* Calls `onVisible` once, after `element` has stayed in the viewport for
298+
* `LINK_VISIBLE_PREFETCH_DELAY` ms. Leaving the viewport cancels the wait and entering
299+
* again restarts it. Returns a cleanup that also cancels a pending wait.
300+
*/
288301
export function observeLinkVisible(element: Element, onVisible: () => void): () => void {
302+
let timer: ReturnType<typeof setTimeout> | undefined;
303+
// Set once the callback has run or the caller cleaned up. Entries queued before
304+
// `disconnect()` can still be delivered, and must not start another wait.
305+
let done = false;
306+
const stop = () => {
307+
done = true;
308+
clearTimeout(timer);
309+
timer = undefined;
310+
observer.disconnect();
311+
};
312+
289313
const observer = new IntersectionObserver(
290314
(entries) => {
291-
if (entries.some((entry) => entry.isIntersecting)) {
292-
onVisible();
293-
observer.disconnect();
315+
if (done) {
316+
return;
294317
}
318+
// Entries are in time order, so the last one is the element's current state.
319+
if (!entries.at(-1)?.isIntersecting) {
320+
clearTimeout(timer);
321+
timer = undefined;
322+
return;
323+
}
324+
timer ??= setTimeout(() => {
325+
stop();
326+
onVisible();
327+
}, LINK_VISIBLE_PREFETCH_DELAY);
295328
},
296329
{ rootMargin: '0px' },
297330
);
298331
observer.observe(element);
299332

300-
return () => observer.disconnect();
333+
return stop;
301334
}

‎packages/void/test/unit/pages-link.test.ts‎

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '../../src/pages/client.ts';
99
import {
1010
handleLinkClick,
11+
LINK_VISIBLE_PREFETCH_DELAY,
1112
linkActiveAttributes,
1213
linkPendingAttributes,
1314
linkPrefetchStrategies,
@@ -394,7 +395,7 @@ describe('handleLinkClick', () => {
394395
});
395396

396397
describe('observeLinkVisible', () => {
397-
it('calls back once when the element becomes visible', () => {
398+
function observeStub() {
398399
let notify: (entries: Array<{ isIntersecting: boolean }>) => void = () => {};
399400
const disconnect = vi.fn();
400401
const observe = vi.fn();
@@ -411,15 +412,108 @@ describe('observeLinkVisible', () => {
411412
const element = {} as Element;
412413
const onVisible = vi.fn();
413414
const stop = observeLinkVisible(element, onVisible);
415+
const show = (isIntersecting: boolean) => notify([{ isIntersecting }]);
416+
return {
417+
element,
418+
observe,
419+
disconnect,
420+
onVisible,
421+
stop,
422+
show,
423+
notify: (...entries: Array<boolean>) =>
424+
notify(entries.map((isIntersecting) => ({ isIntersecting }))),
425+
};
426+
}
414427

415-
notify([{ isIntersecting: false }]);
416-
expect(onVisible).not.toHaveBeenCalled();
417-
notify([{ isIntersecting: true }]);
428+
beforeEach(() => {
429+
vi.useFakeTimers();
430+
});
431+
432+
afterEach(() => {
433+
vi.useRealTimers();
434+
});
435+
436+
it('calls back once the element has stayed visible for the delay', () => {
437+
const { element, observe, disconnect, onVisible, stop, show } = observeStub();
418438

419439
expect(observe).toHaveBeenCalledWith(element);
440+
show(false);
441+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY);
442+
expect(onVisible).not.toHaveBeenCalled();
443+
444+
show(true);
445+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY - 1);
446+
expect(onVisible).not.toHaveBeenCalled();
447+
vi.advanceTimersByTime(1);
448+
420449
expect(onVisible).toHaveBeenCalledOnce();
421450
expect(disconnect).toHaveBeenCalledOnce();
422451
stop();
423452
expect(disconnect).toHaveBeenCalledTimes(2);
424453
});
454+
455+
it('skips an element that leaves before the delay', () => {
456+
const { onVisible, disconnect, show } = observeStub();
457+
458+
show(true);
459+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY - 1);
460+
show(false);
461+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY * 2);
462+
463+
expect(onVisible).not.toHaveBeenCalled();
464+
expect(disconnect).not.toHaveBeenCalled();
465+
});
466+
467+
it('restarts the delay when the element becomes visible again', () => {
468+
const { onVisible, show } = observeStub();
469+
470+
show(true);
471+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY - 1);
472+
show(false);
473+
show(true);
474+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY - 1);
475+
expect(onVisible).not.toHaveBeenCalled();
476+
vi.advanceTimersByTime(1);
477+
478+
expect(onVisible).toHaveBeenCalledOnce();
479+
});
480+
481+
it('uses the latest entry when one callback reports several changes', () => {
482+
const { onVisible, notify } = observeStub();
483+
484+
notify(false, true, false);
485+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY);
486+
expect(onVisible).not.toHaveBeenCalled();
487+
488+
notify(true, false, true);
489+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY);
490+
expect(onVisible).toHaveBeenCalledOnce();
491+
});
492+
493+
it('calls back only once', () => {
494+
const { onVisible, show } = observeStub();
495+
496+
show(true);
497+
show(true);
498+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY);
499+
show(false);
500+
show(true);
501+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY * 2);
502+
503+
expect(onVisible).toHaveBeenCalledOnce();
504+
});
505+
506+
it('cancels a pending callback on cleanup', () => {
507+
const { onVisible, disconnect, stop, show } = observeStub();
508+
509+
show(true);
510+
stop();
511+
expect(vi.getTimerCount()).toBe(0);
512+
// An entry queued before the cleanup can still arrive.
513+
show(true);
514+
vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY);
515+
516+
expect(onVisible).not.toHaveBeenCalled();
517+
expect(disconnect).toHaveBeenCalledOnce();
518+
});
425519
});

0 commit comments

Comments
 (0)