From d44878d5dc9799bc281b34d7672a2f9da6480662 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Aug 2026 20:35:56 +0200 Subject: [PATCH 1/7] fix(router): apply preload delay to viewport links --- .changeset/tidy-links-wait.md | 7 ++ docs/router/api/router/LinkOptionsType.md | 2 +- docs/router/api/router/RouterOptionsType.md | 2 +- docs/router/guide/navigation.md | 2 +- docs/router/guide/preloading.md | 2 +- packages/react-router/src/link.tsx | 84 ++++++++++++--------- packages/react-router/tests/link.test.tsx | 84 +++++++++++++++++++++ packages/react-router/tests/utils.ts | 3 + packages/router-core/src/link.ts | 6 +- packages/router-core/src/router.ts | 3 +- packages/solid-router/src/link.tsx | 75 +++++++++--------- packages/solid-router/tests/link.test.tsx | 84 +++++++++++++++++++++ packages/solid-router/tests/utils.ts | 3 + packages/vue-router/src/link.tsx | 72 +++++++++--------- packages/vue-router/tests/link.test.tsx | 84 +++++++++++++++++++++ packages/vue-router/tests/utils.ts | 3 + 16 files changed, 398 insertions(+), 118 deletions(-) create mode 100644 .changeset/tidy-links-wait.md diff --git a/.changeset/tidy-links-wait.md b/.changeset/tidy-links-wait.md new file mode 100644 index 00000000000..485f02ae6f5 --- /dev/null +++ b/.changeset/tidy-links-wait.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Apply `preloadDelay` to viewport link preloading. diff --git a/docs/router/api/router/LinkOptionsType.md b/docs/router/api/router/LinkOptionsType.md index cf20e0ff198..c321c96b4e5 100644 --- a/docs/router/api/router/LinkOptionsType.md +++ b/docs/router/api/router/LinkOptionsType.md @@ -42,7 +42,7 @@ The `LinkOptions` object accepts/contains the following properties: - Type: `number` - Optional -- Delay focus and hover intent preloading by this many milliseconds. Touch intent preloads immediately. If focus or hover exits before the delay, the preload will be cancelled. +- Delay focus, hover, and viewport preloading by this many milliseconds. Touch intent preloads immediately. If focus or hover exits before the delay, the preload will be cancelled. ### `disabled` diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 91f13e0e503..709d08c621a 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -59,7 +59,7 @@ The `RouterOptions` type accepts an object with the following properties and met - Type: `number` - Optional - Defaults to `50` -- The delay in milliseconds that a route must be hovered over or touched before it is preloaded. +- The delay in milliseconds before intent focus/hover and viewport preloading. Touch intent preloads immediately. ### `defaultComponent` property diff --git a/docs/router/guide/navigation.md b/docs/router/guide/navigation.md index b2bb03ad0e2..0f03ec99708 100644 --- a/docs/router/guide/navigation.md +++ b/docs/router/guide/navigation.md @@ -731,7 +731,7 @@ What's even better is that by using a cache-first library like `@tanstack/query` ### Link Preloading Delay -For `'intent'` preloading, a configurable delay determines how long a link must remain focused or hovered before preloading begins. If focus or hover ends before the delay, the queued preload is cancelled. Touch intent preloads immediately without waiting for the delay. The default delay is 50 milliseconds, but you can change it by passing a `preloadDelay` prop to the `Link` component: +For `'intent'` and `'viewport'` preloading, a configurable delay determines how long to wait before preloading begins after focus, hover, or viewport entry. If focus or hover ends before the delay, the queued preload is cancelled. Touch intent preloads immediately without waiting for the delay. The default delay is 50 milliseconds, but you can change it by passing a `preloadDelay` prop to the `Link` component: ```tsx const link = ( diff --git a/docs/router/guide/preloading.md b/docs/router/guide/preloading.md index 6ae9563f2f8..4e360f6b316 100644 --- a/docs/router/guide/preloading.md +++ b/docs/router/guide/preloading.md @@ -64,7 +64,7 @@ This will turn on `intent` preloading by default for all `` components in ## Preload Delay -By default, preloading will start after **50ms** of the user hovering or touching a `` component. You can change this delay by setting the `defaultPreloadDelay` option on your router: +By default, intent focus/hover and viewport preloading start after **50ms**. Touch intent preloads immediately. You can change this delay by setting the `defaultPreloadDelay` option on your router: diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index fc33e6b5947..2514394c628 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -598,7 +598,7 @@ export function useLinkProps< const hasRenderFetched = React.useRef(false) const preload = - options.reloadDocument || externalLink + options.reloadDocument || externalLink || disabled ? false : (userPreload ?? router.options.defaultPreload) const preloadDelay = @@ -615,21 +615,53 @@ export function useLinkProps< }, [router, _options]) // eslint-disable-next-line react-hooks/rules-of-hooks - const preloadViewportIoCallback = React.useCallback( - (entry: IntersectionObserverEntry | undefined) => { - if (entry?.isIntersecting) { + const enqueuePreload = React.useCallback( + ( + e: + | React.MouseEvent + | React.FocusEvent + | IntersectionObserverEntry + | undefined, + ) => { + if ( + !e || + !( + (e as IntersectionObserverEntry).isIntersecting ?? + preload === 'intent' + ) + ) { + return + } + + if (!preloadDelay) { doPreload() + return + } + + const eventTarget = + (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload + + if (timeoutMap.has(eventTarget)) { + return } + + timeoutMap.set( + eventTarget, + setTimeout(() => { + timeoutMap.delete(eventTarget) + doPreload() + }, preloadDelay), + ) }, - [doPreload], + [doPreload, preload, preloadDelay], ) // eslint-disable-next-line react-hooks/rules-of-hooks useIntersectionObserver( innerRef, - preloadViewportIoCallback, + enqueuePreload, intersectionObserverOptions, - !!disabled || preload !== 'viewport', + preload !== 'viewport', ) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -637,11 +669,11 @@ export function useLinkProps< if (hasRenderFetched.current) { return } - if (!disabled && preload === 'render') { + if (preload === 'render') { doPreload() hasRenderFetched.current = true } - }, [disabled, doPreload, preload]) + }, [doPreload, preload]) // The click handler const handleClick = (e: React.MouseEvent) => { @@ -702,34 +734,12 @@ export function useLinkProps< } } - const enqueueIntentPreload = (e: React.MouseEvent | React.FocusEvent) => { - if (disabled || preload !== 'intent') return - - if (!preloadDelay) { - doPreload() - return - } - - const eventTarget = e.currentTarget - - if (timeoutMap.has(eventTarget)) { - return - } - - const id = setTimeout(() => { - timeoutMap.delete(eventTarget) - doPreload() - }, preloadDelay) - timeoutMap.set(eventTarget, id) - } - - const handleTouchStart = (_: React.TouchEvent) => { - if (disabled || preload !== 'intent') return + const handleTouchStart = () => { + if (preload !== 'intent') return doPreload() } const handleLeave = (e: React.MouseEvent | React.FocusEvent) => { - if (disabled || !preload || !preloadDelay) return const eventTarget = e.currentTarget const id = timeoutMap.get(eventTarget) if (id) { @@ -746,8 +756,8 @@ export function useLinkProps< ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], onClick: composeHandlers([onClick, handleClick]), onBlur: composeHandlers([onBlur, handleLeave]), - onFocus: composeHandlers([onFocus, enqueueIntentPreload]), - onMouseEnter: composeHandlers([onMouseEnter, enqueueIntentPreload]), + onFocus: composeHandlers([onFocus, enqueuePreload]), + onMouseEnter: composeHandlers([onMouseEnter, enqueuePreload]), onMouseLeave: composeHandlers([onMouseLeave, handleLeave]), onTouchStart: composeHandlers([onTouchStart, handleTouchStart]), disabled: !!disabled, @@ -766,7 +776,7 @@ const STATIC_DISABLED_PROPS = { role: 'link', 'aria-disabled': true } const STATIC_ACTIVE_PROPS = { 'data-status': 'active', 'aria-current': 'page' } const STATIC_TRANSITIONING_PROPS = { 'data-transitioning': 'transitioning' } -const timeoutMap = new WeakMap>() +const timeoutMap = new WeakMap>() const intersectionObserverOptions: IntersectionObserverInit = { rootMargin: '100px', @@ -955,7 +965,7 @@ export function createLink( * * Props: * - `preload`: Controls route preloading (eg. 'intent', 'render', 'viewport', true/false) - * - `preloadDelay`: Delay in ms before preloading on hover + * - `preloadDelay`: Delay in ms before preloading on focus, hover, or viewport entry * - `activeProps`/`inactiveProps`: Additional props merged when link is active/inactive * - `resetScroll`/`hashScrollIntoView`: Control scroll behavior on navigation * - `viewTransition`/`startTransition`: Use View Transitions/React transitions for navigation diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index e9ff839d63d..3353c8fc089 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -48,12 +48,16 @@ import type { RouterHistory } from '../src' const ioObserveMock = vi.fn() const ioDisconnectMock = vi.fn() +let ioCallback: IntersectionObserverCallback let history: RouterHistory beforeEach(() => { const io = getIntersectionObserverMock({ observe: ioObserveMock, disconnect: ioDisconnectMock, + onCreate: (callback) => { + ioCallback = callback + }, }) vi.stubGlobal('IntersectionObserver', io) history = createBrowserHistory() @@ -61,6 +65,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() history.destroy() window.history.replaceState(null, 'root', '/') vi.resetAllMocks() @@ -5119,6 +5124,85 @@ describe('Link', () => { expect(ioDisconnectMock).toBeCalledTimes(1) // it should not disconnect again }) + test('Link.preload="viewport" should respect preloadDelay', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> + + Viewport Link + + + Intent Link + + + ), + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history, + }) + const preloadRouteSpy = vi.spyOn(router, 'preloadRoute') + + render() + + const viewportLink = await screen.findByRole('link', { + name: 'Viewport Link', + }) + const intentLink = await screen.findByRole('link', { name: 'Intent Link' }) + vi.useFakeTimers() + + ioCallback([], {} as IntersectionObserver) + ioCallback( + [ + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).not.toHaveBeenCalled() + + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + fireEvent.mouseLeave(viewportLink) + + expect(preloadRouteSpy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(49) + expect(preloadRouteSpy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + + fireEvent.mouseEnter(intentLink) + fireEvent.mouseLeave(intentLink) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + }) + test("Router.preload='render', should trigger the route loader on render", async () => { const mock = vi.fn() diff --git a/packages/react-router/tests/utils.ts b/packages/react-router/tests/utils.ts index be5d60819b5..da96eb2d959 100644 --- a/packages/react-router/tests/utils.ts +++ b/packages/react-router/tests/utils.ts @@ -20,9 +20,11 @@ export function createTimer() { export const getIntersectionObserverMock = ({ observe, disconnect, + onCreate, }: { observe: Mock disconnect: Mock + onCreate?: (callback: IntersectionObserverCallback) => void }) => { return class IO implements IntersectionObserver { root: Document | Element | null @@ -33,6 +35,7 @@ export const getIntersectionObserverMock = ({ _cb: IntersectionObserverCallback, options?: IntersectionObserverInit, ) { + onCreate?.(_cb) this.root = options?.root ?? null this.rootMargin = options?.rootMargin ?? '0px' this.scrollMargin = options?.scrollMargin ?? '0px' diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 7ec148b8f4e..f4f975adb64 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -677,9 +677,9 @@ export interface LinkOptionsProps { */ preload?: false | 'intent' | 'viewport' | 'render' /** - * When the intent preload strategy is set, this delays focus and hover - * preloading by this many milliseconds. Touch intent preloads immediately. - * If focus or hover exits before this delay, the preload will be cancelled. + * Delays focus, hover, and viewport preloading by this many milliseconds. + * Touch intent preloads immediately. If focus or hover exits before this + * delay, the preload will be cancelled. */ preloadDelay?: number /** diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index cc83e358de2..75c47a1c2ef 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -213,7 +213,8 @@ export interface RouterOptions< */ defaultPreload?: false | 'intent' | 'viewport' | 'render' /** - * The delay in milliseconds that a route must be hovered over or touched before it is preloaded. + * The delay in milliseconds before intent focus/hover and viewport preloading. + * Touch intent preloads immediately. * * @default 50 * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloaddelay-property) diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 0b2d101d40f..bcb85ecc723 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -31,7 +31,7 @@ import type { ValidateLinkOptionsArray, } from './typePrimitives' -const timeoutMap = new WeakMap>() +const timeoutMap = new WeakMap>() export function useLinkProps< TRouter extends AnyRouter = RegisteredRouter, @@ -190,7 +190,7 @@ export function useLinkProps< }) const preload = Solid.createMemo(() => { - if (options.reloadDocument || externalLink()) { + if (options.reloadDocument || externalLink() || local.disabled) { return false } return local.preload ?? router.options.defaultPreload @@ -255,11 +255,35 @@ export function useLinkProps< console.warn(preloadWarning) }) - const preloadViewportIoCallback = ( - entry: IntersectionObserverEntry | undefined, + const enqueuePreload = ( + e: MouseEvent | FocusEvent | IntersectionObserverEntry | undefined, ) => { - if (entry?.isIntersecting) { + if ( + !e || + !( + (e as IntersectionObserverEntry).isIntersecting ?? + preload() === 'intent' + ) + ) { + return + } + + if (!preloadDelay()) { doPreload() + return + } + + const eventTarget = + (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload + + if (!timeoutMap.has(eventTarget)) { + timeoutMap.set( + eventTarget, + setTimeout(() => { + timeoutMap.delete(eventTarget) + doPreload() + }, preloadDelay()), + ) } } @@ -267,16 +291,16 @@ export function useLinkProps< useIntersectionObserver( ref, - preloadViewportIoCallback, + enqueuePreload, { rootMargin: '100px' }, - !!local.disabled || preload() !== 'viewport', + preload() !== 'viewport', ) Solid.createEffect(() => { if (hasRenderFetched) { return } - if (!local.disabled && preload() === 'render') { + if (preload() === 'render') { doPreload() hasRenderFetched = true } @@ -345,34 +369,12 @@ export function useLinkProps< } } - const enqueueIntentPreload = (e: MouseEvent | FocusEvent) => { - if (local.disabled || preload() !== 'intent') return - - if (!preloadDelay()) { - doPreload() - return - } - - const eventTarget = e.currentTarget || e.target - - if (!eventTarget || timeoutMap.has(eventTarget)) return - - timeoutMap.set( - eventTarget, - setTimeout(() => { - timeoutMap.delete(eventTarget) - doPreload() - }, preloadDelay()), - ) - } - - const handleTouchStart = (_: TouchEvent) => { - if (local.disabled || preload() !== 'intent') return + const handleTouchStart = () => { + if (preload() !== 'intent') return doPreload() } const handleLeave = (e: MouseEvent | FocusEvent) => { - if (local.disabled) return const eventTarget = e.currentTarget || e.target if (eventTarget) { @@ -392,17 +394,14 @@ export function useLinkProps< const onClick = createComposedHandler(() => local.onClick, handleClick) const onBlur = createComposedHandler(() => local.onBlur, handleLeave) - const onFocus = createComposedHandler( - () => local.onFocus, - enqueueIntentPreload, - ) + const onFocus = createComposedHandler(() => local.onFocus, enqueuePreload) const onMouseEnter = createComposedHandler( () => local.onMouseEnter, - enqueueIntentPreload, + enqueuePreload, ) const onMouseOver = createComposedHandler( () => local.onMouseOver, - enqueueIntentPreload, + enqueuePreload, ) const onMouseLeave = createComposedHandler( () => local.onMouseLeave, diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 7642030465f..afed5176b58 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -43,12 +43,16 @@ import type { RouterHistory } from '../src' const ioObserveMock = vi.fn() const ioDisconnectMock = vi.fn() +let ioCallback: IntersectionObserverCallback let history: RouterHistory beforeEach(() => { const io = getIntersectionObserverMock({ observe: ioObserveMock, disconnect: ioDisconnectMock, + onCreate: (callback) => { + ioCallback = callback + }, }) vi.stubGlobal('IntersectionObserver', io) history = createBrowserHistory() @@ -56,6 +60,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() history.destroy?.() window.history.replaceState(null, 'root', '/') vi.resetAllMocks() @@ -4997,6 +5002,85 @@ describe('Link', () => { expect(ioDisconnectMock).not.toHaveBeenCalled() // it should not disconnect again }) + test('Link.preload="viewport" should respect preloadDelay', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> + + Viewport Link + + + Intent Link + + + ), + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history, + }) + const preloadRouteSpy = vi.spyOn(router, 'preloadRoute') + + render(() => ) + + const viewportLink = await screen.findByRole('link', { + name: 'Viewport Link', + }) + const intentLink = await screen.findByRole('link', { name: 'Intent Link' }) + vi.useFakeTimers() + + ioCallback([], {} as IntersectionObserver) + ioCallback( + [ + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).not.toHaveBeenCalled() + + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + fireEvent.mouseLeave(viewportLink) + + expect(preloadRouteSpy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(49) + expect(preloadRouteSpy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + + fireEvent.mouseEnter(intentLink) + fireEvent.mouseLeave(intentLink) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + }) + test("Router.preload='render', should trigger the route loader on render", async () => { const mock = vi.fn() diff --git a/packages/solid-router/tests/utils.ts b/packages/solid-router/tests/utils.ts index 6c11bea20a8..668cc9deb6a 100644 --- a/packages/solid-router/tests/utils.ts +++ b/packages/solid-router/tests/utils.ts @@ -20,9 +20,11 @@ export function createTimer() { export const getIntersectionObserverMock = ({ observe, disconnect, + onCreate, }: { observe: Mock disconnect: Mock + onCreate?: (callback: IntersectionObserverCallback) => void }) => { return class IO implements IntersectionObserver { root: Document | Element | null @@ -33,6 +35,7 @@ export const getIntersectionObserverMock = ({ _cb: IntersectionObserverCallback, options?: IntersectionObserverInit, ) { + onCreate?.(_cb) this.root = options?.root ?? null this.rootMargin = options?.rootMargin ?? '0px' this.scrollMargin = options?.scrollMargin ?? '0px' diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 75c1901e4e6..3513a03beaa 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -29,7 +29,7 @@ import type { type EventHandler = (e: TEvent) => void -const timeoutMap = new WeakMap>() +const timeoutMap = new WeakMap>() type DataAttributes = { [K in `data-${string}`]?: unknown @@ -224,7 +224,7 @@ export function useLinkProps< }) const preload = Vue.computed(() => { - if (options.reloadDocument) { + if (options.reloadDocument || options.disabled) { return false } return options.preload ?? router.options.defaultPreload @@ -251,26 +251,50 @@ export function useLinkProps< console.warn(preloadWarning) }) - const preloadViewportIoCallback = ( - entry: IntersectionObserverEntry | undefined, + const enqueuePreload = ( + e: MouseEvent | FocusEvent | IntersectionObserverEntry | undefined, ) => { - if (entry?.isIntersecting) { + if ( + !e || + !( + (e as IntersectionObserverEntry).isIntersecting ?? + preload.value === 'intent' + ) + ) { + return + } + + if (!preloadDelay.value) { doPreload() + return + } + + const eventTarget = + (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload + + if (!timeoutMap.has(eventTarget)) { + timeoutMap.set( + eventTarget, + setTimeout(() => { + timeoutMap.delete(eventTarget) + doPreload() + }, preloadDelay.value), + ) } } useIntersectionObserver( ref, - preloadViewportIoCallback, + enqueuePreload, { rootMargin: '100px' }, - () => !!options.disabled || preload.value !== 'viewport', + () => preload.value !== 'viewport', ) Vue.effect(() => { if (hasRenderFetched) { return } - if (!options.disabled && preload.value === 'render') { + if (preload.value === 'render') { doPreload() hasRenderFetched = true } @@ -319,34 +343,12 @@ export function useLinkProps< } } - const enqueueIntentPreload = (e: MouseEvent | FocusEvent) => { - if (options.disabled || preload.value !== 'intent') return - - if (!preloadDelay.value) { - doPreload() - return - } - - const eventTarget = e.currentTarget || e.target - - if (!eventTarget || timeoutMap.has(eventTarget)) return - - timeoutMap.set( - eventTarget, - setTimeout(() => { - timeoutMap.delete(eventTarget) - doPreload() - }, preloadDelay.value), - ) - } - - const handleTouchStart = (_: TouchEvent) => { - if (options.disabled || preload.value !== 'intent') return + const handleTouchStart = () => { + if (preload.value !== 'intent') return doPreload() } const handleLeave = (e: MouseEvent | FocusEvent) => { - if (options.disabled) return const eventTarget = e.currentTarget || e.target if (eventTarget) { @@ -384,15 +386,15 @@ export function useLinkProps< onBlur: composeEventHandlers([options.onBlur, handleLeave]), onFocus: composeEventHandlers([ options.onFocus, - enqueueIntentPreload, + enqueuePreload, ]), onMouseenter: composeEventHandlers([ eventHandlers.onMouseenter, - enqueueIntentPreload, + enqueuePreload, ]), onMouseover: composeEventHandlers([ eventHandlers.onMouseover, - enqueueIntentPreload, + enqueuePreload, ]), onMouseleave: composeEventHandlers([ eventHandlers.onMouseleave, diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index 8d9e4a67acf..9eb5ef8e7a0 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -43,12 +43,16 @@ import type { RouterHistory } from '../src' const ioObserveMock = vi.fn() const ioDisconnectMock = vi.fn() +let ioCallback: IntersectionObserverCallback let history: RouterHistory beforeEach(() => { const io = getIntersectionObserverMock({ observe: ioObserveMock, disconnect: ioDisconnectMock, + onCreate: (callback) => { + ioCallback = callback + }, }) vi.stubGlobal('IntersectionObserver', io) history = createBrowserHistory() @@ -56,6 +60,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() history.destroy?.() window.history.replaceState(null, 'root', '/') vi.resetAllMocks() @@ -5054,6 +5059,85 @@ describe('Link', () => { expect(ioDisconnectMock).not.toHaveBeenCalled() // it should not disconnect again }) + test('Link.preload="viewport" should respect preloadDelay', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> + + Viewport Link + + + Intent Link + + + ), + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history, + }) + const preloadRouteSpy = vi.spyOn(router, 'preloadRoute') + + render() + + const viewportLink = await screen.findByRole('link', { + name: 'Viewport Link', + }) + const intentLink = await screen.findByRole('link', { name: 'Intent Link' }) + vi.useFakeTimers() + + ioCallback([], {} as IntersectionObserver) + ioCallback( + [ + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).not.toHaveBeenCalled() + + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + fireEvent.mouseLeave(viewportLink) + + expect(preloadRouteSpy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(49) + expect(preloadRouteSpy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + + fireEvent.mouseEnter(intentLink) + fireEvent.mouseLeave(intentLink) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + }) + test('Link.disabled should disable viewport observation', async () => { const rootRoute = createRootRoute() const indexRoute = createRoute({ diff --git a/packages/vue-router/tests/utils.ts b/packages/vue-router/tests/utils.ts index be5d60819b5..da96eb2d959 100644 --- a/packages/vue-router/tests/utils.ts +++ b/packages/vue-router/tests/utils.ts @@ -20,9 +20,11 @@ export function createTimer() { export const getIntersectionObserverMock = ({ observe, disconnect, + onCreate, }: { observe: Mock disconnect: Mock + onCreate?: (callback: IntersectionObserverCallback) => void }) => { return class IO implements IntersectionObserver { root: Document | Element | null @@ -33,6 +35,7 @@ export const getIntersectionObserverMock = ({ _cb: IntersectionObserverCallback, options?: IntersectionObserverInit, ) { + onCreate?.(_cb) this.root = options?.root ?? null this.rootMargin = options?.rootMargin ?? '0px' this.scrollMargin = options?.scrollMargin ?? '0px' From 9ae858c4f4815efbc8b04ee848322f08081381eb Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Aug 2026 20:54:22 +0200 Subject: [PATCH 2/7] fix(router): cancel delayed viewport preloads on exit --- .changeset/tidy-links-wait.md | 2 +- docs/router/api/router/LinkOptionsType.md | 2 +- docs/router/guide/navigation.md | 2 +- docs/router/guide/preloading.md | 2 +- packages/react-router/src/link.tsx | 23 +++++++++++++---------- packages/react-router/tests/link.test.tsx | 21 +++++++++++++++++++++ packages/router-core/src/link.ts | 4 ++-- packages/solid-router/src/link.tsx | 20 +++++++++++++------- packages/solid-router/tests/link.test.tsx | 21 +++++++++++++++++++++ packages/vue-router/src/link.tsx | 16 ++++++++++------ packages/vue-router/tests/link.test.tsx | 21 +++++++++++++++++++++ 11 files changed, 105 insertions(+), 29 deletions(-) diff --git a/.changeset/tidy-links-wait.md b/.changeset/tidy-links-wait.md index 485f02ae6f5..f3c78612e0a 100644 --- a/.changeset/tidy-links-wait.md +++ b/.changeset/tidy-links-wait.md @@ -4,4 +4,4 @@ '@tanstack/vue-router': patch --- -Apply `preloadDelay` to viewport link preloading. +Apply `preloadDelay` to viewport link preloading and cancel pending preloads when links leave the viewport. diff --git a/docs/router/api/router/LinkOptionsType.md b/docs/router/api/router/LinkOptionsType.md index c321c96b4e5..9d3d3fe8d8c 100644 --- a/docs/router/api/router/LinkOptionsType.md +++ b/docs/router/api/router/LinkOptionsType.md @@ -42,7 +42,7 @@ The `LinkOptions` object accepts/contains the following properties: - Type: `number` - Optional -- Delay focus, hover, and viewport preloading by this many milliseconds. Touch intent preloads immediately. If focus or hover exits before the delay, the preload will be cancelled. +- Delay focus, hover, and viewport preloading by this many milliseconds. Touch intent preloads immediately. If focus or hover ends, or the link leaves the viewport before the delay, the preload will be cancelled. ### `disabled` diff --git a/docs/router/guide/navigation.md b/docs/router/guide/navigation.md index 0f03ec99708..cdf06e0e43e 100644 --- a/docs/router/guide/navigation.md +++ b/docs/router/guide/navigation.md @@ -731,7 +731,7 @@ What's even better is that by using a cache-first library like `@tanstack/query` ### Link Preloading Delay -For `'intent'` and `'viewport'` preloading, a configurable delay determines how long to wait before preloading begins after focus, hover, or viewport entry. If focus or hover ends before the delay, the queued preload is cancelled. Touch intent preloads immediately without waiting for the delay. The default delay is 50 milliseconds, but you can change it by passing a `preloadDelay` prop to the `Link` component: +For `'intent'` and `'viewport'` preloading, a configurable delay determines how long to wait before preloading begins after focus, hover, or viewport entry. If focus or hover ends, or the link leaves the viewport before the delay, the queued preload is cancelled. Touch intent preloads immediately without waiting for the delay. The default delay is 50 milliseconds, but you can change it by passing a `preloadDelay` prop to the `Link` component: ```tsx const link = ( diff --git a/docs/router/guide/preloading.md b/docs/router/guide/preloading.md index 4e360f6b316..7f48412ce68 100644 --- a/docs/router/guide/preloading.md +++ b/docs/router/guide/preloading.md @@ -64,7 +64,7 @@ This will turn on `intent` preloading by default for all `` components in ## Preload Delay -By default, intent focus/hover and viewport preloading start after **50ms**. Touch intent preloads immediately. You can change this delay by setting the `defaultPreloadDelay` option on your router: +By default, intent focus/hover and viewport preloading start after **50ms**. Pending preloads are cancelled if focus or hover ends, or the link leaves the viewport. Touch intent preloads immediately. You can change this delay by setting the `defaultPreloadDelay` option on your router: diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 2514394c628..030a8985e17 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -623,13 +623,20 @@ export function useLinkProps< | IntersectionObserverEntry | undefined, ) => { + if (!e) { + return + } + + const eventTarget = + (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload + if ( - !e || !( (e as IntersectionObserverEntry).isIntersecting ?? preload === 'intent' ) ) { + cancelPreload(eventTarget) return } @@ -638,9 +645,6 @@ export function useLinkProps< return } - const eventTarget = - (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload - if (timeoutMap.has(eventTarget)) { return } @@ -740,12 +744,7 @@ export function useLinkProps< } const handleLeave = (e: React.MouseEvent | React.FocusEvent) => { - const eventTarget = e.currentTarget - const id = timeoutMap.get(eventTarget) - if (id) { - clearTimeout(id) - timeoutMap.delete(eventTarget) - } + cancelPreload(e.currentTarget) } return { @@ -777,6 +776,10 @@ const STATIC_ACTIVE_PROPS = { 'data-status': 'active', 'aria-current': 'page' } const STATIC_TRANSITIONING_PROPS = { 'data-transitioning': 'transitioning' } const timeoutMap = new WeakMap>() +const cancelPreload = (eventTarget: object) => { + clearTimeout(timeoutMap.get(eventTarget)) + timeoutMap.delete(eventTarget) +} const intersectionObserverOptions: IntersectionObserverInit = { rootMargin: '100px', diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 3353c8fc089..c640ba29a5d 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5197,6 +5197,27 @@ describe('Link', () => { await vi.advanceTimersByTimeAsync(1) expect(preloadRouteSpy).toHaveBeenCalledOnce() + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + ioCallback( + [ + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + fireEvent.mouseEnter(intentLink) fireEvent.mouseLeave(intentLink) await vi.advanceTimersByTimeAsync(50) diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index f4f975adb64..799ae50811a 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -678,8 +678,8 @@ export interface LinkOptionsProps { preload?: false | 'intent' | 'viewport' | 'render' /** * Delays focus, hover, and viewport preloading by this many milliseconds. - * Touch intent preloads immediately. If focus or hover exits before this - * delay, the preload will be cancelled. + * Touch intent preloads immediately. If focus or hover ends, or the link + * leaves the viewport before this delay, the preload will be cancelled. */ preloadDelay?: number /** diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index bcb85ecc723..5cc1c4850f9 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -32,6 +32,10 @@ import type { } from './typePrimitives' const timeoutMap = new WeakMap>() +const cancelPreload = (eventTarget: object) => { + clearTimeout(timeoutMap.get(eventTarget)) + timeoutMap.delete(eventTarget) +} export function useLinkProps< TRouter extends AnyRouter = RegisteredRouter, @@ -258,13 +262,20 @@ export function useLinkProps< const enqueuePreload = ( e: MouseEvent | FocusEvent | IntersectionObserverEntry | undefined, ) => { + if (!e) { + return + } + + const eventTarget = + (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload + if ( - !e || !( (e as IntersectionObserverEntry).isIntersecting ?? preload() === 'intent' ) ) { + cancelPreload(eventTarget) return } @@ -273,9 +284,6 @@ export function useLinkProps< return } - const eventTarget = - (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload - if (!timeoutMap.has(eventTarget)) { timeoutMap.set( eventTarget, @@ -378,9 +386,7 @@ export function useLinkProps< const eventTarget = e.currentTarget || e.target if (eventTarget) { - const id = timeoutMap.get(eventTarget) - clearTimeout(id) - timeoutMap.delete(eventTarget) + cancelPreload(eventTarget) } } diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index afed5176b58..46dfc0391a5 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -5075,6 +5075,27 @@ describe('Link', () => { await vi.advanceTimersByTimeAsync(1) expect(preloadRouteSpy).toHaveBeenCalledOnce() + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + ioCallback( + [ + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + fireEvent.mouseEnter(intentLink) fireEvent.mouseLeave(intentLink) await vi.advanceTimersByTimeAsync(50) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 3513a03beaa..fdba3a077b3 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -254,13 +254,21 @@ export function useLinkProps< const enqueuePreload = ( e: MouseEvent | FocusEvent | IntersectionObserverEntry | undefined, ) => { + if (!e) { + return + } + + const eventTarget = + (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload + if ( - !e || !( (e as IntersectionObserverEntry).isIntersecting ?? preload.value === 'intent' ) ) { + clearTimeout(timeoutMap.get(eventTarget)) + timeoutMap.delete(eventTarget) return } @@ -269,9 +277,6 @@ export function useLinkProps< return } - const eventTarget = - (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload - if (!timeoutMap.has(eventTarget)) { timeoutMap.set( eventTarget, @@ -352,8 +357,7 @@ export function useLinkProps< const eventTarget = e.currentTarget || e.target if (eventTarget) { - const id = timeoutMap.get(eventTarget) - clearTimeout(id) + clearTimeout(timeoutMap.get(eventTarget)) timeoutMap.delete(eventTarget) } } diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index 9eb5ef8e7a0..e8818036077 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -5132,6 +5132,27 @@ describe('Link', () => { await vi.advanceTimersByTimeAsync(1) expect(preloadRouteSpy).toHaveBeenCalledOnce() + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + ioCallback( + [ + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + fireEvent.mouseEnter(intentLink) fireEvent.mouseLeave(intentLink) await vi.advanceTimersByTimeAsync(50) From 71a0e13c5081777c0e3dc62f81afa0933c4ab64f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Aug 2026 21:40:50 +0200 Subject: [PATCH 3/7] fix(router): process queued viewport transitions in order --- packages/react-router/src/utils.ts | 4 ++-- packages/react-router/tests/link.test.tsx | 28 +++++++++++++++++++++-- packages/solid-router/src/utils.ts | 4 ++-- packages/solid-router/tests/link.test.tsx | 28 +++++++++++++++++++++-- packages/vue-router/src/utils.ts | 4 ++-- packages/vue-router/tests/link.test.tsx | 28 +++++++++++++++++++++-- 6 files changed, 84 insertions(+), 12 deletions(-) diff --git a/packages/react-router/src/utils.ts b/packages/react-router/src/utils.ts index 60808bceb1a..a6ae4f71975 100644 --- a/packages/react-router/src/utils.ts +++ b/packages/react-router/src/utils.ts @@ -98,8 +98,8 @@ export function useIntersectionObserver( return } - const observer = new IntersectionObserver(([entry]) => { - callback(entry) + const observer = new IntersectionObserver((entries) => { + entries.forEach(callback) }, intersectionObserverOptions) observer.observe(ref.current) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index c640ba29a5d..2a015341ac2 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5197,6 +5197,22 @@ describe('Link', () => { await vi.advanceTimersByTimeAsync(1) expect(preloadRouteSpy).toHaveBeenCalledOnce() + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + ioCallback( [ { @@ -5206,22 +5222,30 @@ describe('Link', () => { ], {} as IntersectionObserver, ) + await vi.advanceTimersByTimeAsync(49) + ioCallback( [ { isIntersecting: false, target: viewportLink, } as unknown as IntersectionObserverEntry, + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, ], {} as IntersectionObserver, ) - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(1) expect(preloadRouteSpy).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(49) + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) fireEvent.mouseEnter(intentLink) fireEvent.mouseLeave(intentLink) await vi.advanceTimersByTimeAsync(50) - expect(preloadRouteSpy).toHaveBeenCalledOnce() + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) }) test("Router.preload='render', should trigger the route loader on render", async () => { diff --git a/packages/solid-router/src/utils.ts b/packages/solid-router/src/utils.ts index cac0e28aaae..d99be9a7a46 100644 --- a/packages/solid-router/src/utils.ts +++ b/packages/solid-router/src/utils.ts @@ -41,8 +41,8 @@ export function useIntersectionObserver( return } - observerRef = new IntersectionObserver(([entry]) => { - callback(entry) + observerRef = new IntersectionObserver((entries) => { + entries.forEach(callback) }, intersectionObserverOptions) observerRef.observe(r) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 46dfc0391a5..163a4378f21 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -5075,6 +5075,22 @@ describe('Link', () => { await vi.advanceTimersByTimeAsync(1) expect(preloadRouteSpy).toHaveBeenCalledOnce() + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + ioCallback( [ { @@ -5084,22 +5100,30 @@ describe('Link', () => { ], {} as IntersectionObserver, ) + await vi.advanceTimersByTimeAsync(49) + ioCallback( [ { isIntersecting: false, target: viewportLink, } as unknown as IntersectionObserverEntry, + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, ], {} as IntersectionObserver, ) - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(1) expect(preloadRouteSpy).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(49) + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) fireEvent.mouseEnter(intentLink) fireEvent.mouseLeave(intentLink) await vi.advanceTimersByTimeAsync(50) - expect(preloadRouteSpy).toHaveBeenCalledOnce() + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) }) test("Router.preload='render', should trigger the route loader on render", async () => { diff --git a/packages/vue-router/src/utils.ts b/packages/vue-router/src/utils.ts index 0633847db6a..e331f2cb331 100644 --- a/packages/vue-router/src/utils.ts +++ b/packages/vue-router/src/utils.ts @@ -65,8 +65,8 @@ export function useIntersectionObserver( return } - const observer = new IntersectionObserver(([entry]) => { - callback(entry) + const observer = new IntersectionObserver((entries) => { + entries.forEach(callback) }, intersectionObserverOptions) observerRef.value = observer diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index e8818036077..9d2e848094a 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -5132,6 +5132,22 @@ describe('Link', () => { await vi.advanceTimersByTimeAsync(1) expect(preloadRouteSpy).toHaveBeenCalledOnce() + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledOnce() + ioCallback( [ { @@ -5141,22 +5157,30 @@ describe('Link', () => { ], {} as IntersectionObserver, ) + await vi.advanceTimersByTimeAsync(49) + ioCallback( [ { isIntersecting: false, target: viewportLink, } as unknown as IntersectionObserverEntry, + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, ], {} as IntersectionObserver, ) - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(1) expect(preloadRouteSpy).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(49) + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) fireEvent.mouseEnter(intentLink) fireEvent.mouseLeave(intentLink) await vi.advanceTimersByTimeAsync(50) - expect(preloadRouteSpy).toHaveBeenCalledOnce() + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) }) test('Link.disabled should disable viewport observation', async () => { From 0c37079e1c4c791ef5a015bd513d7a932774dcb9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Aug 2026 22:06:51 +0200 Subject: [PATCH 4/7] fix(router): use latest queued viewport state --- packages/react-router/src/utils.ts | 2 +- packages/react-router/tests/link.test.tsx | 2 -- packages/solid-router/src/utils.ts | 2 +- packages/solid-router/tests/link.test.tsx | 2 -- packages/vue-router/src/utils.ts | 2 +- packages/vue-router/tests/link.test.tsx | 2 -- 6 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/react-router/src/utils.ts b/packages/react-router/src/utils.ts index a6ae4f71975..d7c0e2f3063 100644 --- a/packages/react-router/src/utils.ts +++ b/packages/react-router/src/utils.ts @@ -99,7 +99,7 @@ export function useIntersectionObserver( } const observer = new IntersectionObserver((entries) => { - entries.forEach(callback) + callback(entries.pop()) }, intersectionObserverOptions) observer.observe(ref.current) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 2a015341ac2..fb6a2c2d4d1 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5238,8 +5238,6 @@ describe('Link', () => { {} as IntersectionObserver, ) await vi.advanceTimersByTimeAsync(1) - expect(preloadRouteSpy).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(49) expect(preloadRouteSpy).toHaveBeenCalledTimes(2) fireEvent.mouseEnter(intentLink) diff --git a/packages/solid-router/src/utils.ts b/packages/solid-router/src/utils.ts index d99be9a7a46..7546a6a9379 100644 --- a/packages/solid-router/src/utils.ts +++ b/packages/solid-router/src/utils.ts @@ -42,7 +42,7 @@ export function useIntersectionObserver( } observerRef = new IntersectionObserver((entries) => { - entries.forEach(callback) + callback(entries.pop()) }, intersectionObserverOptions) observerRef.observe(r) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 163a4378f21..968d2642736 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -5116,8 +5116,6 @@ describe('Link', () => { {} as IntersectionObserver, ) await vi.advanceTimersByTimeAsync(1) - expect(preloadRouteSpy).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(49) expect(preloadRouteSpy).toHaveBeenCalledTimes(2) fireEvent.mouseEnter(intentLink) diff --git a/packages/vue-router/src/utils.ts b/packages/vue-router/src/utils.ts index e331f2cb331..09bdc8d0444 100644 --- a/packages/vue-router/src/utils.ts +++ b/packages/vue-router/src/utils.ts @@ -66,7 +66,7 @@ export function useIntersectionObserver( } const observer = new IntersectionObserver((entries) => { - entries.forEach(callback) + callback(entries.pop()) }, intersectionObserverOptions) observerRef.value = observer diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index 9d2e848094a..515151c8945 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -5173,8 +5173,6 @@ describe('Link', () => { {} as IntersectionObserver, ) await vi.advanceTimersByTimeAsync(1) - expect(preloadRouteSpy).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(49) expect(preloadRouteSpy).toHaveBeenCalledTimes(2) fireEvent.mouseEnter(intentLink) From 5234e799e2203def5fd171f6bdc2b4700effa9b2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Aug 2026 22:30:14 +0200 Subject: [PATCH 5/7] refactor(router): inline viewport observer options --- packages/react-router/src/link.tsx | 11 +---------- packages/react-router/src/utils.ts | 16 ++++++++-------- packages/solid-router/src/link.tsx | 7 +------ packages/solid-router/src/utils.ts | 14 +++++++------- packages/vue-router/src/link.tsx | 1 - packages/vue-router/src/utils.ts | 14 +++++++------- packages/vue-router/tests/link.test.tsx | 1 - 7 files changed, 24 insertions(+), 40 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 030a8985e17..6291d8737a3 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -661,12 +661,7 @@ export function useLinkProps< ) // eslint-disable-next-line react-hooks/rules-of-hooks - useIntersectionObserver( - innerRef, - enqueuePreload, - intersectionObserverOptions, - preload !== 'viewport', - ) + useIntersectionObserver(innerRef, enqueuePreload, preload !== 'viewport') // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { @@ -781,10 +776,6 @@ const cancelPreload = (eventTarget: object) => { timeoutMap.delete(eventTarget) } -const intersectionObserverOptions: IntersectionObserverInit = { - rootMargin: '100px', -} - const composeHandlers = (handlers: Array>) => (e: React.SyntheticEvent) => { diff --git a/packages/react-router/src/utils.ts b/packages/react-router/src/utils.ts index d7c0e2f3063..e15a575a7db 100644 --- a/packages/react-router/src/utils.ts +++ b/packages/react-router/src/utils.ts @@ -66,9 +66,8 @@ export function usePrevious(value: T): T | null { * When the intersection changes, the callback will be called with the `IntersectionObserverEntry`. * * @param ref - The ref to observe - * @param intersectionObserverOptions - The options to pass to the IntersectionObserver - * @param disabled - Whether observation is disabled * @param callback - The callback to call when the intersection changes + * @param disabled - Whether observation is disabled * @returns The IntersectionObserver instance * @example * ```tsx @@ -77,7 +76,6 @@ export function usePrevious(value: T): T | null { * useIntersectionObserver( * ref, * (entry) => { doSomething(entry) }, - * { rootMargin: '10px' }, * false * ) * return
@@ -86,7 +84,6 @@ export function usePrevious(value: T): T | null { export function useIntersectionObserver( ref: React.RefObject, callback: (entry: IntersectionObserverEntry | undefined) => void, - intersectionObserverOptions: IntersectionObserverInit = {}, disabled?: boolean, ) { React.useEffect(() => { @@ -98,16 +95,19 @@ export function useIntersectionObserver( return } - const observer = new IntersectionObserver((entries) => { - callback(entries.pop()) - }, intersectionObserverOptions) + const observer = new IntersectionObserver( + (entries) => { + callback(entries.pop()) + }, + { rootMargin: '100px' }, + ) observer.observe(ref.current) return () => { observer.disconnect() } - }, [callback, disabled, intersectionObserverOptions, ref]) + }, [callback, disabled, ref]) } /** diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 5cc1c4850f9..c58d4ef74aa 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -297,12 +297,7 @@ export function useLinkProps< const [ref, setRef] = Solid.createSignal(null) - useIntersectionObserver( - ref, - enqueuePreload, - { rootMargin: '100px' }, - preload() !== 'viewport', - ) + useIntersectionObserver(ref, enqueuePreload, preload() !== 'viewport') Solid.createEffect(() => { if (hasRenderFetched) { diff --git a/packages/solid-router/src/utils.ts b/packages/solid-router/src/utils.ts index 7546a6a9379..08516cac7f7 100644 --- a/packages/solid-router/src/utils.ts +++ b/packages/solid-router/src/utils.ts @@ -8,9 +8,8 @@ import * as Solid from 'solid-js' * When the intersection changes, the callback will be called with the `IntersectionObserverEntry`. * * @param ref - The ref to observe - * @param intersectionObserverOptions - The options to pass to the IntersectionObserver - * @param disabled - Whether observation is disabled * @param callback - The callback to call when the intersection changes + * @param disabled - Whether observation is disabled * @returns The IntersectionObserver instance * @example * ```tsx @@ -19,7 +18,6 @@ import * as Solid from 'solid-js' * useIntersectionObserver( * ref, * (entry) => { doSomething(entry) }, - * { rootMargin: '10px' }, * false * ) * return
@@ -28,7 +26,6 @@ import * as Solid from 'solid-js' export function useIntersectionObserver( ref: Solid.Accessor, callback: (entry: IntersectionObserverEntry | undefined) => void, - intersectionObserverOptions: IntersectionObserverInit = {}, disabled?: boolean, ): Solid.Accessor { const isIntersectionObserverAvailable = @@ -41,9 +38,12 @@ export function useIntersectionObserver( return } - observerRef = new IntersectionObserver((entries) => { - callback(entries.pop()) - }, intersectionObserverOptions) + observerRef = new IntersectionObserver( + (entries) => { + callback(entries.pop()) + }, + { rootMargin: '100px' }, + ) observerRef.observe(r) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index fdba3a077b3..27864071c24 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -291,7 +291,6 @@ export function useLinkProps< useIntersectionObserver( ref, enqueuePreload, - { rootMargin: '100px' }, () => preload.value !== 'viewport', ) diff --git a/packages/vue-router/src/utils.ts b/packages/vue-router/src/utils.ts index 09bdc8d0444..a3d386197ab 100644 --- a/packages/vue-router/src/utils.ts +++ b/packages/vue-router/src/utils.ts @@ -31,9 +31,8 @@ export const usePrevious = (fn: () => boolean) => { * When the intersection changes, the callback will be called with the `IntersectionObserverEntry`. * * @param ref - The ref to observe - * @param intersectionObserverOptions - The options to pass to the IntersectionObserver - * @param disabled - Whether observation is disabled * @param callback - The callback to call when the intersection changes + * @param disabled - Whether observation is disabled * @returns The IntersectionObserver instance * @example * ```tsx @@ -42,7 +41,6 @@ export const usePrevious = (fn: () => boolean) => { * useIntersectionObserver( * ref, * (entry) => { doSomething(entry) }, - * { rootMargin: '10px' }, * () => false * ) * return
@@ -51,7 +49,6 @@ export const usePrevious = (fn: () => boolean) => { export function useIntersectionObserver( ref: Vue.Ref, callback: (entry: IntersectionObserverEntry | undefined) => void, - intersectionObserverOptions: IntersectionObserverInit = {}, disabled: () => boolean, ): Vue.Ref { const isIntersectionObserverAvailable = @@ -65,9 +62,12 @@ export function useIntersectionObserver( return } - const observer = new IntersectionObserver((entries) => { - callback(entries.pop()) - }, intersectionObserverOptions) + const observer = new IntersectionObserver( + (entries) => { + callback(entries.pop()) + }, + { rootMargin: '100px' }, + ) observerRef.value = observer observer.observe(r) diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index 515151c8945..4fd46fb4d49 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -5219,7 +5219,6 @@ describe('Link', () => { useIntersectionObserver( element, () => {}, - {}, () => disabled.value, ) From 0956e761033183f95456efc8bb59d43b9251396e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 11 Aug 2026 23:00:10 +0200 Subject: [PATCH 6/7] fix(router): stabilize viewport preload timers --- packages/react-router/src/link.tsx | 5 +- packages/react-router/src/utils.ts | 1 + packages/react-router/tests/link.test.tsx | 100 ++++++++++++++++++++++ packages/solid-router/src/link.tsx | 9 +- packages/solid-router/src/utils.ts | 5 +- packages/vue-router/src/link.tsx | 4 +- packages/vue-router/src/utils.ts | 1 + 7 files changed, 116 insertions(+), 9 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 6291d8737a3..b4bbeeaf239 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -624,11 +624,12 @@ export function useLinkProps< | undefined, ) => { if (!e) { + cancelPreload(innerRef) return } const eventTarget = - (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload + (e as { currentTarget?: EventTarget | null }).currentTarget || innerRef if ( !( @@ -657,7 +658,7 @@ export function useLinkProps< }, preloadDelay), ) }, - [doPreload, preload, preloadDelay], + [doPreload, innerRef, preload, preloadDelay], ) // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/react-router/src/utils.ts b/packages/react-router/src/utils.ts index e15a575a7db..4a191efbefd 100644 --- a/packages/react-router/src/utils.ts +++ b/packages/react-router/src/utils.ts @@ -106,6 +106,7 @@ export function useIntersectionObserver( return () => { observer.disconnect() + callback(undefined) } }, [callback, disabled, ref]) } diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index fb6a2c2d4d1..6191796a980 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5246,6 +5246,106 @@ describe('Link', () => { expect(preloadRouteSpy).toHaveBeenCalledTimes(2) }) + test('Link.preload="viewport" should cancel and use new link options after they change', async () => { + const rootRoute = createRootRoute() + const RouteComponent = () => { + const [to, setTo] = React.useState<'/about' | '/other'>('/about') + return ( + <> + + + Viewport Link + + + ) + } + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: RouteComponent, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute, otherRoute]), + history, + }) + const preloadRouteSpy = vi.spyOn(router, 'preloadRoute') + + render() + + const viewportLink = await screen.findByRole('link', { + name: 'Viewport Link', + }) + const initialIoCallback = ioCallback + vi.useFakeTimers() + + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Change destination' })) + expect(viewportLink).toHaveAttribute('href', '/other') + expect(ioCallback).not.toBe(initialIoCallback) + + ioCallback( + [ + { + isIntersecting: false, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).not.toHaveBeenCalled() + + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + fireEvent.click(screen.getByRole('button', { name: 'Change destination' })) + expect(viewportLink).toHaveAttribute('href', '/about') + + ioCallback( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledTimes(1) + expect(preloadRouteSpy).toHaveBeenCalledWith( + expect.objectContaining({ to: '/about' }), + ) + }) + test("Router.preload='render', should trigger the route loader on render", async () => { const mock = vi.fn() diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index c58d4ef74aa..604119cf0af 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -259,15 +259,18 @@ export function useLinkProps< console.warn(preloadWarning) }) + const [ref, setRef] = Solid.createSignal(null) + const enqueuePreload = ( e: MouseEvent | FocusEvent | IntersectionObserverEntry | undefined, ) => { if (!e) { + cancelPreload(ref) return } const eventTarget = - (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload + (e as { currentTarget?: EventTarget | null }).currentTarget || ref if ( !( @@ -295,9 +298,7 @@ export function useLinkProps< } } - const [ref, setRef] = Solid.createSignal(null) - - useIntersectionObserver(ref, enqueuePreload, preload() !== 'viewport') + useIntersectionObserver(ref, enqueuePreload, () => preload() !== 'viewport') Solid.createEffect(() => { if (hasRenderFetched) { diff --git a/packages/solid-router/src/utils.ts b/packages/solid-router/src/utils.ts index 08516cac7f7..fb5b99f016e 100644 --- a/packages/solid-router/src/utils.ts +++ b/packages/solid-router/src/utils.ts @@ -26,7 +26,7 @@ import * as Solid from 'solid-js' export function useIntersectionObserver( ref: Solid.Accessor, callback: (entry: IntersectionObserverEntry | undefined) => void, - disabled?: boolean, + disabled: Solid.Accessor, ): Solid.Accessor { const isIntersectionObserverAvailable = typeof IntersectionObserver === 'function' @@ -34,7 +34,7 @@ export function useIntersectionObserver( Solid.createEffect(() => { const r = ref() - if (!r || !isIntersectionObserverAvailable || disabled) { + if (!r || !isIntersectionObserverAvailable || disabled()) { return } @@ -49,6 +49,7 @@ export function useIntersectionObserver( Solid.onCleanup(() => { observerRef?.disconnect() + callback(undefined) }) }) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 27864071c24..96eee53a50e 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -255,11 +255,13 @@ export function useLinkProps< e: MouseEvent | FocusEvent | IntersectionObserverEntry | undefined, ) => { if (!e) { + clearTimeout(timeoutMap.get(ref)) + timeoutMap.delete(ref) return } const eventTarget = - (e as { currentTarget?: EventTarget | null }).currentTarget || doPreload + (e as { currentTarget?: EventTarget | null }).currentTarget || ref if ( !( diff --git a/packages/vue-router/src/utils.ts b/packages/vue-router/src/utils.ts index a3d386197ab..0d5da9c4127 100644 --- a/packages/vue-router/src/utils.ts +++ b/packages/vue-router/src/utils.ts @@ -75,6 +75,7 @@ export function useIntersectionObserver( onCleanup(() => { observer.disconnect() observerRef.value = null + callback(undefined) }) }) From dddc18d279099f17a36ab7a2bc423bb322fed956 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Aug 2026 09:01:55 +0200 Subject: [PATCH 7/7] cleaner timeout map key --- packages/react-router/src/link.tsx | 27 +++++++++-------------- packages/react-router/src/utils.ts | 6 ++--- packages/react-router/tests/link.test.tsx | 15 ++++++++++++- packages/solid-router/src/link.tsx | 23 +++++++++---------- packages/solid-router/src/utils.ts | 7 +++--- packages/vue-router/src/link.tsx | 27 ++++++++++------------- packages/vue-router/src/utils.ts | 7 +++--- 7 files changed, 58 insertions(+), 54 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index b4bbeeaf239..e94e9283a95 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -616,28 +616,21 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks const enqueuePreload = React.useCallback( - ( - e: - | React.MouseEvent - | React.FocusEvent - | IntersectionObserverEntry - | undefined, - ) => { + (e?: React.MouseEvent | React.FocusEvent | IntersectionObserverEntry) => { if (!e) { cancelPreload(innerRef) return } - const eventTarget = - (e as { currentTarget?: EventTarget | null }).currentTarget || innerRef - if ( !( (e as IntersectionObserverEntry).isIntersecting ?? preload === 'intent' ) ) { - cancelPreload(eventTarget) + if ((e as IntersectionObserverEntry).isIntersecting === false) { + cancelPreload(innerRef) + } return } @@ -646,14 +639,14 @@ export function useLinkProps< return } - if (timeoutMap.has(eventTarget)) { + if (timeoutMap.has(innerRef)) { return } timeoutMap.set( - eventTarget, + innerRef, setTimeout(() => { - timeoutMap.delete(eventTarget) + timeoutMap.delete(innerRef) doPreload() }, preloadDelay), ) @@ -739,8 +732,10 @@ export function useLinkProps< doPreload() } - const handleLeave = (e: React.MouseEvent | React.FocusEvent) => { - cancelPreload(e.currentTarget) + const handleLeave = () => { + if (preload === 'intent') { + cancelPreload(innerRef) + } } return { diff --git a/packages/react-router/src/utils.ts b/packages/react-router/src/utils.ts index 4a191efbefd..b650e343881 100644 --- a/packages/react-router/src/utils.ts +++ b/packages/react-router/src/utils.ts @@ -83,7 +83,7 @@ export function usePrevious(value: T): T | null { */ export function useIntersectionObserver( ref: React.RefObject, - callback: (entry: IntersectionObserverEntry | undefined) => void, + callback: (entry?: IntersectionObserverEntry) => void, disabled?: boolean, ) { React.useEffect(() => { @@ -92,7 +92,7 @@ export function useIntersectionObserver( disabled || typeof IntersectionObserver !== 'function' ) { - return + return () => callback() } const observer = new IntersectionObserver( @@ -106,7 +106,7 @@ export function useIntersectionObserver( return () => { observer.disconnect() - callback(undefined) + callback() } }, [callback, disabled, ref]) } diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 6191796a980..1c135361acf 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5250,6 +5250,9 @@ describe('Link', () => { const rootRoute = createRootRoute() const RouteComponent = () => { const [to, setTo] = React.useState<'/about' | '/other'>('/about') + const [preload, setPreload] = React.useState< + 'viewport' | 'intent' | false + >('viewport') return ( <> - + + + Viewport Link @@ -5344,6 +5349,14 @@ describe('Link', () => { expect(preloadRouteSpy).toHaveBeenCalledWith( expect.objectContaining({ to: '/about' }), ) + + preloadRouteSpy.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Use intent' })) + fireEvent.mouseEnter(viewportLink) + fireEvent.click(screen.getByRole('button', { name: 'Disable preload' })) + fireEvent.mouseLeave(viewportLink) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).not.toHaveBeenCalled() }) test("Router.preload='render', should trigger the route loader on render", async () => { diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 604119cf0af..babc8e15a7e 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -262,23 +262,22 @@ export function useLinkProps< const [ref, setRef] = Solid.createSignal(null) const enqueuePreload = ( - e: MouseEvent | FocusEvent | IntersectionObserverEntry | undefined, + e?: MouseEvent | FocusEvent | IntersectionObserverEntry, ) => { if (!e) { cancelPreload(ref) return } - const eventTarget = - (e as { currentTarget?: EventTarget | null }).currentTarget || ref - if ( !( (e as IntersectionObserverEntry).isIntersecting ?? preload() === 'intent' ) ) { - cancelPreload(eventTarget) + if ((e as IntersectionObserverEntry).isIntersecting === false) { + cancelPreload(ref) + } return } @@ -287,11 +286,11 @@ export function useLinkProps< return } - if (!timeoutMap.has(eventTarget)) { + if (!timeoutMap.has(ref)) { timeoutMap.set( - eventTarget, + ref, setTimeout(() => { - timeoutMap.delete(eventTarget) + timeoutMap.delete(ref) doPreload() }, preloadDelay()), ) @@ -378,11 +377,9 @@ export function useLinkProps< doPreload() } - const handleLeave = (e: MouseEvent | FocusEvent) => { - const eventTarget = e.currentTarget || e.target - - if (eventTarget) { - cancelPreload(eventTarget) + const handleLeave = () => { + if (preload() === 'intent') { + cancelPreload(ref) } } diff --git a/packages/solid-router/src/utils.ts b/packages/solid-router/src/utils.ts index fb5b99f016e..5d38caca31a 100644 --- a/packages/solid-router/src/utils.ts +++ b/packages/solid-router/src/utils.ts @@ -25,7 +25,7 @@ import * as Solid from 'solid-js' */ export function useIntersectionObserver( ref: Solid.Accessor, - callback: (entry: IntersectionObserverEntry | undefined) => void, + callback: (entry?: IntersectionObserverEntry) => void, disabled: Solid.Accessor, ): Solid.Accessor { const isIntersectionObserverAvailable = @@ -34,7 +34,8 @@ export function useIntersectionObserver( Solid.createEffect(() => { const r = ref() - if (!r || !isIntersectionObserverAvailable || disabled()) { + if (disabled() || !r || !isIntersectionObserverAvailable) { + Solid.onCleanup(() => callback()) return } @@ -49,7 +50,7 @@ export function useIntersectionObserver( Solid.onCleanup(() => { observerRef?.disconnect() - callback(undefined) + callback() }) }) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 96eee53a50e..f381577f98c 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -252,7 +252,7 @@ export function useLinkProps< }) const enqueuePreload = ( - e: MouseEvent | FocusEvent | IntersectionObserverEntry | undefined, + e?: MouseEvent | FocusEvent | IntersectionObserverEntry, ) => { if (!e) { clearTimeout(timeoutMap.get(ref)) @@ -260,17 +260,16 @@ export function useLinkProps< return } - const eventTarget = - (e as { currentTarget?: EventTarget | null }).currentTarget || ref - if ( !( (e as IntersectionObserverEntry).isIntersecting ?? preload.value === 'intent' ) ) { - clearTimeout(timeoutMap.get(eventTarget)) - timeoutMap.delete(eventTarget) + if ((e as IntersectionObserverEntry).isIntersecting === false) { + clearTimeout(timeoutMap.get(ref)) + timeoutMap.delete(ref) + } return } @@ -279,11 +278,11 @@ export function useLinkProps< return } - if (!timeoutMap.has(eventTarget)) { + if (!timeoutMap.has(ref)) { timeoutMap.set( - eventTarget, + ref, setTimeout(() => { - timeoutMap.delete(eventTarget) + timeoutMap.delete(ref) doPreload() }, preloadDelay.value), ) @@ -354,12 +353,10 @@ export function useLinkProps< doPreload() } - const handleLeave = (e: MouseEvent | FocusEvent) => { - const eventTarget = e.currentTarget || e.target - - if (eventTarget) { - clearTimeout(timeoutMap.get(eventTarget)) - timeoutMap.delete(eventTarget) + const handleLeave = () => { + if (preload.value === 'intent') { + clearTimeout(timeoutMap.get(ref)) + timeoutMap.delete(ref) } } diff --git a/packages/vue-router/src/utils.ts b/packages/vue-router/src/utils.ts index 0d5da9c4127..8787649d8bd 100644 --- a/packages/vue-router/src/utils.ts +++ b/packages/vue-router/src/utils.ts @@ -48,7 +48,7 @@ export const usePrevious = (fn: () => boolean) => { */ export function useIntersectionObserver( ref: Vue.Ref, - callback: (entry: IntersectionObserverEntry | undefined) => void, + callback: (entry?: IntersectionObserverEntry) => void, disabled: () => boolean, ): Vue.Ref { const isIntersectionObserverAvailable = @@ -58,7 +58,8 @@ export function useIntersectionObserver( // Use watchEffect with cleanup to properly manage the observer lifecycle Vue.watchEffect((onCleanup) => { const r = ref.value - if (!r || !isIntersectionObserverAvailable || disabled()) { + if (disabled() || !r || !isIntersectionObserverAvailable) { + onCleanup(() => callback()) return } @@ -75,7 +76,7 @@ export function useIntersectionObserver( onCleanup(() => { observer.disconnect() observerRef.value = null - callback(undefined) + callback() }) })