Skip to content

Commit 3f168cc

Browse files
authored
feat: improved script warmup (#302)
1 parent c89380f commit 3f168cc

File tree

11 files changed

+1323
-850
lines changed

11 files changed

+1323
-850
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
title: Warmup Strategy
3+
description: Customize the preload or preconnect strategy used for your scripts.
4+
---
5+
6+
## Background
7+
8+
Nuxt Scripts will insert relevant warmup `link` tags to optimize the loading of your scripts. Optimizing
9+
for the quickest load after Nuxt has finished hydrating.
10+
11+
For example if we have a script like so:
12+
13+
```ts
14+
useScript('/script.js')
15+
```
16+
17+
This code will load in `/script.js` on the `onNuxtReady` event. As the network may be idle while your Nuxt App is hydrating,
18+
Nuxt Scripts will use this time to warmup the script by inserting a `preload` tag in the `head` of the document.
19+
20+
```html
21+
<link rel="preload" href="/script.js" as="script" fetchpriority="low">
22+
```
23+
24+
The behavior is only applied when we are using the `client` or `onNuxtReady` [Script Triggers](/docs/guides/scripts-triggers).
25+
To customize the behavior further we can use the `warmupStrategy` option.
26+
27+
## `warmupStrategy`
28+
29+
The `warmupStrategy` option can be used to customize the `link` tag inserted for the script. The option can be a function
30+
that returns an object with the following properties:
31+
32+
* - `false` - Disable warmup.
33+
* - `'preload'` - Preload the script, use when the script is loaded immediately.
34+
* - `'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.
35+
36+
All of these options can also be passed to a callback function, which can be useful when have a dynamic trigger for the script.
37+
38+
## `warmup`
39+
40+
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.
41+
42+
This can be useful when you know that the script is going to be loaded shortly.
43+
44+
```ts
45+
const script = useScript('/video.js', {
46+
trigger: 'manual'
47+
})
48+
// warmup the script when we think the user may need the script
49+
onVisible(videoContainer, () => {
50+
script.warmup('preload')
51+
})
52+
// load it in
53+
onClick(videoContainer, () => {
54+
script.load()
55+
})
56+
```

package.json

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
"release:minor": "npm run lint && npm run test && npm run prepack && changelogen --minor --release",
4848
"lint": "eslint .",
4949
"lint:fix": "eslint . --fix",
50-
"test": "vitest",
50+
"test": "pnpm dev:prepare && vitest --run --exclude **/__runtime__ && pnpm test:runtime",
51+
"test:runtime": "cd test/fixtures/basic && vitest --run",
5152
"test:types": "echo 'broken due to type regeneration, use pnpm typecheck' && npx nuxi typecheck",
5253
"script:generate-tpc": "bun ./scripts/generateTpcScripts.ts && pnpm lint:fix"
5354
},
@@ -72,7 +73,7 @@
7273
"@types/google.maps": "^3.58.1",
7374
"@types/vimeo__player": "^2.18.3",
7475
"@types/youtube": "^0.1.0",
75-
"@unhead/vue": "1.11.6",
76+
"@unhead/vue": "1.11.9",
7677
"@vueuse/core": "^11.1.0",
7778
"consola": "^3.2.3",
7879
"defu": "^6.1.4",
@@ -101,7 +102,7 @@
101102
"@nuxt/test-utils": "3.14.2",
102103
"@types/semver": "^7.5.8",
103104
"@typescript-eslint/typescript-estree": "^8.7.0",
104-
"@unhead/schema": "1.11.6",
105+
"@unhead/schema": "1.11.9",
105106
"acorn-loose": "^8.4.0",
106107
"bumpp": "^9.5.2",
107108
"changelogen": "^0.5.7",
@@ -119,14 +120,14 @@
119120
"resolutions": {
120121
"@nuxt/schema": "3.13.2",
121122
"@nuxt/scripts": "workspace:*",
122-
"@unhead/dom": "1.11.6",
123-
"@unhead/schema": "1.11.6",
124-
"@unhead/shared": "1.11.6",
125-
"@unhead/ssr": "1.11.6",
126-
"@unhead/vue": "1.11.6",
123+
"@unhead/dom": "1.11.9",
124+
"@unhead/schema": "1.11.9",
125+
"@unhead/shared": "1.11.9",
126+
"@unhead/ssr": "1.11.9",
127+
"@unhead/vue": "1.11.9",
127128
"nuxt": "^3.13.2",
128129
"nuxt-scripts-devtools": "workspace:*",
129-
"unhead": "1.11.6",
130+
"unhead": "1.11.9",
130131
"vue": "^3.5.9",
131132
"vue-router": "^4.4.5"
132133
}

pnpm-lock.yaml

Lines changed: 1049 additions & 801 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/assets.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { addDevServerHandler, useNuxt } from '@nuxt/kit'
1+
import { addDevServerHandler, useNuxt, tryUseNuxt } from '@nuxt/kit'
22
import { createError, eventHandler, lazyEventHandler } from 'h3'
33
import { fetch } from 'ofetch'
44
import { defu } from 'defu'
@@ -25,10 +25,10 @@ const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365
2525

2626
// TODO: refactor to use nitro storage when it can be cached between builds
2727
export const bundleStorage = () => {
28-
const nuxt = useNuxt()
28+
const nuxt = tryUseNuxt()
2929
return createStorage({
3030
driver: fsDriver({
31-
base: resolve(nuxt.options.rootDir, 'node_modules/.cache/nuxt/scripts'),
31+
base: resolve(nuxt?.options.rootDir || '', 'node_modules/.cache/nuxt/scripts'),
3232
}),
3333
})
3434
}

src/module.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,10 @@ declare module '#nuxt-scripts' {
175175
type NuxtUseScriptOptions = Omit<import('${typesPath}').NuxtUseScriptOptions, 'use' | 'beforeInit'>
176176
interface ScriptRegistry {
177177
${newScripts.map((i) => {
178-
const key = i.import?.name.replace('useScript', '')
179-
const keyLcFirst = key.substring(0, 1).toLowerCase() + key.substring(1)
180-
return ` ${keyLcFirst}?: import('${i.import?.from}').${key}Input | [import('${i.import?.from}').${key}Input, NuxtUseScriptOptions]`
181-
}).join('\n')}
178+
const key = i.import?.name.replace('useScript', '')
179+
const keyLcFirst = key.substring(0, 1).toLowerCase() + key.substring(1)
180+
return ` ${keyLcFirst}?: import('${i.import?.from}').${key}Input | [import('${i.import?.from}').${key}Input, NuxtUseScriptOptions]`
181+
}).join('\n')}
182182
}
183183
}`
184184
return types

src/runtime/composables/useScript.ts

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,69 @@
1-
import type { UseScriptInput, VueScriptInstance } from '@unhead/vue'
2-
import type { UseScriptOptions, UseFunctionType, AsAsyncFunctionValues } from '@unhead/schema'
1+
import type { UseScriptInput, VueScriptInstance, MaybeComputedRefEntriesOnly } from '@unhead/vue'
2+
import type { UseScriptOptions, UseFunctionType, Head, DataKeys, SchemaAugmentations, ScriptBase } from '@unhead/schema'
33
import { resolveScriptKey } from 'unhead'
44
import { defu } from 'defu'
55
import { useScript as _useScript } from '@unhead/vue'
6+
import { parseURL } from 'ufo'
7+
import { pick } from '../utils'
68
import { injectHead, onNuxtReady, useHead, useNuxtApp, useRuntimeConfig, reactive } from '#imports'
7-
import type { NuxtDevToolsScriptInstance, NuxtUseScriptOptions } from '#nuxt-scripts'
9+
import type { NuxtDevToolsScriptInstance, NuxtUseScriptOptions, UseScriptContext, WarmupStrategy } from '#nuxt-scripts'
810

911
function useNuxtScriptRuntimeConfig() {
1012
return useRuntimeConfig().public['nuxt-scripts'] as {
1113
defaultScriptOptions: NuxtUseScriptOptions
1214
}
1315
}
1416

15-
export type UseScriptContext<T extends Record<symbol | string, any>> =
16-
(Promise<T> & VueScriptInstance<T>)
17-
& AsAsyncFunctionValues<T>
18-
& {
19-
/**
20-
* @deprecated Use top-level functions instead.
21-
*/
22-
$script: Promise<T> & VueScriptInstance<T>
23-
}
2417
const ValidPreloadTriggers = ['onNuxtReady', 'client']
18+
const PreconnectServerModes = ['preconnect', 'dns-prefetch']
19+
20+
type ResolvedScriptInput = (MaybeComputedRefEntriesOnly<Omit<ScriptBase & DataKeys & SchemaAugmentations['script'], 'src'>> & { src: string })
21+
function warmup(_: ResolvedScriptInput, rel: WarmupStrategy, head: any) {
22+
const { src } = _
23+
const $url = parseURL(src)
24+
const isPreconnect = rel && PreconnectServerModes.includes(rel)
25+
const href = isPreconnect ? `${$url.protocol}${$url.host}` : src
26+
const isCrossOrigin = !!$url.host
27+
if (!rel || (isPreconnect && !isCrossOrigin)) {
28+
return
29+
}
30+
const link = {
31+
href,
32+
rel,
33+
...pick(_, [
34+
// shared keys between script and link
35+
'crossorigin',
36+
'referrerpolicy',
37+
'fetchpriority',
38+
'integrity',
39+
// ignore id
40+
]),
41+
}
42+
const defaults: Required<Head>['link'][0] = { fetchpriority: 'low' }
43+
if (rel === 'preload') {
44+
defaults.as = 'script'
45+
}
46+
// is absolute, add privacy headers
47+
if (isCrossOrigin) {
48+
defaults.crossorigin = 'anonymous'
49+
defaults.referrerpolicy = 'no-referrer'
50+
}
51+
return useHead({ link: [defu(link, defaults)] }, { head, tagPriority: 'high' })
52+
}
2553

2654
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>> {
2755
input = typeof input === 'string' ? { src: input } : input
2856
options = defu(options, useNuxtScriptRuntimeConfig()?.defaultScriptOptions) as NuxtUseScriptOptions<T, U>
2957
// browser hint optimizations
30-
const rel = options.trigger === 'onNuxtReady' ? 'preload' : 'preconnect'
31-
const isCrossOrigin = input.src && !input.src.startsWith('/')
32-
const id = resolveScriptKey(input) as keyof typeof nuxtApp._scripts
58+
const id = String(resolveScriptKey(input) as keyof typeof nuxtApp._scripts)
3359
const nuxtApp = useNuxtApp()
60+
const head = options.head || injectHead()
3461
nuxtApp.$scripts = nuxtApp.$scripts! || reactive({})
3562
const exists = !!(nuxtApp.$scripts as Record<string, any>)?.[id]
63+
3664
// need to make sure it's not already registered
37-
if (!exists && input.src && ValidPreloadTriggers.includes(String(options.trigger)) && (rel === 'preload' || isCrossOrigin)) {
38-
useHead({
39-
link: [
40-
{
41-
rel,
42-
as: rel === 'preload' ? 'script' : undefined,
43-
href: input.src,
44-
crossorigin: !isCrossOrigin ? undefined : (typeof input.crossorigin !== 'undefined' ? input.crossorigin : 'anonymous'),
45-
key: `nuxt-script-${id}`,
46-
tagPriority: rel === 'preload' ? 'high' : 0,
47-
fetchpriority: 'low',
48-
},
49-
],
50-
})
65+
if (!options.warmupStrategy && ValidPreloadTriggers.includes(String(options.trigger))) {
66+
options.warmupStrategy = 'preload'
5167
}
5268
if (options.trigger === 'onNuxtReady') {
5369
options.trigger = onNuxtReady
@@ -62,8 +78,21 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
6278
})
6379
}
6480
}
65-
const instance = _useScript<T>(input, options as any as UseScriptOptions<T>)
66-
// @ts-expect-error untyped
81+
const instance = _useScript<T>(input, options as any as UseScriptOptions<T>) as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T, U>, T>>
82+
instance.warmup = (rel) => {
83+
if (!instance._warmupEl) {
84+
instance._warmupEl = warmup(input, rel, head)
85+
}
86+
}
87+
if (options.warmupStrategy) {
88+
instance.warmup(options.warmupStrategy)
89+
}
90+
const _remove = instance.remove
91+
instance.remove = () => {
92+
instance._warmupEl?.dispose()
93+
nuxtApp.$scripts[id] = undefined
94+
return _remove()
95+
}
6796
nuxtApp.$scripts[id] = instance
6897
// used for devtools integration
6998
if (import.meta.dev && import.meta.client) {
@@ -85,7 +114,6 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
85114
}
86115

