Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

Commit

Permalink
feat(nuxt): support prefetching <nuxt-link> (#4329)
Browse files Browse the repository at this point in the history
Co-authored-by: Pooya Parsa <pooya@pi0.io>
  • Loading branch information
Mini-ghost and pi0 committed Sep 13, 2022
1 parent 65481d4 commit addcb5c
Show file tree
Hide file tree
Showing 7 changed files with 188 additions and 23 deletions.
4 changes: 4 additions & 0 deletions docs/content/3.api/2.components/4.nuxt-link.md
Expand Up @@ -78,6 +78,8 @@ In this example, we use `<NuxtLink>` with `target`, `rel`, and `noRel` props.
- **replace**: Works the same as [Vue Router's `replace` prop](https://router.vuejs.org/api/#replace) on internal links
- **ariaCurrentValue**: An `aria-current` attribute value to apply on exact active links. Works the same as [Vue Router's `aria-current-value` prop](https://router.vuejs.org/api/#aria-current-value) on internal links
- **external**: Forces the link to be considered as external (`true`) or internal (`false`). This is helpful to handle edge-cases
- **prefetch** and **noPrefetch**: Whether to enable prefetching assets for links that enter the view port.
- **prefetchedClass**: A class to apply to links that have been prefetched.
- **custom**: Whether `<NuxtLink>` should wrap its content in an `<a>` element. It allows taking full control of how a link is rendered and how navigation works when it is clicked. Works the same as [Vue Router's `custom` prop](https://router.vuejs.org/api/#custom)

::alert{icon=👉}
Expand Down Expand Up @@ -107,12 +109,14 @@ defineNuxtLink({
externalRelAttribute?: string;
activeClass?: string;
exactActiveClass?: string;
prefetchedClass?: string;
}) => Component
```

- **componentName**: A name for the defined `<NuxtLink>` 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/#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/#linkexactactiveclass). Defaults to Vue Router's default (`"router-link-exact-active"`)
- **prefetchedClass**: A default class to apply to links that have been prefetched.

:LinkExample{link="/examples/routing/nuxt-link"}
3 changes: 2 additions & 1 deletion docs/content/3.api/4.advanced/1.hooks.md
Expand Up @@ -18,7 +18,8 @@ Hook | Arguments | Environment | Description
`app:redirected` | - | Server | Called before SSR redirection.
`app:beforeMount` | `vueApp` | Client | Called before mounting the app, called only on client side.
`app:mounted` | `vueApp` | Client | Called when Vue app is initialized and mounted in browser.
`app:suspense:resolve` | `appComponent` | Client | On [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event
`app:suspense:resolve` | `appComponent` | Client | On [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event.
`link:prefetch` | `to` | Client | Called when a `<NuxtLink>` is observed to be prefetched.
`page:start` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) pending event.
`page:finish` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event.

Expand Down
153 changes: 149 additions & 4 deletions packages/nuxt/src/app/components/nuxt-link.ts
@@ -1,8 +1,8 @@
import { defineComponent, h, resolveComponent, PropType, computed, DefineComponent, ComputedRef } from 'vue'
import { RouteLocationRaw } from 'vue-router'
import { defineComponent, h, ref, resolveComponent, PropType, computed, DefineComponent, ComputedRef, onMounted, onBeforeUnmount } from 'vue'
import { RouteLocationRaw, Router } from 'vue-router'
import { hasProtocol } from 'ufo'

import { navigateTo, useRouter } from '#app'
import { navigateTo, useRouter, useNuxtApp } from '#app'

const firstNonUndefined = <T>(...args: (T | undefined)[]) => args.find(arg => arg !== undefined)

Expand All @@ -13,6 +13,7 @@ export type NuxtLinkOptions = {
externalRelAttribute?: string | null
activeClass?: string
exactActiveClass?: string
prefetchedClass?: string
}

export type NuxtLinkProps = {
Expand All @@ -28,13 +29,33 @@ export type NuxtLinkProps = {
rel?: string | null
noRel?: boolean

prefetch?: boolean
noPrefetch?: boolean

// Styling
activeClass?: string
exactActiveClass?: string

// Vue Router's `<RouterLink>` additional props
ariaCurrentValue?: string
};
}

// Polyfills for Safari support
// https://caniuse.com/requestidlecallback
const requestIdleCallback: Window['requestIdleCallback'] = process.server
? undefined as any
: (globalThis.requestIdleCallback || ((cb) => {
const start = Date.now()
const idleDeadline = {
didTimeout: false,
timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
}
return setTimeout(() => { cb(idleDeadline) }, 1)
}))

const cancelIdleCallback: Window['cancelIdleCallback'] = process.server
? null as any
: (globalThis.cancelIdleCallback || ((id) => { clearTimeout(id) }))

export function defineNuxtLink (options: NuxtLinkOptions) {
const componentName = options.componentName || 'NuxtLink'
Expand Down Expand Up @@ -77,6 +98,18 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
required: false
},

// Prefetching
prefetch: {
type: Boolean as PropType<boolean>,
default: undefined,
required: false
},
noPrefetch: {
type: Boolean as PropType<boolean>,
default: undefined,
required: false
},

// Styling
activeClass: {
type: String as PropType<string>,
Expand All @@ -88,6 +121,11 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
default: undefined,
required: false
},
prefetchedClass: {
type: String as PropType<string>,
default: undefined,
required: false
},

// Vue Router's `<RouterLink>` additional props
replace: {
Expand Down Expand Up @@ -145,13 +183,49 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
return to.value === '' || hasProtocol(to.value, true)
})

// Prefetching
const prefetched = ref(false)
const el = process.server ? undefined : ref<HTMLElement | null>(null)
if (process.client) {
checkPropConflicts(props, 'prefetch', 'noPrefetch')
const shouldPrefetch = props.prefetch !== false && props.noPrefetch !== true && typeof to.value === 'string' && !isSlowConnection()
if (shouldPrefetch) {
const nuxtApp = useNuxtApp()
const observer = useObserver()
let idleId: number
let unobserve: Function | null = null
onMounted(() => {
idleId = requestIdleCallback(() => {
if (el?.value) {
unobserve = observer!.observe(el.value, async () => {
unobserve?.()
unobserve = null
await Promise.all([
nuxtApp.hooks.callHook('link:prefetch', to.value as string).catch(() => {}),
preloadRouteComponents(to.value as string, router).catch(() => {})
])
prefetched.value = true
})
}
})
})
onBeforeUnmount(() => {
if (idleId) { cancelIdleCallback(idleId) }
unobserve?.()
unobserve = null
})
}
}

return () => {
if (!isExternal.value) {
// Internal link
return h(
resolveComponent('RouterLink'),
{
ref: process.server ? undefined : (ref: any) => { el!.value = ref?.$el },
to: to.value,
class: prefetched.value && (props.prefetchedClass || options.prefetchedClass),
activeClass: props.activeClass || options.activeClass,
exactActiveClass: props.exactActiveClass || options.exactActiveClass,
replace: props.replace,
Expand Down Expand Up @@ -201,3 +275,74 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
}

export default defineNuxtLink({ componentName: 'NuxtLink' })

// --- Prefetching utils ---

function useObserver () {
if (process.server) { return }

const nuxtApp = useNuxtApp()
if (nuxtApp._observer) {
return nuxtApp._observer
}

let observer: IntersectionObserver | null = null
type CallbackFn = () => void
const callbacks = new Map<Element, CallbackFn>()

const observe = (element: Element, callback: CallbackFn) => {
if (!observer) {
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const callback = callbacks.get(entry.target)
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0
if (isVisible && callback) { callback() }
}
})
}
callbacks.set(element, callback)
observer.observe(element)
return () => {
callbacks.delete(element)
observer!.unobserve(element)
if (callbacks.size === 0) {
observer!.disconnect()
observer = null
}
}
}

const _observer = nuxtApp._observer = {
observe
}

return _observer
}

function isSlowConnection () {
if (process.server) { return }

// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection
const cn = (navigator as any).connection as { saveData: boolean, effectiveType: string } | null
if (cn && (cn.saveData || /2g/.test(cn.effectiveType))) { return true }
return false
}

async function preloadRouteComponents (to: string, router: Router & { _nuxtLinkPreloaded?: Set<string> } = useRouter()) {
if (process.server) { return }

if (!router._nuxtLinkPreloaded) { router._nuxtLinkPreloaded = new Set() }
if (router._nuxtLinkPreloaded.has(to)) { return }
router._nuxtLinkPreloaded.add(to)

const components = router.resolve(to).matched
.map(component => component.components?.default)
.filter(component => typeof component === 'function')

const promises: Promise<any>[] = []
for (const component of components) {
const promise = Promise.resolve((component as Function)()).catch(() => {})
promises.push(promise)
}
await Promise.all(promises)
}
1 change: 1 addition & 0 deletions packages/nuxt/src/app/nuxt.ts
Expand Up @@ -31,6 +31,7 @@ export interface RuntimeNuxtHooks {
'app:error': (err: any) => HookResult
'app:error:cleared': (options: { redirect?: string }) => HookResult
'app:data:refresh': (keys?: string[]) => HookResult
'link:prefetch': (link: string) => HookResult
'page:start': (Component?: VNode) => HookResult
'page:finish': (Component?: VNode) => HookResult
'vue:setup': () => void
Expand Down
15 changes: 9 additions & 6 deletions packages/nuxt/src/app/plugins/payload.client.ts
Expand Up @@ -6,14 +6,17 @@ export default defineNuxtPlugin((nuxtApp) => {
if (!isPrerendered()) {
return
}
addRouteMiddleware(async (to, from) => {
if (to.path === from.path) { return }
const url = to.path
const prefetchPayload = async (url: string) => {
const payload = await loadPayload(url)
if (!payload) {
return
}
if (!payload) { return }
Object.assign(nuxtApp.payload.data, payload.data)
Object.assign(nuxtApp.payload.state, payload.state)
}
nuxtApp.hooks.hook('link:prefetch', async (to) => {
await prefetchPayload(to)
})
addRouteMiddleware(async (to, from) => {
if (to.path === from.path) { return }
await prefetchPayload(to.path)
})
})
15 changes: 11 additions & 4 deletions test/basic.test.ts
Expand Up @@ -597,9 +597,11 @@ describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', ()
it('does not fetch a prefetched payload', async () => {
const page = await createPage()
const requests = [] as string[]

page.on('request', (req) => {
requests.push(req.url().replace(url('/'), '/'))
})

await page.goto(url('/random/a'))
await page.waitForLoadState('networkidle')

Expand All @@ -610,25 +612,30 @@ describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', ()

// We are not triggering API requests in the payload
expect(requests).not.toContain(expect.stringContaining('/api/random'))
requests.length = 0
// requests.length = 0

await page.click('[href="/random/b"]')
await page.waitForLoadState('networkidle')

// We are not triggering API requests in the payload in client-side nav
expect(requests).not.toContain('/api/random')

// We are fetching a payload we did not prefetch
expect(requests).toContain('/random/b/_payload.js' + importSuffix)

// We are not refetching payloads we've already prefetched
expect(requests.filter(p => p.includes('_payload')).length).toBe(1)
requests.length = 0
// expect(requests.filter(p => p.includes('_payload')).length).toBe(1)
// requests.length = 0

await page.click('[href="/random/c"]')
await page.waitForLoadState('networkidle')

// We are not triggering API requests in the payload in client-side nav
expect(requests).not.toContain('/api/random')

// We are not refetching payloads we've already prefetched
// Note: we refetch on dev as urls differ between '' and '?import'
expect(requests.filter(p => p.includes('_payload')).length).toBe(process.env.NUXT_TEST_DEV ? 1 : 0)
// expect(requests.filter(p => p.includes('_payload')).length).toBe(process.env.NUXT_TEST_DEV ? 1 : 0)
})
})

Expand Down
20 changes: 12 additions & 8 deletions test/fixtures/basic/pages/random/[id].vue
@@ -1,12 +1,15 @@
<template>
<div>
<NuxtLink to="/random/a">
<NuxtLink to="/" prefetched-class="prefetched">
Home
</NuxtLink>
<NuxtLink to="/random/a" prefetched-class="prefetched">
Random (A)
</NuxtLink>
<NuxtLink to="/random/b">
<NuxtLink to="/random/b" prefetched-class="prefetched">
Random (B)
</NuxtLink>
<NuxtLink to="/random/c">
<NuxtLink to="/random/c" prefetched-class="prefetched">
Random (C)
</NuxtLink>
<br>
Expand Down Expand Up @@ -39,9 +42,10 @@ const { data: randomNumbers, refresh } = await useFetch('/api/random', { key: pa
const random = useRandomState(100, pageKey)
const globalRandom = useRandomState(100)
// TODO: NuxtLink should do this automatically on observed
if (process.client) {
preloadPayload('/random/c')
}
</script>

<style scoped>
.prefetched {
color: green;
}
</style>

0 comments on commit addcb5c

Please sign in to comment.