feat(nuxt): allow defining triggers for prefetching links (#27846)

This commit is contained in:
Kewin Szlezingier 2024-08-19 16:50:20 +02:00 committed by GitHub
parent 421e0f56c2
commit 0c9ba32c1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 152 additions and 14 deletions

View File

@ -124,6 +124,7 @@ When not using `external`, `<NuxtLink>` supports all Vue Router's [`RouterLink`
- `noRel`: If set to `true`, no `rel` attribute will be added to the link - `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`. - `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. - `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. - `noPrefetch`: Disables prefetching.
- `prefetchedClass`: A class to apply to links that have been prefetched. - `prefetchedClass`: A class to apply to links that have been prefetched.
@ -185,8 +186,13 @@ interface NuxtLinkOptions {
externalRelAttribute?: string; externalRelAttribute?: string;
activeClass?: string; activeClass?: string;
exactActiveClass?: string; exactActiveClass?: string;
prefetchedClass?: string;
trailingSlash?: 'append' | 'remove' trailingSlash?: 'append' | 'remove'
prefetch?: boolean
prefetchedClass?: string
prefetchOn?: Partial<{
visibility: boolean
interaction: boolean
}>
} }
function defineNuxtLink(options: NuxtLinkOptions): Component {} 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 - `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"`) - `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"`) - `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. - `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"} :link-example{to="/docs/examples/routing/pages"}

View File

@ -59,6 +59,13 @@ export interface NuxtLinkProps extends Omit<RouterLinkProps, 'to'> {
* When enabled will prefetch middleware, layouts and payloads of links in the viewport. * When enabled will prefetch middleware, layouts and payloads of links in the viewport.
*/ */
prefetch?: boolean 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. * Escape hatch to disable `prefetch` attribute.
*/ */
@ -71,7 +78,7 @@ export interface NuxtLinkProps extends Omit<RouterLinkProps, 'to'> {
*/ */
export interface NuxtLinkOptions extends export interface NuxtLinkOptions extends
Partial<Pick<RouterLinkProps, 'activeClass' | 'exactActiveClass'>>, Partial<Pick<RouterLinkProps, 'activeClass' | 'exactActiveClass'>>,
Partial<Pick<NuxtLinkProps, 'prefetchedClass'>> { Partial<Pick<NuxtLinkProps, 'prefetch' | 'prefetchedClass'>> {
/** /**
* The name of the component. * The name of the component.
* @default "NuxtLink" * @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. * If unset or not matching the valid values `append` or `remove`, it will be ignored.
*/ */
trailingSlash?: 'append' | 'remove' trailingSlash?: 'append' | 'remove'
/**
* Allows controlling default setting for when to prefetch links. By default, prefetch is triggered only on visibility.
*/
prefetchOn?: Exclude<NuxtLinkProps['prefetchOn'], string>
} }
/* @__NO_SIDE_EFFECTS__ */ /* @__NO_SIDE_EFFECTS__ */
@ -239,6 +251,14 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
default: undefined, default: undefined,
required: false, required: false,
}, },
prefetchOn: {
type: [String, Object] as PropType<NuxtLinkProps['prefetchOn']>,
default: options.prefetchOn || {
visibility: true,
interaction: false,
} satisfies NuxtLinkProps['prefetchOn'],
required: false,
},
noPrefetch: { noPrefetch: {
type: Boolean as PropType<NuxtLinkProps['noPrefetch']>, type: Boolean as PropType<NuxtLinkProps['noPrefetch']>,
default: undefined, default: undefined,
@ -299,10 +319,27 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
const el = import.meta.server ? undefined : ref<HTMLElement | null>(null) const el = import.meta.server ? undefined : ref<HTMLElement | null>(null)
const elRef = import.meta.server ? undefined : (ref: any) => { el!.value = props.custom ? ref?.$el?.nextElementSibling : ref?.$el } 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) { if (import.meta.client) {
checkPropConflicts(props, 'prefetch', 'noPrefetch') checkPropConflicts(props, 'prefetch', 'noPrefetch')
const shouldPrefetch = props.prefetch !== false && props.noPrefetch !== true && props.target !== '_blank' && !isSlowConnection() if (shouldPrefetch('visibility')) {
if (shouldPrefetch) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
let idleId: number let idleId: number
let unobserve: (() => void) | null = null let unobserve: (() => void) | null = null
@ -314,15 +351,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
unobserve = observer!.observe(el.value as HTMLElement, async () => { unobserve = observer!.observe(el.value as HTMLElement, async () => {
unobserve?.() unobserve?.()
unobserve = null unobserve = null
await prefetch(nuxtApp)
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
}) })
} }
}) })
@ -355,6 +384,8 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
replace: props.replace, replace: props.replace,
ariaCurrentValue: props.ariaCurrentValue, ariaCurrentValue: props.ariaCurrentValue,
custom: props.custom, 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 // `custom` API cannot support fallthrough attributes as the slot

View File

@ -345,6 +345,10 @@ export default defineUntypedSchema({
/** @type {typeof import('#app/components/nuxt-link')['NuxtLinkOptions']} */ /** @type {typeof import('#app/components/nuxt-link')['NuxtLinkOptions']} */
nuxtLink: { nuxtLink: {
componentName: 'NuxtLink', componentName: 'NuxtLink',
prefetch: true,
prefetchOn: {
visibility: true,
},
}, },
/** /**
* Options that apply to `useAsyncData` (and also therefore `useFetch`) * Options that apply to `useAsyncData` (and also therefore `useFetch`)

View File

@ -0,0 +1,95 @@
/// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" />
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 }
}