87116
if (!nuxtApp._scripts[instance.id]) {
88-
const head = injectHead()
89117
head.hooks.hook('script:updated', (ctx) => {
90118
if (ctx.script.id !== instance.id)
91119
return

src/runtime/types.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { DataKeys, SchemaAugmentations, ScriptBase } from '@unhead/schema'
1+
import type {
2+
ActiveHeadEntry,
3+
AsAsyncFunctionValues,
4+
DataKeys,
5+
SchemaAugmentations,
6+
ScriptBase,
7+
} from '@unhead/schema'
28
import type { UseScriptInput, VueScriptInstance, UseScriptOptions } from '@unhead/vue'
39
import type { ComputedRef, Ref } from 'vue'
410
import type { InferInput, ObjectSchema } from 'valibot'
@@ -25,6 +31,20 @@ import type { GoogleAnalyticsInput } from './registry/google-analytics'
2531
import type { GoogleTagManagerInput } from './registry/google-tag-manager'
2632
import { object } from '#nuxt-scripts-validator'
2733

34+
export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch'
35+
36+
export type UseScriptContext<T extends Record<symbol | string, any>> =
37+
(Promise<T> & VueScriptInstance<T>)
38+
& AsAsyncFunctionValues<T>
39+
& {
40+
/**
41+
* @deprecated Use top-level functions instead.
42+
*/
43+
$script: Promise<T> & VueScriptInstance<T>
44+
warmup: (rel: WarmupStrategy) => void
45+
_warmupEl?: void | ActiveHeadEntry<any>
46+
}
47+
2848
export type NuxtUseScriptOptions<T extends Record<symbol | string, any> = {}, U = {}> = Omit<UseScriptOptions<T, U>, 'trigger'> & {
2949
/**
3050
* The trigger to load the script:
@@ -46,6 +66,16 @@ export type NuxtUseScriptOptions<T extends Record<symbol | string, any> = {}, U
4666
* loading the actual script and not getting warnings.
4767
*/
4868
skipValidation?: boolean
69+
/**
70+
* Specify a strategy for warming up the connection to the third-party script.
71+
*
72+
* The strategy to use for preloading the script.
73+
* - `false` - Disable preloading.
74+
* - `'preload'` - Preload the script.
75+
* - `'preconnect'` | `'dns-prefetch'` - Preconnect to the script. Only works when loading a script from a different origin, will fallback
76+
* to `false` if the origin is the same.
77+
*/
78+
warmupStrategy?: WarmupStrategy
4979
/**
5080
* @internal
5181
*/

src/runtime/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,13 @@ export function useRegistryScript<T extends Record<string | symbol, any>, O = Em
7676
}
7777
return useScript<T, U>(scriptInput, scriptOptions as NuxtUseScriptOptions<T, U>)
7878
}
79+
80+
export function pick(obj: Record<string, any>, keys: string[]) {
81+
const res: Record<string, any> = {}
82+
for (const k of keys) {
83+
if (k in obj) {
84+
res[k] = obj[k]
85+
}
86+
}
87+
return res
88+
}

0 commit comments

Comments
 (0)