Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improved script warmup #302

Merged
merged 13 commits into from
Oct 15, 2024
56 changes: 56 additions & 0 deletions docs/content/docs/1.guides/1.warmup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
title: Warmup Strategy
description: Customize the preload or preconnect strategy used for your scripts.
---

## Background

Nuxt Scripts will insert relevant warmup `link` tags to optimize the loading of your scripts. Optimizing
for the quickest load after Nuxt has finished hydrating.

For example if we have a script like so:

```ts
useScript('/script.js')
```

This code will load in `/script.js` on the `onNuxtReady` event. As the network may be idle while your Nuxt App is hydrating,
Nuxt Scripts will use this time to warmup the script by inserting a `preload` tag in the `head` of the document.

```html
<link rel="preload" href="/script.js" as="script" fetchpriority="low">
```

The behavior is only applied when we are using the `client` or `onNuxtReady` [Script Triggers](/docs/guides/scripts-triggers).
To customize the behavior further we can use the `warmupStrategy` option.

## `warmupStrategy`

The `warmupStrategy` option can be used to customize the `link` tag inserted for the script. The option can be a function
that returns an object with the following properties:

* - `false` - Disable warmup.
* - `'preload'` - Preload the script, use when the script is loaded immediately.
* - `'preconnect'` or `'dns-prefetch'` - Preconnect to the script origin, use when you know a script will be loaded within 10 seconds. Only works when loading a script from a different origin, will fallback to `false` if the origin is the same.

All of these options can also be passed to a callback function, which can be useful when have a dynamic trigger for the script.

## `warmup`

The `warmup` function can be called explicitly to add either preconnect or preload link tags for a script. This will only work the first time the function is called.

This can be useful when you know that the script is going to be loaded shortly.

```ts
const script = useScript('/video.js', {
trigger: 'manual'
})
// warmup the script when we think the user may need the script
onVisible(videoContainer, () => {
script.warmup('preload')
})
// load it in
onClick(videoContainer, () => {
script.load()
})
```
19 changes: 10 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"release:minor": "npm run lint && npm run test && npm run prepack && changelogen --minor --release",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest",
"test": "pnpm dev:prepare && vitest --run --exclude **/__runtime__ && pnpm test:runtime",
"test:runtime": "cd test/fixtures/basic && vitest --run",
"test:types": "echo 'broken due to type regeneration, use pnpm typecheck' && npx nuxi typecheck",
"script:generate-tpc": "bun ./scripts/generateTpcScripts.ts && pnpm lint:fix"
},
Expand All @@ -72,7 +73,7 @@
"@types/google.maps": "^3.58.1",
"@types/vimeo__player": "^2.18.3",
"@types/youtube": "^0.1.0",
"@unhead/vue": "1.11.6",
"@unhead/vue": "1.11.9",
"@vueuse/core": "^11.1.0",
"consola": "^3.2.3",
"defu": "^6.1.4",
Expand Down Expand Up @@ -101,7 +102,7 @@
"@nuxt/test-utils": "3.14.2",
"@types/semver": "^7.5.8",
"@typescript-eslint/typescript-estree": "^8.7.0",
"@unhead/schema": "1.11.6",
"@unhead/schema": "1.11.9",
"acorn-loose": "^8.4.0",
"bumpp": "^9.5.2",
"changelogen": "^0.5.7",
Expand All @@ -119,14 +120,14 @@
"resolutions": {
"@nuxt/schema": "3.13.2",
"@nuxt/scripts": "workspace:*",
"@unhead/dom": "1.11.6",
"@unhead/schema": "1.11.6",
"@unhead/shared": "1.11.6",
"@unhead/ssr": "1.11.6",
"@unhead/vue": "1.11.6",
"@unhead/dom": "1.11.9",
"@unhead/schema": "1.11.9",
"@unhead/shared": "1.11.9",
"@unhead/ssr": "1.11.9",
"@unhead/vue": "1.11.9",
"nuxt": "^3.13.2",
"nuxt-scripts-devtools": "workspace:*",
"unhead": "1.11.6",
"unhead": "1.11.9",
"vue": "^3.5.9",
"vue-router": "^4.4.5"
}
Expand Down
1,850 changes: 1,049 additions & 801 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/assets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { addDevServerHandler, useNuxt } from '@nuxt/kit'
import { addDevServerHandler, useNuxt, tryUseNuxt } from '@nuxt/kit'
import { createError, eventHandler, lazyEventHandler } from 'h3'
import { fetch } from 'ofetch'
import { defu } from 'defu'
Expand All @@ -25,10 +25,10 @@ const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365

