diff --git a/packages/nuxt/src/app/components/nuxt-link.ts b/packages/nuxt/src/app/components/nuxt-link.ts index 88b10e37d4..5244625ac0 100644 --- a/packages/nuxt/src/app/components/nuxt-link.ts +++ b/packages/nuxt/src/app/components/nuxt-link.ts @@ -1,7 +1,8 @@ import { defineComponent, h, ref, resolveComponent, PropType, computed, DefineComponent, ComputedRef, onMounted, onBeforeUnmount } from 'vue' -import type { RouteLocationRaw, Router } from 'vue-router' +import type { RouteLocationRaw } from 'vue-router' import { hasProtocol } from 'ufo' +import { preloadRouteComponents } from '../composables/preload' import { navigateTo, useRouter } from '../composables/router' import { useNuxtApp } from '../nuxt' @@ -189,7 +190,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) { const el = process.server ? undefined : ref(null) if (process.client) { checkPropConflicts(props, 'prefetch', 'noPrefetch') - const shouldPrefetch = props.prefetch !== false && props.noPrefetch !== true && typeof to.value === 'string' && !isSlowConnection() + const shouldPrefetch = props.prefetch !== false && props.noPrefetch !== true && typeof to.value === 'string' && props.target !== '_blank' && !isSlowConnection() if (shouldPrefetch) { const nuxtApp = useNuxtApp() const observer = useObserver() @@ -269,7 +270,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) { }) } - return h('a', { href, rel, target }, slots.default?.()) + return h('a', { ref: el, href, rel, target }, slots.default?.()) } } }) as unknown as DefineComponent @@ -328,22 +329,3 @@ function isSlowConnection () { if (cn && (cn.saveData || /2g/.test(cn.effectiveType))) { return true } return false } - -async function preloadRouteComponents (to: string, router: Router & { _nuxtLinkPreloaded?: Set } = 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[] = [] - for (const component of components) { - const promise = Promise.resolve((component as Function)()).catch(() => {}) - promises.push(promise) - } - await Promise.all(promises) -} diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index 98fc038d67..485b37d727 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -12,5 +12,5 @@ export type { CookieOptions, CookieRef } from './cookie' export { useRequestHeaders, useRequestEvent, setResponseStatus } from './ssr' export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, setPageLayout, navigateTo, useRoute, useActiveRoute, useRouter } from './router' export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router' -export { preloadComponents, prefetchComponents } from './preload' +export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload' export { isPrerendered, loadPayload, preloadPayload } from './payload' diff --git a/packages/nuxt/src/app/composables/preload.ts b/packages/nuxt/src/app/composables/preload.ts index 7b9f01acf4..308b40c4b9 100644 --- a/packages/nuxt/src/app/composables/preload.ts +++ b/packages/nuxt/src/app/composables/preload.ts @@ -1,5 +1,7 @@ import type { Component } from 'vue' +import type { Router } from 'vue-router' import { useNuxtApp } from '../nuxt' +import { useRouter } from './router' /** * Preload a component or components that have been globally registered. @@ -31,3 +33,31 @@ function _loadAsyncComponent (component: Component) { return (component as any).__asyncLoader() } } + +export async function preloadRouteComponents (to: string, router: Router & { _routePreloaded?: Set; _preloadPromises?: Array> } = useRouter()): Promise { + if (process.server) { return } + + if (!router._routePreloaded) { router._routePreloaded = new Set() } + if (router._routePreloaded.has(to)) { return } + router._routePreloaded.add(to) + + const promises = router._preloadPromises ||= [] + + if (promises.length > 4) { + // Defer adding new preload requests until the existing ones have resolved + return Promise.all(promises).then(() => preloadRouteComponents(to, router)) + } + + const components = router.resolve(to).matched + .map(component => component.components?.default) + .filter(component => typeof component === 'function') + + for (const component of components) { + const promise = Promise.resolve((component as Function)()) + .catch(() => {}) + .finally(() => promises.splice(promises.indexOf(promise))) + promises.push(promise) + } + + await Promise.all(promises) +} diff --git a/packages/nuxt/src/app/plugins/cross-origin-prefetch.client.ts b/packages/nuxt/src/app/plugins/cross-origin-prefetch.client.ts new file mode 100644 index 0000000000..83988f6038 --- /dev/null +++ b/packages/nuxt/src/app/plugins/cross-origin-prefetch.client.ts @@ -0,0 +1,29 @@ +import { ref } from 'vue' +import { parseURL } from 'ufo' +import { defineNuxtPlugin, useHead } from '#app' + +export default defineNuxtPlugin((nuxtApp) => { + const externalURLs = ref(new Set()) + useHead({ + script: [ + () => ({ + type: 'speculationrules', + innerHTML: JSON.stringify({ + prefetch: [ + { + source: 'list', + urls: [...externalURLs.value], + requires: ['anonymous-client-ip-when-cross-origin'] + } + ] + }) + }) + ] + }) + nuxtApp.hook('link:prefetch', (url) => { + const { protocol } = parseURL(url) + if (protocol && ['http:', 'https:'].includes(protocol)) { + externalURLs.value.add(url) + } + }) +}) diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 801311d62c..a3caf94879 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -178,6 +178,11 @@ async function initNuxt (nuxt: Nuxt) { addPlugin(resolve(nuxt.options.appDir, 'plugins/payload.client')) } + // Add experimental cross-origin prefetch support using Speculation Rules API + if (nuxt.options.experimental.crossOriginPrefetch) { + addPlugin(resolve(nuxt.options.appDir, 'plugins/cross-origin-prefetch.client')) + } + // Track components used to render for webpack if (nuxt.options.builder === '@nuxt/webpack-builder') { addPlugin(resolve(nuxt.options.appDir, 'plugins/preload.server')) diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index 26776780ec..447d91815e 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -56,6 +56,7 @@ const appPreset = defineUnimportPreset({ 'updateAppConfig', 'defineAppConfig', 'preloadComponents', + 'preloadRouteComponents', 'prefetchComponents', 'loadPayload', 'preloadPayload', diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 9fc13cf299..25dabdb754 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -79,5 +79,8 @@ export default defineUntypedSchema({ * When this option is enabled (by default) payload of pages generated with `nuxt generate` are extracted */ payloadExtraction: true, + + /** Enable cross-origin prefetch using the Speculation Rules API. */ + crossOriginPrefetch: false } })