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
421e0f56c2
commit
0c9ba32c1e
@ -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"}
|
||||||
|
@ -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
|
||||||
|
@ -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`)
|
||||||
|
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