From 0c9ba32c1ee5aece43e569bfc2d10c2ae20c68e9 Mon Sep 17 00:00:00 2001 From: Kewin Szlezingier <77052270+kewinzaq1@users.noreply.github.com> Date: Mon, 19 Aug 2024 16:50:20 +0200 Subject: [PATCH] feat(nuxt): allow defining triggers for prefetching links (#27846) --- docs/3.api/1.components/4.nuxt-link.md | 12 ++- packages/nuxt/src/app/components/nuxt-link.ts | 55 ++++++++--- packages/schema/src/config/experimental.ts | 4 + test/nuxt/components.test.ts | 95 +++++++++++++++++++ 4 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 test/nuxt/components.test.ts diff --git a/docs/3.api/1.components/4.nuxt-link.md b/docs/3.api/1.components/4.nuxt-link.md index 79e069c21d..d21b798345 100644 --- a/docs/3.api/1.components/4.nuxt-link.md +++ b/docs/3.api/1.components/4.nuxt-link.md @@ -124,6 +124,7 @@ When not using `external`, `` supports all Vue Router's [`RouterLink` - `noRel`: If set to `true`, no `rel` attribute will be added to the link - `external`: Forces the link to be rendered as an `a` tag instead of a Vue Router `RouterLink`. - `prefetch`: When enabled will prefetch middleware, layouts and payloads (when using [payloadExtraction](/docs/api/nuxt-config#crossoriginprefetch)) of links in the viewport. Used by the experimental [crossOriginPrefetch](/docs/api/nuxt-config#crossoriginprefetch) config. +- `prefetchOn`: Allows custom control of when to prefetch links. Possible options are `interaction` and `visibility` (default). You can also pass an object for full control, for example: `{ interaction: true, visibility: true }`. This prop is only used when `prefetch` is enabled (default) and `noPrefetch` is not set. - `noPrefetch`: Disables prefetching. - `prefetchedClass`: A class to apply to links that have been prefetched. @@ -185,8 +186,13 @@ interface NuxtLinkOptions { externalRelAttribute?: string; activeClass?: string; exactActiveClass?: string; - prefetchedClass?: string; trailingSlash?: 'append' | 'remove' + prefetch?: boolean + prefetchedClass?: string + prefetchOn?: Partial<{ + visibility: boolean + interaction: boolean + }> } function defineNuxtLink(options: NuxtLinkOptions): Component {} ``` @@ -195,7 +201,9 @@ function defineNuxtLink(options: NuxtLinkOptions): Component {} - `externalRelAttribute`: A default `rel` attribute value applied on external links. Defaults to `"noopener noreferrer"`. Set it to `""` to disable - `activeClass`: A default class to apply on active links. Works the same as [Vue Router's `linkActiveClass` option](https://router.vuejs.org/api/interfaces/RouterOptions.html#Properties-linkActiveClass). Defaults to Vue Router's default (`"router-link-active"`) - `exactActiveClass`: A default class to apply on exact active links. Works the same as [Vue Router's `linkExactActiveClass` option](https://router.vuejs.org/api/interfaces/RouterOptions.html#Properties-linkExactActiveClass). Defaults to Vue Router's default (`"router-link-exact-active"`) -- `prefetchedClass`: A default class to apply to links that have been prefetched. - `trailingSlash`: An option to either add or remove trailing slashes in the `href`. If unset or not matching the valid values `append` or `remove`, it will be ignored. +- `prefetch`: Whether or not to prefetch links by default. +- `prefetchOn`: Granular control of which prefetch strategies to apply by default. +- `prefetchedClass`: A default class to apply to links that have been prefetched. :link-example{to="/docs/examples/routing/pages"} diff --git a/packages/nuxt/src/app/components/nuxt-link.ts b/packages/nuxt/src/app/components/nuxt-link.ts index 0b5d1c6511..3ed5722650 100644 --- a/packages/nuxt/src/app/components/nuxt-link.ts +++ b/packages/nuxt/src/app/components/nuxt-link.ts @@ -59,6 +59,13 @@ export interface NuxtLinkProps extends Omit { * When enabled will prefetch middleware, layouts and payloads of links in the viewport. */ prefetch?: boolean + /** + * Allows controlling when to prefetch links. By default, prefetch is triggered only on visibility. + */ + prefetchOn?: 'visibility' | 'interaction' | Partial<{ + visibility: boolean + interaction: boolean + }> /** * Escape hatch to disable `prefetch` attribute. */ @@ -71,7 +78,7 @@ export interface NuxtLinkProps extends Omit { */ export interface NuxtLinkOptions extends Partial>, - Partial> { + Partial> { /** * The name of the component. * @default "NuxtLink" @@ -86,6 +93,11 @@ export interface NuxtLinkOptions extends * If unset or not matching the valid values `append` or `remove`, it will be ignored. */ trailingSlash?: 'append' | 'remove' + + /** + * Allows controlling default setting for when to prefetch links. By default, prefetch is triggered only on visibility. + */ + prefetchOn?: Exclude } /* @__NO_SIDE_EFFECTS__ */ @@ -239,6 +251,14 @@ export function defineNuxtLink (options: NuxtLinkOptions) { default: undefined, required: false, }, + prefetchOn: { + type: [String, Object] as PropType, + default: options.prefetchOn || { + visibility: true, + interaction: false, + } satisfies NuxtLinkProps['prefetchOn'], + required: false, + }, noPrefetch: { type: Boolean as PropType, default: undefined, @@ -299,10 +319,27 @@ export function defineNuxtLink (options: NuxtLinkOptions) { const el = import.meta.server ? undefined : ref(null) const elRef = import.meta.server ? undefined : (ref: any) => { el!.value = props.custom ? ref?.$el?.nextElementSibling : ref?.$el } + function shouldPrefetch (mode: 'visibility' | 'interaction') { + return !prefetched.value && (typeof props.prefetchOn === 'string' ? props.prefetchOn === mode : (props.prefetchOn?.[mode] ?? options.prefetchOn?.[mode])) && (props.prefetch ?? options.prefetch) !== false && props.noPrefetch !== true && props.target !== '_blank' && !isSlowConnection() + } + + async function prefetch (nuxtApp = useNuxtApp()) { + if (prefetched.value) { return } + + prefetched.value = true + + const path = typeof to.value === 'string' + ? to.value + : isExternal.value ? resolveRouteObject(to.value) : router.resolve(to.value).fullPath + await Promise.all([ + nuxtApp.hooks.callHook('link:prefetch', path).catch(() => {}), + !isExternal.value && !hasTarget.value && preloadRouteComponents(to.value as string, router).catch(() => {}), + ]) + } + if (import.meta.client) { checkPropConflicts(props, 'prefetch', 'noPrefetch') - const shouldPrefetch = props.prefetch !== false && props.noPrefetch !== true && props.target !== '_blank' && !isSlowConnection() - if (shouldPrefetch) { + if (shouldPrefetch('visibility')) { const nuxtApp = useNuxtApp() let idleId: number let unobserve: (() => void) | null = null @@ -314,15 +351,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) { unobserve = observer!.observe(el.value as HTMLElement, async () => { unobserve?.() unobserve = null - - const path = typeof to.value === 'string' - ? to.value - : isExternal.value ? resolveRouteObject(to.value) : router.resolve(to.value).fullPath - await Promise.all([ - nuxtApp.hooks.callHook('link:prefetch', path).catch(() => {}), - !isExternal.value && !hasTarget.value && preloadRouteComponents(to.value as string, router).catch(() => {}), - ]) - prefetched.value = true + await prefetch(nuxtApp) }) } }) @@ -355,6 +384,8 @@ export function defineNuxtLink (options: NuxtLinkOptions) { replace: props.replace, ariaCurrentValue: props.ariaCurrentValue, custom: props.custom, + onPointerenter: shouldPrefetch('interaction') ? prefetch.bind(null, undefined) : undefined, + onFocus: shouldPrefetch('interaction') ? prefetch.bind(null, undefined) : undefined, } // `custom` API cannot support fallthrough attributes as the slot diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 88724631b0..1e5aaf6b8a 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -345,6 +345,10 @@ export default defineUntypedSchema({ /** @type {typeof import('#app/components/nuxt-link')['NuxtLinkOptions']} */ nuxtLink: { componentName: 'NuxtLink', + prefetch: true, + prefetchOn: { + visibility: true, + }, }, /** * Options that apply to `useAsyncData` (and also therefore `useFetch`) diff --git a/test/nuxt/components.test.ts b/test/nuxt/components.test.ts new file mode 100644 index 0000000000..a5c0afdc2d --- /dev/null +++ b/test/nuxt/components.test.ts @@ -0,0 +1,95 @@ +/// + +import { describe, expect, it, vi } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' + +import { nuxtLinkDefaults } from '#build/nuxt.config.mjs' + +describe('nuxt-link:prefetch', () => { + it('should prefetch on visibility by default', async () => { + const component = defineNuxtLink(nuxtLinkDefaults) + + const { observer } = useMockObserver() + + const nuxtApp = useNuxtApp() + nuxtApp.hooks.callHook = vi.fn(() => Promise.resolve()) + + await mountSuspended(component, { props: { to: '/to' } }) + + expect(nuxtApp.hooks.callHook).not.toHaveBeenCalled() + + await observer.trigger() + expect(nuxtApp.hooks.callHook).toHaveBeenCalledTimes(1) + + await observer.trigger() + expect(nuxtApp.hooks.callHook).toHaveBeenCalledTimes(1) + }) + + it('should prefetch with custom string `prefetchOn`', async () => { + const component = defineNuxtLink(nuxtLinkDefaults) + const nuxtApp = useNuxtApp() + nuxtApp.hooks.callHook = vi.fn(() => Promise.resolve()) + + const { observer } = useMockObserver() + const wrapper = await mountSuspended(component, { props: { to: '/to', prefetchOn: 'interaction' } }) + + await observer.trigger() + expect(nuxtApp.hooks.callHook).not.toHaveBeenCalled() + + await wrapper.find('a').trigger('focus') + expect(nuxtApp.hooks.callHook).toHaveBeenCalledTimes(1) + + await wrapper.find('a').trigger('focus') + expect(nuxtApp.hooks.callHook).toHaveBeenCalledTimes(1) + + await wrapper.find('a').trigger('pointerenter') + expect(nuxtApp.hooks.callHook).toHaveBeenCalledTimes(1) + }) + + it('should prefetch with custom object `prefetchOn`', async () => { + const component = defineNuxtLink(nuxtLinkDefaults) + const nuxtApp = useNuxtApp() + nuxtApp.hooks.callHook = vi.fn(() => Promise.resolve()) + + const { observer } = useMockObserver() + await mountSuspended(component, { props: { to: '/to', prefetchOn: { interaction: true } } }) + + await observer.trigger() + expect(nuxtApp.hooks.callHook).toHaveBeenCalled() + }) + + it('should prefetch with custom object `prefetchOn` overriding default', async () => { + const component = defineNuxtLink(nuxtLinkDefaults) + const nuxtApp = useNuxtApp() + nuxtApp.hooks.callHook = vi.fn(() => Promise.resolve()) + + const { observer } = useMockObserver() + await mountSuspended(component, { props: { to: '/to', prefetchOn: { interaction: true, visibility: false } } }) + + await observer.trigger() + expect(nuxtApp.hooks.callHook).not.toHaveBeenCalled() + }) +}) + +function useMockObserver () { + let callback: (entries: Array<{ target: HTMLElement, isIntersecting: boolean }>) => unknown + let el: HTMLElement + const mockObserver = class IntersectionObserver { + el: HTMLElement + constructor (_callback?: (entries: Array<{ target: HTMLElement, isIntersecting: boolean }>) => unknown) { + callback ||= _callback + } + + observe = (_el: HTMLElement) => { el = _el } + + trigger = () => callback?.([{ target: el, isIntersecting: true }]) + unobserve = () => {} + disconnect = () => {} + } + + window.IntersectionObserver = mockObserver as any + + const observer = new mockObserver() + + return { observer } +}