diff --git a/.changeset/tidy-links-wait.md b/.changeset/tidy-links-wait.md new file mode 100644 index 0000000000..f3c78612e0 --- /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 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 cf20e0ff19..9d3d3fe8d8 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 ends, or the link leaves the viewport 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 91f13e0e50..709d08c621 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 b2bb03ad0e..cdf06e0e43 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, 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 6ae9563f2f..7f48412ce6 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**. 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 fc33e6b594..e94e9283a9 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,33 +615,58 @@ 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) => { + if (!e) { + cancelPreload(innerRef) + return + } + + if ( + !( + (e as IntersectionObserverEntry).isIntersecting ?? + preload === 'intent' + ) + ) { + if ((e as IntersectionObserverEntry).isIntersecting === false) { + cancelPreload(innerRef) + } + return + } + + if (!preloadDelay) { doPreload() + return } + + if (timeoutMap.has(innerRef)) { + return + } + + timeoutMap.set( + innerRef, + setTimeout(() => { + timeoutMap.delete(innerRef) + doPreload() + }, preloadDelay), + ) }, - [doPreload], + [doPreload, innerRef, preload, preloadDelay], ) // eslint-disable-next-line react-hooks/rules-of-hooks - useIntersectionObserver( - innerRef, - preloadViewportIoCallback, - intersectionObserverOptions, - !!disabled || preload !== 'viewport', - ) + useIntersectionObserver(innerRef, enqueuePreload, preload !== 'viewport') // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { 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,39 +727,14 @@ 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) { - clearTimeout(id) - timeoutMap.delete(eventTarget) + const handleLeave = () => { + if (preload === 'intent') { + cancelPreload(innerRef) } } @@ -746,8 +746,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,10 +766,10 @@ 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 intersectionObserverOptions: IntersectionObserverInit = { - rootMargin: '100px', +const timeoutMap = new WeakMap>() +const cancelPreload = (eventTarget: object) => { + clearTimeout(timeoutMap.get(eventTarget)) + timeoutMap.delete(eventTarget) } const composeHandlers = @@ -955,7 +955,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/src/utils.ts b/packages/react-router/src/utils.ts index 60808bceb1..b650e34388 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
@@ -85,8 +83,7 @@ export function usePrevious(value: T): T | null { */ export function useIntersectionObserver( ref: React.RefObject, - callback: (entry: IntersectionObserverEntry | undefined) => void, - intersectionObserverOptions: IntersectionObserverInit = {}, + callback: (entry?: IntersectionObserverEntry) => void, disabled?: boolean, ) { React.useEffect(() => { @@ -95,19 +92,23 @@ export function useIntersectionObserver( disabled || typeof IntersectionObserver !== 'function' ) { - return + return () => callback() } - const observer = new IntersectionObserver(([entry]) => { - callback(entry) - }, intersectionObserverOptions) + const observer = new IntersectionObserver( + (entries) => { + callback(entries.pop()) + }, + { rootMargin: '100px' }, + ) observer.observe(ref.current) return () => { observer.disconnect() + callback() } - }, [callback, disabled, intersectionObserverOptions, ref]) + }, [callback, disabled, ref]) } /** diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index e9ff839d63..1c135361ac 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,241 @@ 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() + + 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( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} 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(1) + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) + + fireEvent.mouseEnter(intentLink) + fireEvent.mouseLeave(intentLink) + await vi.advanceTimersByTimeAsync(50) + 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') + const [preload, setPreload] = React.useState< + 'viewport' | 'intent' | false + >('viewport') + 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' }), + ) + + 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 () => { const mock = vi.fn() diff --git a/packages/react-router/tests/utils.ts b/packages/react-router/tests/utils.ts index be5d60819b..da96eb2d95 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 7ec148b8f4..799ae50811 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 ends, or the link + * leaves the viewport 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 cc83e358de..75c47a1c2e 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 0b2d101d40..babc8e15a7 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -31,7 +31,11 @@ import type { ValidateLinkOptionsArray, } from './typePrimitives' -const timeoutMap = new WeakMap>() +const timeoutMap = new WeakMap>() +const cancelPreload = (eventTarget: object) => { + clearTimeout(timeoutMap.get(eventTarget)) + timeoutMap.delete(eventTarget) +} export function useLinkProps< TRouter extends AnyRouter = RegisteredRouter, @@ -190,7 +194,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,28 +259,51 @@ export function useLinkProps< console.warn(preloadWarning) }) - const preloadViewportIoCallback = ( - entry: IntersectionObserverEntry | undefined, + const [ref, setRef] = Solid.createSignal(null) + + const enqueuePreload = ( + e?: MouseEvent | FocusEvent | IntersectionObserverEntry, ) => { - if (entry?.isIntersecting) { + if (!e) { + cancelPreload(ref) + return + } + + if ( + !( + (e as IntersectionObserverEntry).isIntersecting ?? + preload() === 'intent' + ) + ) { + if ((e as IntersectionObserverEntry).isIntersecting === false) { + cancelPreload(ref) + } + return + } + + if (!preloadDelay()) { doPreload() + return } - } - const [ref, setRef] = Solid.createSignal(null) + if (!timeoutMap.has(ref)) { + timeoutMap.set( + ref, + setTimeout(() => { + timeoutMap.delete(ref) + doPreload() + }, preloadDelay()), + ) + } + } - useIntersectionObserver( - ref, - preloadViewportIoCallback, - { rootMargin: '100px' }, - !!local.disabled || preload() !== 'viewport', - ) + useIntersectionObserver(ref, enqueuePreload, () => preload() !== 'viewport') Solid.createEffect(() => { if (hasRenderFetched) { return } - if (!local.disabled && preload() === 'render') { + if (preload() === 'render') { doPreload() hasRenderFetched = true } @@ -345,40 +372,14 @@ 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) { - const id = timeoutMap.get(eventTarget) - clearTimeout(id) - timeoutMap.delete(eventTarget) + const handleLeave = () => { + if (preload() === 'intent') { + cancelPreload(ref) } } @@ -392,17 +393,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/src/utils.ts b/packages/solid-router/src/utils.ts index cac0e28aaa..5d38caca31 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
@@ -27,9 +25,8 @@ import * as Solid from 'solid-js' */ export function useIntersectionObserver( ref: Solid.Accessor, - callback: (entry: IntersectionObserverEntry | undefined) => void, - intersectionObserverOptions: IntersectionObserverInit = {}, - disabled?: boolean, + callback: (entry?: IntersectionObserverEntry) => void, + disabled: Solid.Accessor, ): Solid.Accessor { const isIntersectionObserverAvailable = typeof IntersectionObserver === 'function' @@ -37,18 +34,23 @@ export function useIntersectionObserver( Solid.createEffect(() => { const r = ref() - if (!r || !isIntersectionObserverAvailable || disabled) { + if (disabled() || !r || !isIntersectionObserverAvailable) { + Solid.onCleanup(() => callback()) return } - observerRef = new IntersectionObserver(([entry]) => { - callback(entry) - }, intersectionObserverOptions) + observerRef = new IntersectionObserver( + (entries) => { + callback(entries.pop()) + }, + { rootMargin: '100px' }, + ) observerRef.observe(r) Solid.onCleanup(() => { observerRef?.disconnect() + callback() }) }) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 7642030465..968d264273 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,128 @@ 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() + + 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( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} 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(1) + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) + + fireEvent.mouseEnter(intentLink) + fireEvent.mouseLeave(intentLink) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) + }) + 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 6c11bea20a..668cc9deb6 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 75c1901e4e..f381577f98 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,55 @@ export function useLinkProps< console.warn(preloadWarning) }) - const preloadViewportIoCallback = ( - entry: IntersectionObserverEntry | undefined, + const enqueuePreload = ( + e?: MouseEvent | FocusEvent | IntersectionObserverEntry, ) => { - if (entry?.isIntersecting) { + if (!e) { + clearTimeout(timeoutMap.get(ref)) + timeoutMap.delete(ref) + return + } + + if ( + !( + (e as IntersectionObserverEntry).isIntersecting ?? + preload.value === 'intent' + ) + ) { + if ((e as IntersectionObserverEntry).isIntersecting === false) { + clearTimeout(timeoutMap.get(ref)) + timeoutMap.delete(ref) + } + return + } + + if (!preloadDelay.value) { doPreload() + return + } + + if (!timeoutMap.has(ref)) { + timeoutMap.set( + ref, + setTimeout(() => { + timeoutMap.delete(ref) + doPreload() + }, preloadDelay.value), + ) } } useIntersectionObserver( ref, - preloadViewportIoCallback, - { rootMargin: '100px' }, - () => !!options.disabled || preload.value !== 'viewport', + enqueuePreload, + () => preload.value !== 'viewport', ) Vue.effect(() => { if (hasRenderFetched) { return } - if (!options.disabled && preload.value === 'render') { + if (preload.value === 'render') { doPreload() hasRenderFetched = true } @@ -319,40 +348,15 @@ 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) { - const id = timeoutMap.get(eventTarget) - clearTimeout(id) - timeoutMap.delete(eventTarget) + const handleLeave = () => { + if (preload.value === 'intent') { + clearTimeout(timeoutMap.get(ref)) + timeoutMap.delete(ref) } } @@ -384,15 +388,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/src/utils.ts b/packages/vue-router/src/utils.ts index 0633847db6..8787649d8b 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
@@ -50,8 +48,7 @@ export const usePrevious = (fn: () => boolean) => { */ export function useIntersectionObserver( ref: Vue.Ref, - callback: (entry: IntersectionObserverEntry | undefined) => void, - intersectionObserverOptions: IntersectionObserverInit = {}, + callback: (entry?: IntersectionObserverEntry) => void, disabled: () => boolean, ): Vue.Ref { const isIntersectionObserverAvailable = @@ -61,13 +58,17 @@ 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 } - const observer = new IntersectionObserver(([entry]) => { - callback(entry) - }, intersectionObserverOptions) + const observer = new IntersectionObserver( + (entries) => { + callback(entries.pop()) + }, + { rootMargin: '100px' }, + ) observerRef.value = observer observer.observe(r) @@ -75,6 +76,7 @@ export function useIntersectionObserver( onCleanup(() => { observer.disconnect() observerRef.value = null + callback() }) }) diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index 8d9e4a67ac..4fd46fb4d4 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,128 @@ 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() + + 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( + [ + { + isIntersecting: true, + target: viewportLink, + } as unknown as IntersectionObserverEntry, + ], + {} 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(1) + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) + + fireEvent.mouseEnter(intentLink) + fireEvent.mouseLeave(intentLink) + await vi.advanceTimersByTimeAsync(50) + expect(preloadRouteSpy).toHaveBeenCalledTimes(2) + }) + test('Link.disabled should disable viewport observation', async () => { const rootRoute = createRootRoute() const indexRoute = createRoute({ @@ -5092,7 +5219,6 @@ describe('Link', () => { useIntersectionObserver( element, () => {}, - {}, () => disabled.value, ) diff --git a/packages/vue-router/tests/utils.ts b/packages/vue-router/tests/utils.ts index be5d60819b..da96eb2d95 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'