// TODO: refactor to use nitro storage when it can be cached between builds
export const bundleStorage = () => {
const nuxt = useNuxt()
const nuxt = tryUseNuxt()
return createStorage({
driver: fsDriver({
base: resolve(nuxt.options.rootDir, 'node_modules/.cache/nuxt/scripts'),
base: resolve(nuxt?.options.rootDir || '', 'node_modules/.cache/nuxt/scripts'),
}),
})
}
Expand Down
8 changes: 4 additions & 4 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,10 @@ declare module '#nuxt-scripts' {
type NuxtUseScriptOptions = Omit<import('${typesPath}').NuxtUseScriptOptions, 'use' | 'beforeInit'>
interface ScriptRegistry {
${newScripts.map((i) => {
const key = i.import?.name.replace('useScript', '')
const keyLcFirst = key.substring(0, 1).toLowerCase() + key.substring(1)
return ` ${keyLcFirst}?: import('${i.import?.from}').${key}Input | [import('${i.import?.from}').${key}Input, NuxtUseScriptOptions]`
}).join('\n')}
const key = i.import?.name.replace('useScript', '')
const keyLcFirst = key.substring(0, 1).toLowerCase() + key.substring(1)
return ` ${keyLcFirst}?: import('${i.import?.from}').${key}Input | [import('${i.import?.from}').${key}Input, NuxtUseScriptOptions]`
}).join('\n')}
}
}`
return types
Expand Down
92 changes: 60 additions & 32 deletions src/runtime/composables/useScript.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,69 @@
import type { UseScriptInput, VueScriptInstance } from '@unhead/vue'
import type { UseScriptOptions, UseFunctionType, AsAsyncFunctionValues } from '@unhead/schema'
import type { UseScriptInput, VueScriptInstance, MaybeComputedRefEntriesOnly } from '@unhead/vue'
import type { UseScriptOptions, UseFunctionType, Head, DataKeys, SchemaAugmentations, ScriptBase } from '@unhead/schema'
import { resolveScriptKey } from 'unhead'
import { defu } from 'defu'
import { useScript as _useScript } from '@unhead/vue'
import { parseURL } from 'ufo'
import { pick } from '../utils'
import { injectHead, onNuxtReady, useHead, useNuxtApp, useRuntimeConfig, reactive } from '#imports'
import type { NuxtDevToolsScriptInstance, NuxtUseScriptOptions } from '#nuxt-scripts'
import type { NuxtDevToolsScriptInstance, NuxtUseScriptOptions, UseScriptContext, WarmupStrategy } from '#nuxt-scripts'

function useNuxtScriptRuntimeConfig() {
return useRuntimeConfig().public['nuxt-scripts'] as {
defaultScriptOptions: NuxtUseScriptOptions
}
}

export type UseScriptContext<T extends Record<symbol | string, any>> =
(Promise<T> & VueScriptInstance<T>)
& AsAsyncFunctionValues<T>
& {
/**
* @deprecated Use top-level functions instead.
*/
$script: Promise<T> & VueScriptInstance<T>
}
const ValidPreloadTriggers = ['onNuxtReady', 'client']
const PreconnectServerModes = ['preconnect', 'dns-prefetch']

type ResolvedScriptInput = (MaybeComputedRefEntriesOnly<Omit<ScriptBase & DataKeys & SchemaAugmentations['script'], 'src'>> & { src: string })
function warmup(_: ResolvedScriptInput, rel: WarmupStrategy, head: any) {
const { src } = _
const $url = parseURL(src)
const isPreconnect = rel && PreconnectServerModes.includes(rel)
const href = isPreconnect ? `${$url.protocol}${$url.host}` : src
const isCrossOrigin = !!$url.host
if (!rel || (isPreconnect && !isCrossOrigin)) {
return
}
const link = {
href,
rel,
...pick(_, [
// shared keys between script and link
'crossorigin',
'referrerpolicy',
'fetchpriority',
'integrity',
// ignore id
]),
}
const defaults: Required<Head>['link'][0] = { fetchpriority: 'low' }
if (rel === 'preload') {
defaults.as = 'script'
}
// is absolute, add privacy headers
if (isCrossOrigin) {
defaults.crossorigin = 'anonymous'
defaults.referrerpolicy = 'no-referrer'
}
return useHead({ link: [defu(link, defaults)] }, { head, tagPriority: 'high' })
}

export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>, U = Record<symbol | string, any>>(input: UseScriptInput, options?: NuxtUseScriptOptions<T, U>): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T, U>, T>> {
input = typeof input === 'string' ? { src: input } : input
options = defu(options, useNuxtScriptRuntimeConfig()?.defaultScriptOptions) as NuxtUseScriptOptions<T, U>
// browser hint optimizations
const rel = options.trigger === 'onNuxtReady' ? 'preload' : 'preconnect'
const isCrossOrigin = input.src && !input.src.startsWith('/')
const id = resolveScriptKey(input) as keyof typeof nuxtApp._scripts
const id = String(resolveScriptKey(input) as keyof typeof nuxtApp._scripts)
const nuxtApp = useNuxtApp()
const head = options.head || injectHead()
nuxtApp.$scripts = nuxtApp.$scripts! || reactive({})
const exists = !!(nuxtApp.$scripts as Record<string, any>)?.[id]

// need to make sure it's not already registered
if (!exists && input.src && ValidPreloadTriggers.includes(String(options.trigger)) && (rel === 'preload' || isCrossOrigin)) {
useHead({
link: [
{
rel,
as: rel === 'preload' ? 'script' : undefined,
href: input.src,
crossorigin: !isCrossOrigin ? undefined : (typeof input.crossorigin !== 'undefined' ? input.crossorigin : 'anonymous'),
key: `nuxt-script-${id}`,
tagPriority: rel === 'preload' ? 'high' : 0,
fetchpriority: 'low',
},
],
})
if (!options.warmupStrategy && ValidPreloadTriggers.includes(String(options.trigger))) {
options.warmupStrategy = 'preload'
}
if (options.trigger === 'onNuxtReady') {
options.trigger = onNuxtReady
Expand All @@ -62,8 +78,21 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
})
}
}
const instance = _useScript<T>(input, options as any as UseScriptOptions<T>)
// @ts-expect-error untyped
const instance = _useScript<T>(input, options as any as UseScriptOptions<T>) as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T, U>, T>>
instance.warmup = (rel) => {
if (!instance._warmupEl) {
instance._warmupEl = warmup(input, rel, head)
}
}
if (options.warmupStrategy) {
instance.warmup(options.warmupStrategy)
}
const _remove = instance.remove
instance.remove = () => {
instance._warmupEl?.dispose()
nuxtApp.$scripts[id] = undefined
return _remove()
}
nuxtApp.$scripts[id] = instance
// used for devtools integration
if (import.meta.dev && import.meta.client) {
Expand All @@ -85,7 +114,6 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
}

if (!nuxtApp._scripts[instance.id]) {
const head = injectHead()
head.hooks.hook('script:updated', (ctx) => {
if (ctx.script.id !== instance.id)
return
Expand Down
32 changes: 31 additions & 1 deletion src/runtime/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { DataKeys, SchemaAugmentations, ScriptBase } from '@unhead/schema'
import type {
ActiveHeadEntry,
AsAsyncFunctionValues,
DataKeys,
SchemaAugmentations,
ScriptBase,
} from '@unhead/schema'
import type { UseScriptInput, VueScriptInstance, UseScriptOptions } from '@unhead/vue'
import type { ComputedRef, Ref } from 'vue'
import type { InferInput, ObjectSchema } from 'valibot'
Expand All @@ -25,6 +31,20 @@ import type { GoogleAnalyticsInput } from './registry/google-analytics'
import type { GoogleTagManagerInput } from './registry/google-tag-manager'
import { object } from '#nuxt-scripts-validator'

export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch'

export type UseScriptContext<T extends Record<symbol | string, any>> =
(Promise<T> & VueScriptInstance<T>)
& AsAsyncFunctionValues<T>
& {
/**
* @deprecated Use top-level functions instead.
*/
$script: Promise<T> & VueScriptInstance<T>
warmup: (rel: WarmupStrategy) => void
_warmupEl?: void | ActiveHeadEntry<any>
}

export type NuxtUseScriptOptions<T extends Record<symbol | string, any> = {}, U = {}> = Omit<UseScriptOptions<T, U>, 'trigger'> & {
/**
* The trigger to load the script:
Expand All @@ -46,6 +66,16 @@ export type NuxtUseScriptOptions<T extends Record<symbol | string, any> = {}, U
* loading the actual script and not getting warnings.
*/
skipValidation?: boolean
/**
* Specify a strategy for warming up the connection to the third-party script.
*
* The strategy to use for preloading the script.
* - `false` - Disable preloading.
* - `'preload'` - Preload the script.
* - `'preconnect'` | `'dns-prefetch'` - Preconnect to the script. Only works when loading a script from a different origin, will fallback
* to `false` if the origin is the same.
*/
warmupStrategy?: WarmupStrategy
/**
* @internal
*/
Expand Down
10 changes: 10 additions & 0 deletions src/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,13 @@ export function useRegistryScript<T extends Record<string | symbol, any>, O = Em
}
return useScript<T, U>(scriptInput, scriptOptions as NuxtUseScriptOptions<T, U>)
}

export function pick(obj: Record<string, any>, keys: string[]) {
const res: Record<string, any> = {}
for (const k of keys) {
if (k in obj) {
res[k] = obj[k]
}
}
return res
}
Loading