mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 15:15:19 +00:00
feat(nuxt): allow defining triggers for prefetching links (#27846)
This commit is contained in:
parent
03ce7c63b3
commit
dba2b972bd
@ -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
|
||||
- `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"}
|
||||
|
@ -59,6 +59,13 @@ export interface NuxtLinkProps extends Omit<RouterLinkProps, 'to'> {
|
||||
* 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<RouterLinkProps, 'to'> {
|
||||
*/
|
||||
export interface NuxtLinkOptions extends
|
||||
Partial<Pick<RouterLinkProps, 'activeClass' | 'exactActiveClass'>>,
|
||||
Partial<Pick<NuxtLinkProps, 'prefetchedClass'>> {
|
||||
Partial<Pick<NuxtLinkProps, 'prefetch' | 'prefetchedClass'>> {
|
||||
/**
|
||||
* 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<NuxtLinkProps['prefetchOn'], string>
|
||||
}
|
||||
|
||||
/* @__NO_SIDE_EFFECTS__ */
|
||||
@ -239,6 +251,14 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
prefetchOn: {
|
||||
type: [String, Object] as PropType<NuxtLinkProps['prefetchOn']>,
|
||||
default: options.prefetchOn || {
|
||||
visibility: true,
|
||||
interaction: false,
|
||||
} satisfies NuxtLinkProps['prefetchOn'],
|
||||
required: false,
|
||||
},
|
||||
noPrefetch: {
|
||||
type: Boolean as PropType<NuxtLinkProps['noPrefetch']>,
|
||||
default: undefined,
|
||||
@ -299,10 +319,27 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
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 }
|
||||
|
||||
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
|
||||
|
@ -437,6 +437,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`)
|
||||
|
95
test/nuxt/components.test.ts
Normal file
95
test/nuxt/components.test.ts
Normal 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 }
|
||||
}
|
Loading…
Reference in New Issue
Block a user