Skip to content

Commit c234e21

Browse files
authoredJan 9, 2025··
feat(scripts)!: useScript overhaul, @unhead/scripts (#436)
* chore: release v1.11.14 * progress commit * chore: progress commit * chore: broken tests * chore: fix build * chore: fix build * doc: install fix * fix: no longer augment as promise, export legacy * fix: use forwarding proxy once loaded * chore: read me * feat: support event deduping
1 parent d6bfebf commit c234e21

30 files changed

+951
-230
lines changed
 

Diff for: ‎docs/content/1.usage/2.composables/4.use-script.md

+20-51
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ title: useScript
33
description: Load third-party scripts with SSR support and a proxied API.
44
---
55

6-
**Stable as of v1.9**
7-
86
## Features
97

108
- 🪨 Turn a third-party script into a fully typed API
@@ -14,6 +12,26 @@ description: Load third-party scripts with SSR support and a proxied API.
1412
- 🪝 Proxy API: Use a scripts functions before it's loaded (or while SSR)
1513
- 🇹 Fully typed APIs
1614

15+
## Installation
16+
17+
As of Unhead v2, you will need to add the `@unhead/scripts` dependency to use `useScript`.
18+
19+
::code-group
20+
21+
```bash [yarn]
22+
yarn add -D @unhead/scripts
23+
```
24+
25+
```bash [npm]
26+
npm install -D @unhead/scripts
27+
```
28+
29+
```bash [pnpm]
30+
pnpm add -D @unhead/scripts
31+
```
32+
33+
::
34+
1735
## Background
1836

1937
Loading scripts using the `useHead` composable is easy.
@@ -314,28 +332,6 @@ const val = myScript.proxy.siteId // ❌ val will be a function
314332
const user = myScript.proxy.loadUser() // ❌ the result of calling any function is always void
315333
````
316334

317-
#### Stubbing
318-
319-
In cases where you're using the Proxy API, you can additionally hook into the resolving of the proxy using the `stub`
320-
option.
321-
322-
For example, in a server context, we probably want to polyfill some returns so our scrits remains functional.
323-
324-
```ts
325-
const analytics = useScript<{ event: ((arg: string) => boolean) }>('/analytics.js', {
326-
use() { return window.analytics },
327-
stub() {
328-
if (import.meta.server) {
329-
return {
330-
event: (e) => {
331-
console.log('event', e)
332-
}
333-
}
334-
}
335-
}
336-
})
337-
```
338-
339335
## API
340336

341337
```ts
@@ -420,33 +416,6 @@ fathom.then((api) => {
420416
})
421417
```
422418

423-
#### `stub`
424-
425-
A more advanced function used to stub out the logic of the API. This will be called on the server and client.
426-
427-
This is particularly useful when the API you want to use is a primitive and you need to access it on the server. For instance,
428-
pushing to `dataLayer` when using Google Tag Manager.
429-
430-
```ts
431-
const myScript = useScript<MyScriptApi>({
432-
src: 'https://example.com/script.js',
433-
}, {
434-
use: () => window.myScript,
435-
stub: ({ fn }) => {
436-
// stub out behavior on server
437-
if (process.server && fn === 'sendEvent')
438-
return (opt: string) => fetch('https://api.example.com/event', { method: 'POST', body: opt })
439-
}
440-
})
441-
const { sendEvent, doSomething } = myScript.proxy
442-
// on server, will send a fetch to https://api.example.com/event
443-
// on client it falls back to the real API
444-
sendEvent('event')
445-
// on server, will noop
446-
// on client it falls back to the real API
447-
doSomething()
448-
```
449-
450419
## Script Instance API
451420

452421
The `useScript` composable returns the script instance that you can use to interact with the script.

Diff for: ‎packages/schema/src/hooks.ts

-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { ScriptInstance } from './'
21
import type { CreateHeadOptions, HeadEntry, Unhead } from './head'
32
import type { HeadTag } from './tags'
43

@@ -53,7 +52,4 @@ export interface HeadHooks {
5352
'ssr:beforeRender': (ctx: ShouldRenderContext) => HookResult
5453
'ssr:render': (ctx: { tags: HeadTag[] }) => HookResult
5554
'ssr:rendered': (ctx: SSRRenderContext) => HookResult
56-
57-
'script:updated': (ctx: { script: ScriptInstance<any> }) => HookResult
58-
'script:instance-fn': (ctx: { script: ScriptInstance<any>, fn: string | symbol, exists: boolean }) => HookResult
5955
}

Diff for: ‎packages/schema/src/index.ts

-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,4 @@ export * from './head'
22
export * from './hooks'
33
export * from './safeSchema'
44
export * from './schema'
5-
export * from './script'
65
export * from './tags'
7-
8-
export {}

Diff for: ‎packages/scripts/README.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# @unhead/scripts
2+
3+
Unhead Scripts allows you to load third-party scripts with better performance, privacy, and security.
4+
5+
## License
6+
7+
MIT License © 2022-PRESENT [Harlan Wilton](https://github.com/harlan-zw)

Diff for: ‎packages/scripts/build.config.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { defineBuildConfig } from 'unbuild'
2+
3+
export default defineBuildConfig({
4+
clean: true,
5+
declaration: true,
6+
rollup: {
7+
emitCJS: true,
8+
},
9+
entries: [
10+
{ input: 'src/index' },
11+
{ input: 'src/vue/index', name: 'vue' },
12+
{ input: 'src/legacy', name: 'legacy' },
13+
{ input: 'src/vue-legacy', name: 'vue-legacy' },
14+
],
15+
externals: [
16+
'vue',
17+
'@vue/runtime-core',
18+
'unplugin-vue-components',
19+
'unhead',
20+
'@unhead/vue',
21+
'@unhead/schema',
22+
'vite',
23+
'vue-router',
24+
'@unhead/vue',
25+
'@unhead/schema',
26+
'unplugin-ast',
27+
'unplugin',
28+
'unplugin-vue-components',
29+
'vue',
30+
'@vue/runtime-core',
31+
],
32+
})

Diff for: ‎packages/scripts/legacy.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './dist/legacy'

Diff for: ‎packages/scripts/overrides.d.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
declare module '@unhead/schema' {
2+
import type { ScriptInstance } from '@unhead/scripts'
3+
4+
export interface HeadHooks {
5+
'script:updated': (ctx: { script: ScriptInstance<any> }) => void | Promise<void>
6+
}
7+
}
8+
9+
export {}

Diff for: ‎packages/scripts/package.json

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
{
2+
"name": "@unhead/scripts",
3+
"type": "module",
4+
"version": "1.11.14",
5+
"description": "Unhead Scripts allows you to load third-party scripts with better performance, privacy, and security.",
6+
"author": "Harlan Wilton <harlan@harlanzw.com>",
7+
"license": "MIT",
8+
"funding": "https://github.com/sponsors/harlan-zw",
9+
"homepage": "https://unhead.unjs.io",
10+
"repository": {
11+
"type": "git",
12+
"url": "git+https://github.com/unjs/unhead.git",
13+
"directory": "packages/schema-org"
14+
},
15+
"bugs": {
16+
"url": "https://github.com/unjs/unhead/issues"
17+
},
18+
"keywords": [
19+
"schema.org",
20+
"node",
21+
"seo"
22+
],
23+
"sideEffects": false,
24+
"exports": {
25+
".": {
26+
"types": "./dist/index.d.ts",
27+
"import": "./dist/index.mjs",
28+
"require": "./dist/index.cjs"
29+
},
30+
"./vue": {
31+
"types": "./dist/vue.d.ts",
32+
"import": "./dist/vue.mjs",
33+
"require": "./dist/vue.cjs"
34+
},
35+
"./legacy": {
36+
"types": "./dist/legacy.d.ts",
37+
"import": "./dist/legacy.mjs",
38+
"require": "./dist/legacy.cjs"
39+
},
40+
"./vue-legacy": {
41+
"types": "./dist/vue-legacy.d.ts",
42+
"import": "./dist/vue-legacy.mjs",
43+
"require": "./dist/vue-legacy.cjs"
44+
}
45+
},
46+
"main": "dist/index.cjs",
47+
"module": "dist/index.mjs",
48+
"types": "dist/index.d.ts",
49+
"typesVersions": {
50+
"*": {
51+
"vue": [
52+
"dist/vue"
53+
],
54+
"legacy": [
55+
"dist/legacy"
56+
],
57+
"vue-legacy": [
58+
"dist/vue-legacy"
59+
]
60+
}
61+
},
62+
"files": [
63+
"dist",
64+
"legacy.d.ts",
65+
"overrides.d.ts",
66+
"vue.d.ts"
67+
],
68+
"scripts": {
69+
"build": "unbuild .",
70+
"stub": "unbuild . --stub",
71+
"test": "vitest",
72+
"release": "bumpp package.json --commit --push --tag",
73+
"lint": "eslint \"{src,test}/**/*.{ts,vue,json,yml}\" --fix"
74+
},
75+
"peerDependencies": {
76+
"@unhead/shared": "workspace:*",
77+
"@unhead/vue": "workspace:*",
78+
"unhead": "workspace:*"
79+
},
80+
"peerDependenciesMeta": {
81+
"@unhead/vue": {
82+
"optional": true
83+
}
84+
},
85+
"build": {
86+
"external": [
87+
"vue"
88+
]
89+
},
90+
"devDependencies": {
91+
"@unhead/schema": "workspace:*",
92+
"@unhead/shared": "workspace:*",
93+
"@unhead/vue": "workspace:*",
94+
"unhead": "workspace:*",
95+
"unplugin-vue-components": "^0.27.5"
96+
}
97+
}

Diff for: ‎packages/scripts/src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './proxy'
2+
export * from './types'
3+
export * from './useScript'

Diff for: ‎packages/scripts/src/legacy.ts

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { UseScriptOptions as CurrentUseScriptOptions, ScriptInstance, UseFunctionType, UseScriptInput } from './types'
2+
import { useUnhead } from 'unhead'
3+
import { useScript as _useScript } from './useScript'
4+
5+
export interface UseScriptOptions<T extends BaseScriptApi = Record<string, any>> extends CurrentUseScriptOptions {
6+
/**
7+
* Stub the script instance. Useful for SSR or testing.
8+
*/
9+
stub?: ((ctx: { script: ScriptInstance<T>, fn: string | symbol }) => any)
10+
}
11+
12+
type BaseScriptApi = Record<symbol | string, any>
13+
14+
export type AsAsyncFunctionValues<T extends BaseScriptApi> = {
15+
[key in keyof T]:
16+
T[key] extends any[] ? T[key] :
17+
T[key] extends (...args: infer A) => infer R ? (...args: A) => R extends Promise<any> ? R : Promise<R> :
18+
T[key] extends Record<any, any> ? AsAsyncFunctionValues<T[key]> :
19+
never
20+
}
21+
22+
export type UseScriptContext<T extends Record<symbol | string, any>> =
23+
(Promise<T> & ScriptInstance<T>)
24+
& AsAsyncFunctionValues<T>
25+
& {
26+
/**
27+
* @deprecated Use top-level functions instead.
28+
*/
29+
$script: Promise<T> & ScriptInstance<T>
30+
}
31+
32+
const ScriptProxyTarget = Symbol('ScriptProxyTarget')
33+
function scriptProxy() {}
34+
scriptProxy[ScriptProxyTarget] = true
35+
36+
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
37+
const head = _options?.head || useUnhead()
38+
const script = _useScript(_input, _options) as any as UseScriptContext<T>
39+
// support deprecated behavior
40+
script.$script = script
41+
const proxyChain = (instance: any, accessor?: string | symbol, accessors?: (string | symbol)[]) => {
42+
return new Proxy((!accessor ? instance : instance?.[accessor]) || scriptProxy, {
43+
get(_, k, r) {
44+
// @ts-expect-error untyped
45+
head.hooks.callHook('script:instance-fn', { script, fn: k, exists: k in _ })
46+
if (!accessor) {
47+
const stub = _options?.stub?.({ script, fn: k })
48+
if (stub)
49+
return stub
50+
}
51+
if (_ && k in _ && typeof _[k] !== 'undefined') {
52+
return Reflect.get(_, k, r)
53+
}
54+
if (k === Symbol.iterator) {
55+
return [][Symbol.iterator]
56+
}
57+
return proxyChain(accessor ? instance?.[accessor] : instance, k, accessors || [k])
58+
},
59+
async apply(_, _this, args) {
60+
// we are faking, just return, avoid promise handles
61+
if (head.ssr && _[ScriptProxyTarget])
62+
return
63+
let instance: any
64+
const access = (fn?: T) => {
65+
instance = fn || instance
66+
for (let i = 0; i < (accessors || []).length; i++) {
67+
const k = (accessors || [])[i]
68+
fn = fn?.[k]
69+
}
70+
return fn
71+
}
72+
let fn = access(script.instance)
73+
if (!fn) {
74+
fn = await (new Promise<T | undefined>((resolve) => {
75+
script.onLoaded((api) => {
76+
resolve(access(api))
77+
})
78+
}))
79+
}
80+
return typeof fn === 'function' ? Reflect.apply(fn, instance, args) : fn
81+
},
82+
})
83+
}
84+
script.proxy = proxyChain(script.instance)
85+
return new Proxy(Object.assign(script._loadPromise, script), {
86+
get(_, k) {
87+
// _ keys are reserved for internal overrides
88+
const target = (k in script || String(k)[0] === '_') ? script : script.proxy
89+
if (k === 'then' || k === 'catch') {
90+
return script[k].bind(script)
91+
}
92+
return Reflect.get(target, k, target)
93+
},
94+
})
95+
}

Diff for: ‎packages/scripts/src/proxy.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { AsVoidFunctions, RecordingEntry } from './types'
2+
3+
export function createNoopedRecordingProxy<T extends Record<string, any>>(instance: T = {} as T): { proxy: AsVoidFunctions<T>, stack: RecordingEntry[][] } {
4+
const stack: RecordingEntry[][] = []
5+
6+
let stackIdx = -1
7+
const handler = (reuseStack = false) => ({
8+
get(_, prop, receiver) {
9+
if (!reuseStack) {
10+
const v = Reflect.get(_, prop, receiver)
11+
if (typeof v !== 'undefined') {
12+
return v
13+
}
14+
stackIdx++ // root get triggers a new stack
15+
stack[stackIdx] = []
16+
}
17+
stack[stackIdx].push({ type: 'get', key: prop })
18+
// @ts-expect-error untyped
19+
return new Proxy(() => {}, handler(true))
20+
},
21+
apply(_, __, args) {
22+
stack[stackIdx].push({ type: 'apply', key: '', args })
23+
return undefined
24+
},
25+
} as ProxyHandler<T>)
26+
27+
return {
28+
proxy: new Proxy(instance || {}, handler()),
29+
stack,
30+
}
31+
}
32+
33+
export function createForwardingProxy<T extends Record<string, any>>(target: T): AsVoidFunctions<T> {
34+
const handler: ProxyHandler<T> = {
35+
get(_, prop, receiver) {
36+
const v = Reflect.get(_, prop, receiver)
37+
if (typeof v === 'object') {
38+
return new Proxy(v, handler)
39+
}
40+
return v
41+
},
42+
apply(_, __, args) {
43+
// does not return the apply output for consistency
44+
// @ts-expect-error untyped
45+
Reflect.apply(_, __, args)
46+
return undefined
47+
},
48+
}
49+
return new Proxy(target, handler) as AsVoidFunctions<T>
50+
}
51+
52+
export function replayProxyRecordings<T extends object>(target: T, stack: RecordingEntry[][]) {
53+
stack.forEach((recordings) => {
54+
let context: any = target
55+
let prevContext: any = target
56+
recordings.forEach(({ type, key, args }) => {
57+
if (type === 'get') {
58+
prevContext = context
59+
context = context[key]
60+
}
61+
else if (type === 'apply') {
62+
// @ts-expect-error untyped
63+
context = (context as () => any).call(prevContext, ...args)
64+
}
65+
})
66+
})
67+
}

Diff for: ‎packages/schema/src/script.ts renamed to ‎packages/scripts/src/types.ts

+38-16
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,50 @@
1-
import type { ActiveHeadEntry, HeadEntryOptions } from './head'
2-
import type { Script } from './schema'
1+
import type { ActiveHeadEntry, HeadEntryOptions, Script } from '@unhead/schema'
32

43
export type UseScriptStatus = 'awaitingLoad' | 'loading' | 'loaded' | 'error' | 'removed'
54

5+
export type UseScriptContext<T extends Record<symbol | string, any>> = ScriptInstance<T>
66
/**
77
* Either a string source for the script or full script properties.
88
*/
99
export type UseScriptInput = string | (Omit<Script, 'src'> & { src: string })
1010
export type UseScriptResolvedInput = Omit<Script, 'src'> & { src: string }
1111
type BaseScriptApi = Record<symbol | string, any>
1212

13-
export type AsAsyncFunctionValues<T extends BaseScriptApi> = {
13+
export type AsVoidFunctions<T extends BaseScriptApi> = {
1414
[key in keyof T]:
1515
T[key] extends any[] ? T[key] :
16-
T[key] extends (...args: infer A) => infer R ? (...args: A) => R extends Promise<any> ? R : Promise<R> :
17-
T[key] extends Record<any, any> ? AsAsyncFunctionValues<T[key]> :
16+
T[key] extends (...args: infer A) => any ? (...args: A) => void :
17+
T[key] extends Record<any, any> ? AsVoidFunctions<T[key]> :
1818
never
1919
}
2020

21+
export type UseFunctionType<T, U> = T extends {
22+
use: infer V
23+
} ? V extends (...args: any) => any ? ReturnType<V> : U : U
24+
25+
export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch'
26+
2127
export interface ScriptInstance<T extends BaseScriptApi> {
22-
proxy: AsAsyncFunctionValues<T>
28+
proxy: AsVoidFunctions<T>
2329
instance?: T
2430
id: string
25-
status: UseScriptStatus
31+
status: Readonly<UseScriptStatus>
2632
entry?: ActiveHeadEntry<any>
2733
load: () => Promise<T>
34+
warmup: (rel: WarmupStrategy) => ActiveHeadEntry<any>
2835
remove: () => boolean
2936
setupTriggerHandler: (trigger: UseScriptOptions['trigger']) => void
3037
// cbs
31-
onLoaded: (fn: (instance: T) => void | Promise<void>) => void
32-
onError: (fn: (err?: Error) => void | Promise<void>) => void
38+
onLoaded: (fn: (instance: T) => void | Promise<void>, options?: EventHandlerOptions) => void
39+
onError: (fn: (err?: Error) => void | Promise<void>, options?: EventHandlerOptions) => void
40+
/**
41+
* @internal
42+
*/
43+
_loadPromise: Promise<T | false>
44+
/**
45+
* @internal
46+
*/
47+
_warmupEl: any
3348
/**
3449
* @internal
3550
*/
@@ -51,19 +66,22 @@ export interface ScriptInstance<T extends BaseScriptApi> {
5166
}
5267
}
5368

54-
export type UseFunctionType<T, U> = T extends {
55-
use: infer V
56-
} ? V extends (...args: any) => any ? ReturnType<V> : U : U
69+
export interface EventHandlerOptions {
70+
/**
71+
* Used to dedupe the event, allowing you to have an event run only a single time.
72+
*/
73+
key?: string
74+
}
75+
76+
export type RecordingEntry =
77+
| { type: 'get', key: string | symbol, args?: any[], value?: any }
78+
| { type: 'apply', key: string | symbol, args: any[] }
5779

5880
export interface UseScriptOptions<T extends BaseScriptApi = Record<string, any>> extends HeadEntryOptions {
5981
/**
6082
* Resolve the script instance from the window.
6183
*/
6284
use?: () => T | undefined | null
63-
/**
64-
* Stub the script instance. Useful for SSR or testing.
65-
*/
66-
stub?: ((ctx: { script: ScriptInstance<T>, fn: string | symbol }) => any)
6785
/**
6886
* The trigger to load the script:
6987
* - `undefined` | `client` - (Default) Load the script on the client when this js is loaded.
@@ -73,6 +91,10 @@ export interface UseScriptOptions<T extends BaseScriptApi = Record<string, any>>
7391
* - `server` - Have the script injected on the server.
7492
*/
7593
trigger?: 'client' | 'server' | 'manual' | Promise<boolean | void> | ((fn: any) => any) | null
94+
/**
95+
* Add a preload or preconnect link tag before the script is loaded.
96+
*/
97+
warmupStrategy?: WarmupStrategy
7698
/**
7799
* Context to run events with. This is useful in Vue to attach the current instance context before
78100
* calling the event, allowing the event to be reactive.

Diff for: ‎packages/unhead/src/composables/useScript.ts renamed to ‎packages/scripts/src/useScript.ts

+70-81
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,32 @@
11
import type {
2-
AsAsyncFunctionValues,
32
Head,
3+
} from '@unhead/schema'
4+
import type {
5+
EventHandlerOptions,
46
ScriptInstance,
57
UseFunctionType,
8+
UseScriptContext,
69
UseScriptInput,
710
UseScriptOptions,
811
UseScriptResolvedInput,
9-
} from '@unhead/schema'
12+
WarmupStrategy,
13+
} from './types'
1014
import { hashCode, ScriptNetworkEvents } from '@unhead/shared'
11-
import { useUnhead } from '../context'
12-
13-
export type UseScriptContext<T extends Record<symbol | string, any>> =
14-
(Promise<T> & ScriptInstance<T>)
15-
& AsAsyncFunctionValues<T>
16-
& {
17-
/**
18-
* @deprecated Use top-level functions instead.
19-
*/
20-
$script: Promise<T> & ScriptInstance<T>
21-
}
22-
23-
const ScriptProxyTarget = Symbol('ScriptProxyTarget')
24-
function scriptProxy() {}
25-
scriptProxy[ScriptProxyTarget] = true
15+
import { useUnhead } from 'unhead'
16+
import { createForwardingProxy, createNoopedRecordingProxy, replayProxyRecordings } from './proxy'
2617

2718
export function resolveScriptKey(input: UseScriptResolvedInput) {
2819
return input.key || hashCode(input.src || (typeof input.innerHTML === 'string' ? input.innerHTML : ''))
2920
}
3021

22+
const PreconnectServerModes = ['preconnect', 'dns-prefetch']
23+
3124
/**
3225
* Load third-party scripts with SSR support and a proxied API.
3326
*
3427
* @see https://unhead.unjs.io/usage/composables/use-script
3528
*/
36-
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>, U = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
29+
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
3730
const input: UseScriptResolvedInput = typeof _input === 'string' ? { src: _input } : _input
3831
const options = _options || {}
3932
const head = options.head || useUnhead()
@@ -60,7 +53,15 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
6053
})
6154

6255
const _cbs: ScriptInstance<T>['_cbs'] = { loaded: [], error: [] }
63-
const _registerCb = (key: 'loaded' | 'error', cb: any) => {
56+
const _uniqueCbs: Set<string> = new Set<string>()
57+
const _registerCb = (key: 'loaded' | 'error', cb: any, options?: EventHandlerOptions) => {
58+
if (options?.key) {
59+
const key = `${options?.key}:${options.key}`
60+
if (_uniqueCbs.has(key)) {
61+
return
62+
}
63+
_uniqueCbs.add(key)
64+
}
6465
if (_cbs[key]) {
6566
const i: number = _cbs[key].push(cb)
6667
return () => _cbs[key]?.splice(i - 1, 1)
@@ -98,15 +99,18 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
9899
})
99100
})
100101

101-
const script = Object.assign(loadPromise, <Partial<UseScriptContext<T>>> {
102+
const script = {
103+
_loadPromise: loadPromise,
102104
instance: (!head.ssr && options?.use?.()) || null,
103105
proxy: null,
104106
id,
105107
status: 'awaitingLoad',
108+
106109
remove() {
107110
// cancel any pending triggers as we've started loading
108111
script._triggerAbortController?.abort()
109112
script._triggerPromises = [] // clear any pending promises
113+
script._warmupEl?.dispose()
110114
if (script.entry) {
111115
script.entry.dispose()
112116
script.entry = undefined
@@ -116,6 +120,31 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
116120
}
117121
return false
118122
},
123+
warmup(rel: WarmupStrategy) {
124+
const { src } = input
125+
const isCrossOrigin = !src.startsWith('/') || src.startsWith('//')
126+
const isPreconnect = rel && PreconnectServerModes.includes(rel)
127+
let href = src
128+
if (!rel || (isPreconnect && !isCrossOrigin)) {
129+
return
130+
}
131+
if (isPreconnect) {
132+
const $url = new URL(src)
133+
href = `${$url.protocol}//${$url.host}`
134+
}
135+
const link: Required<Head>['link'][0] = {
136+
href,
137+
rel,
138+
crossorigin: input.crossorigin || isCrossOrigin ? 'anonymous' : undefined,
139+
referrerpolicy: input.referrerpolicy || isCrossOrigin ? 'no-referrer' : undefined,
140+
fetchpriority: input.fetchpriority || 'low',
141+
integrity: input.integrity,
142+
as: rel === 'preload' ? 'script' : undefined,
143+
}
144+
// @ts-expect-error untyped
145+
script._warmupEl = head.push({ link: [link] }, { head, tagPriority: 'high' })
146+
return script._warmupEl
147+
},
119148
load(cb?: () => void | Promise<void>) {
120149
// cancel any pending triggers as we've started loading
121150
script._triggerAbortController?.abort()
@@ -140,11 +169,11 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
140169
_registerCb('loaded', cb)
141170
return loadPromise
142171
},
143-
onLoaded(cb: (instance: T) => void | Promise<void>) {
144-
return _registerCb('loaded', cb)
172+
onLoaded(cb: (instance: T) => void | Promise<void>, options?: EventHandlerOptions) {
173+
return _registerCb('loaded', cb, options)
145174
},
146-
onError(cb: (err?: Error) => void | Promise<void>) {
147-
return _registerCb('error', cb)
175+
onError(cb: (err?: Error) => void | Promise<void>, options?: EventHandlerOptions) {
176+
return _registerCb('error', cb, options)
148177
},
149178
setupTriggerHandler(trigger: UseScriptOptions['trigger']) {
150179
if (script.status !== 'awaitingLoad') {
@@ -187,7 +216,7 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
187216
}
188217
},
189218
_cbs,
190-
}) as UseScriptContext<T>
219+
} as any as UseScriptContext<T>
191220
// script is ready
192221
loadPromise
193222
.then((api) => {
@@ -204,62 +233,22 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
204233
const hookCtx = { script }
205234

206235
script.setupTriggerHandler(options.trigger)
207-
// support deprecated behavior
208-
script.$script = script
209-
const proxyChain = (instance: any, accessor?: string | symbol, accessors?: (string | symbol)[]) => {
210-
return new Proxy((!accessor ? instance : instance?.[accessor]) || scriptProxy, {
211-
get(_, k, r) {
212-
head.hooks.callHook('script:instance-fn', { script, fn: k, exists: k in _ })
213-
if (!accessor) {
214-
const stub = options.stub?.({ script, fn: k })
215-
if (stub)
216-
return stub
217-
}
218-
if (_ && k in _ && typeof _[k] !== 'undefined') {
219-
return Reflect.get(_, k, r)
220-
}
221-
if (k === Symbol.iterator) {
222-
return [][Symbol.iterator]
223-
}
224-
return proxyChain(accessor ? instance?.[accessor] : instance, k, accessors || [k])
225-
},
226-
async apply(_, _this, args) {
227-
// we are faking, just return, avoid promise handles
228-
if (head.ssr && _[ScriptProxyTarget])
229-
return
230-
let instance: any
231-
const access = (fn?: T) => {
232-
instance = fn || instance
233-
for (let i = 0; i < (accessors || []).length; i++) {
234-
const k = (accessors || [])[i]
235-
fn = fn?.[k]
236-
}
237-
return fn
238-
}
239-
let fn = access(script.instance)
240-
if (!fn) {
241-
fn = await (new Promise<T | undefined>((resolve) => {
242-
script.onLoaded((api) => {
243-
resolve(access(api))
244-
})
245-
}))
246-
}
247-
return typeof fn === 'function' ? Reflect.apply(fn, instance, args) : fn
248-
},
236+
if (options.use) {
237+
const { proxy, stack } = createNoopedRecordingProxy<T>(options.use() || {} as T)
238+
script.proxy = proxy
239+
script.onLoaded((instance) => {
240+
replayProxyRecordings(instance, stack)
241+
// just forward everything with the same behavior
242+
script.proxy = createForwardingProxy(instance)
249243
})
250244
}
251-
script.proxy = proxyChain(script.instance)
252-
// remove in v2, just return the script
253-
const res = new Proxy(script, {
254-
get(_, k) {
255-
// _ keys are reserved for internal overrides
256-
const target = (k in script || String(k)[0] === '_') ? script : script.proxy
257-
if (k === 'then' || k === 'catch') {
258-
return script[k].bind(script)
259-
}
260-
return Reflect.get(target, k, target)
261-
},
262-
})
263-
head._scripts = Object.assign(head._scripts || {}, { [id]: res })
264-
return res
245+
// need to make sure it's not already registered
246+
if (!options.warmupStrategy && (typeof options.trigger === 'undefined' || options.trigger === 'client')) {
247+
options.warmupStrategy = 'preload'
248+
}
249+
if (options.warmupStrategy) {
250+
script.warmup(options.warmupStrategy)
251+
}
252+
head._scripts = Object.assign(head._scripts || {}, { [id]: script })
253+
return script
265254
}

Diff for: ‎packages/scripts/src/utils.ts

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { RecordingEntry } from './types'
2+
3+
export function createSpyProxy<T extends Record<string, any> | any[]>(target: T, onApply: (stack: RecordingEntry[][]) => void): T {
4+
const stack: RecordingEntry[][] = []
5+
6+
let stackIdx = -1
7+
const handler = (reuseStack = false) => ({
8+
get(_, prop, receiver) {
9+
if (!reuseStack) {
10+
stackIdx++ // root get triggers a new stack
11+
stack[stackIdx] = []
12+
}
13+
const v = Reflect.get(_, prop, receiver)
14+
if (typeof v === 'object' || typeof v === 'function') {
15+
stack[stackIdx].push({ type: 'get', key: prop })
16+
// @ts-expect-error untyped
17+
return new Proxy(v, handler(true))
18+
}
19+
stack[stackIdx].push({ type: 'get', key: prop, value: v })
20+
return v
21+
},
22+
apply(_, __, args) {
23+
stack[stackIdx].push({ type: 'apply', key: '', args })
24+
onApply(stack)
25+
// @ts-expect-error untyped
26+
return Reflect.apply(_, __, args)
27+
},
28+
} as ProxyHandler<T>)
29+
30+
return new Proxy(target, handler())
31+
}

Diff for: ‎packages/scripts/src/vue-legacy.ts

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type {
2+
DataKeys,
3+
HeadEntryOptions,
4+
SchemaAugmentations,
5+
ScriptBase,
6+
} from '@unhead/schema'
7+
import type { MaybeComputedRefEntriesOnly } from '@unhead/vue'
8+
import type { ComponentInternalInstance, Ref, WatchHandle } from 'vue'
9+
import type { UseScriptOptions as BaseUseScriptOptions, ScriptInstance, UseFunctionType, UseScriptStatus } from './types'
10+
import { injectHead } from '@unhead/vue'
11+
import { getCurrentInstance, isRef, onMounted, onScopeDispose, ref, watch } from 'vue'
12+
import { useScript as _useScript } from './legacy'
13+
14+
export interface VueScriptInstance<T extends Record<symbol | string, any>> extends Omit<ScriptInstance<T>, 'status'> {
15+
status: Ref<UseScriptStatus>
16+
}
17+
18+
export type UseScriptInput = string | (MaybeComputedRefEntriesOnly<Omit<ScriptBase & DataKeys & SchemaAugmentations['script'], 'src'>> & { src: string })
19+
export interface UseScriptOptions<T extends Record<symbol | string, any> = Record<string, any>> extends HeadEntryOptions, Pick<BaseUseScriptOptions<T>, 'use' | 'eventContext' | 'beforeInit'> {
20+
/**
21+
* The trigger to load the script:
22+
* - `undefined` | `client` - (Default) Load the script on the client when this js is loaded.
23+
* - `manual` - Load the script manually by calling `$script.load()`, exists only on the client.
24+
* - `Promise` - Load the script when the promise resolves, exists only on the client.
25+
* - `Function` - Register a callback function to load the script, exists only on the client.
26+
* - `server` - Have the script injected on the server.
27+
* - `ref` - Load the script when the ref is true.
28+
*/
29+
trigger?: BaseUseScriptOptions['trigger'] | Ref<boolean>
30+
}
31+
32+
export type UseScriptContext<T extends Record<symbol | string, any>> = Promise<T> & VueScriptInstance<T>
33+
34+
function registerVueScopeHandlers<T extends Record<symbol | string, any> = Record<symbol | string, any>>(script: UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>>, scope?: ComponentInternalInstance | null) {
35+
if (!scope) {
36+
return
37+
}
38+
const _registerCb = (key: 'loaded' | 'error', cb: any) => {
39+
if (!script._cbs[key]) {
40+
cb(script.instance)
41+
return () => {}
42+
}
43+
let i: number | null = script._cbs[key].push(cb)
44+
const destroy = () => {
45+
// avoid removing the wrong callback
46+
if (i) {
47+
script._cbs[key]?.splice(i - 1, 1)
48+
i = null
49+
}
50+
}
51+
onScopeDispose(destroy)
52+
return destroy
53+
}
54+
// if we have a scope we should make these callbacks reactive
55+
script.onLoaded = (cb: (instance: T) => void | Promise<void>) => _registerCb('loaded', cb)
56+
script.onError = (cb: (err?: Error) => void | Promise<void>) => _registerCb('error', cb)
57+
onScopeDispose(() => {
58+
// stop any trigger promises
59+
script._triggerAbortController?.abort()
60+
})
61+
}
62+
63+
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
64+
const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptInput
65+
const options = _options || {} as UseScriptOptions<T>
66+
const head = options?.head || injectHead()
67+
// @ts-expect-error untyped
68+
options.head = head
69+
const scope = getCurrentInstance()
70+
options.eventContext = scope
71+
if (scope && typeof options.trigger === 'undefined') {
72+
options.trigger = onMounted
73+
}
74+
else if (isRef(options.trigger)) {
75+
const refTrigger = options.trigger as Ref<boolean>
76+
let off: WatchHandle
77+
options.trigger = new Promise<boolean>((resolve) => {
78+
off = watch(refTrigger, (val) => {
79+
if (val) {
80+
resolve(true)
81+
}
82+
}, {
83+
immediate: true,
84+
})
85+
onScopeDispose(() => resolve(false), true)
86+
}).then((val) => {
87+
off?.()
88+
return val
89+
})
90+
}
91+
// we may be re-using an existing script
92+
// sync the status, need to register before useScript
93+
// @ts-expect-error untyped
94+
head._scriptStatusWatcher = head._scriptStatusWatcher || head.hooks.hook('script:updated', ({ script: s }) => {
95+
s._statusRef.value = s.status
96+
})
97+
// @ts-expect-error untyped
98+
const script = _useScript(input as BaseUseScriptInput, options)
99+
script._statusRef = script._statusRef || ref<UseScriptStatus>(script.status)
100+
// Note: we don't remove scripts on unmount as it's not a common use case and reloading the script may be expensive
101+
// @ts-expect-error untyped
102+
registerVueScopeHandlers(script, scope)
103+
return new Proxy(script, {
104+
get(_, key, a) {
105+
// we can't override status as it will break the unhead useScript API
106+
return Reflect.get(_, key === 'status' ? '_statusRef' : key, a)
107+
},
108+
}) as any as UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>>
109+
}

Diff for: ‎packages/scripts/src/vue/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useScript'

Diff for: ‎packages/vue/src/composables/useScript.ts renamed to ‎packages/scripts/src/vue/useScript.ts

+11-26
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,22 @@
11
import type {
2-
AsAsyncFunctionValues,
3-
UseScriptInput as BaseUseScriptInput,
4-
UseScriptOptions as BaseUseScriptOptions,
52
DataKeys,
63
HeadEntryOptions,
74
SchemaAugmentations,
85
ScriptBase,
9-
ScriptInstance,
10-
UseFunctionType,
11-
UseScriptResolvedInput,
12-
UseScriptStatus,
136
} from '@unhead/schema'
7+
import type { MaybeComputedRefEntriesOnly } from '@unhead/vue'
148
import type { ComponentInternalInstance, Ref, WatchHandle } from 'vue'
15-
import type { MaybeComputedRefEntriesOnly } from '../types'
16-
import { useScript as _useScript } from 'unhead'
9+
import type { UseScriptOptions as BaseUseScriptOptions, ScriptInstance, UseFunctionType, UseScriptStatus } from '../types'
10+
import { injectHead } from '@unhead/vue'
1711
import { getCurrentInstance, isRef, onMounted, onScopeDispose, ref, watch } from 'vue'
18-
import { injectHead } from './injectHead'
12+
import { useScript as _useScript } from '../useScript'
1913

2014
export interface VueScriptInstance<T extends Record<symbol | string, any>> extends Omit<ScriptInstance<T>, 'status'> {
2115
status: Ref<UseScriptStatus>
2216
}
2317

2418
export type UseScriptInput = string | (MaybeComputedRefEntriesOnly<Omit<ScriptBase & DataKeys & SchemaAugmentations['script'], 'src'>> & { src: string })
25-
export interface UseScriptOptions<T extends Record<symbol | string, any> = Record<string, any>, U = Record<string, any>> extends HeadEntryOptions, Pick<BaseUseScriptOptions<T>, 'use' | 'stub' | 'eventContext' | 'beforeInit'> {
19+
export interface UseScriptOptions<T extends Record<symbol | string, any> = Record<string, any>> extends HeadEntryOptions, Pick<BaseUseScriptOptions<T>, 'use' | 'eventContext' | 'beforeInit'> {
2620
/**
2721
* The trigger to load the script:
2822
* - `undefined` | `client` - (Default) Load the script on the client when this js is loaded.
@@ -35,17 +29,9 @@ export interface UseScriptOptions<T extends Record<symbol | string, any> = Recor
3529
trigger?: BaseUseScriptOptions['trigger'] | Ref<boolean>
3630
}
3731

38-
export type UseScriptContext<T extends Record<symbol | string, any>> =
39-
(Promise<T> & VueScriptInstance<T>)
40-
& AsAsyncFunctionValues<T>
41-
& {
42-
/**
43-
* @deprecated Use top-level functions instead.
44-
*/
45-
$script: Promise<T> & VueScriptInstance<T>
46-
}
32+
export type UseScriptContext<T extends Record<symbol | string, any>> = Promise<T> & VueScriptInstance<T>
4733

48-
function registerVueScopeHandlers<T extends Record<symbol | string, any> = Record<symbol | string, any>>(script: UseScriptContext<UseFunctionType<UseScriptOptions<T, any>, T>>, scope?: ComponentInternalInstance | null) {
34+
function registerVueScopeHandlers<T extends Record<symbol | string, any> = Record<symbol | string, any>>(script: UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>>, scope?: ComponentInternalInstance | null) {
4935
if (!scope) {
5036
return
5137
}
@@ -74,9 +60,9 @@ function registerVueScopeHandlers<T extends Record<symbol | string, any> = Recor
7460
})
7561
}
7662

77-
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>, U = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T, U>): UseScriptContext<UseFunctionType<UseScriptOptions<T, U>, T>> {
78-
const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptResolvedInput
79-
const options = _options || {}
63+
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
64+
const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptInput
65+
const options = _options || {} as UseScriptOptions<T>
8066
const head = options?.head || injectHead()
8167
// @ts-expect-error untyped
8268
options.head = head
@@ -106,7 +92,6 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
10692
// sync the status, need to register before useScript
10793
// @ts-expect-error untyped
10894
head._scriptStatusWatcher = head._scriptStatusWatcher || head.hooks.hook('script:updated', ({ script: s }) => {
109-
// @ts-expect-error untyped
11095
s._statusRef.value = s.status
11196
})
11297
// @ts-expect-error untyped
@@ -121,5 +106,5 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
121106
// we can't override status as it will break the unhead useScript API
122107
return Reflect.get(_, key === 'status' ? '_statusRef' : key, a)
123108
},
124-
}) as any as UseScriptContext<UseFunctionType<UseScriptOptions<T, U>, T>>
109+
}) as any as UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>>
125110
}

Diff for: ‎test/unhead/dom/useScript.test.ts renamed to ‎packages/scripts/test/unit/dom.test.ts

+18-36
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,40 @@
1-
import { useScript } from 'unhead'
21
import { describe, it } from 'vitest'
3-
import { useDelayedSerializedDom, useDOMHead } from './util'
2+
import { useDelayedSerializedDom, useDOMHead } from '../../../../test/unhead/dom/util'
3+
import { useScript } from '../../src/useScript'
44

55
describe('dom useScript', () => {
66
it('basic', async () => {
7-
const head = useDOMHead()
7+
useDOMHead()
88

9-
const instance = useScript<{ test: (foo: string) => void }>({
9+
let calledFn
10+
const instance = useScript({
1011
src: 'https://cdn.example.com/script.js',
1112
}, {
1213
use() {
1314
return {
14-
test: () => {},
15+
test: () => {
16+
calledFn = 'test'
17+
return 'foo'
18+
},
1519
}
1620
},
1721
})
1822

19-
expect(await useDelayedSerializedDom()).toMatchInlineSnapshot(`
20-
"<!DOCTYPE html><html><head>
21-
22-
<script data-onload="" data-onerror="" defer="" fetchpriority="low" crossorigin="anonymous" referrerpolicy="no-referrer" src="https://cdn.example.com/script.js" data-hid="c5c65b0"></script></head>
23-
<body>
24-
25-
<div>
26-
<h1>hello world</h1>
27-
</div>
28-
29-
30-
31-
</body></html>"
23+
expect((await useDelayedSerializedDom()).split('\n').filter(l => l.startsWith('<link'))).toMatchInlineSnapshot(`
24+
[
25+
"<link href="https://cdn.example.com/script.js" rel="preload" crossorigin="anonymous" referrerpolicy="no-referrer" fetchpriority="low" as="script"><script data-onload="" data-onerror="" defer="" fetchpriority="low" crossorigin="anonymous" referrerpolicy="no-referrer" src="https://cdn.example.com/script.js" data-hid="c5c65b0"></script></head>",
26+
]
3227
`)
3328

34-
let calledFn
35-
const hookPromise = new Promise<void>((resolve) => {
36-
head.hooks.hook('script:instance-fn', ({ script, fn }) => {
37-
if (script.id === instance.$script.id) {
38-
calledFn = fn
39-
resolve()
40-
}
41-
})
42-
})
43-
instance.test('hello-world')
44-
await hookPromise
29+
instance.proxy.test('hello-world')
4530
expect(calledFn).toBe('test')
4631
})
4732
it('proxy', async () => {
48-
useDOMHead()
33+
const head = useDOMHead()
4934

5035
const instance = useScript<{ test: (foo: string) => string }>({
5136
src: 'https://cdn.example.com/script.js',
37+
head,
5238
}, {
5339
use() {
5440
return {
@@ -57,7 +43,7 @@ describe('dom useScript', () => {
5743
},
5844
})
5945

60-
expect(await instance.proxy.test('hello-world')).toEqual('hello-world')
46+
expect(instance.proxy.test('hello-world')).toEqual('hello-world')
6147
})
6248
it('remove & re-add', async () => {
6349
useDOMHead()
@@ -67,11 +53,7 @@ describe('dom useScript', () => {
6753
})
6854

6955
let dom = await useDelayedSerializedDom()
70-
expect(dom.split('\n').filter(l => l.trim().startsWith('<script'))).toMatchInlineSnapshot(`
71-
[
72-
"<script data-onload="" data-onerror="" defer="" fetchpriority="low" crossorigin="anonymous" referrerpolicy="no-referrer" src="https://cdn.example.com/script.js" data-hid="c5c65b0"></script></head>",
73-
]
74-
`)
56+
expect(dom.split('\n').filter(l => l.trim().startsWith('<script'))).toMatchInlineSnapshot(`[]`)
7557
instance.remove()
7658
// wait
7759
await new Promise(r => setTimeout(r, 100))

Diff for: ‎test/unhead/e2e/scripts.test.ts renamed to ‎packages/scripts/test/unit/e2e.test.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { renderDOMHead } from '@unhead/dom'
22
import { renderSSRHead } from '@unhead/ssr'
3-
import { useHead, useScript } from 'unhead'
3+
import { useHead } from 'unhead'
44
import { describe, it } from 'vitest'
5-
import { useDom } from '../../fixtures'
6-
import { createHeadWithContext } from '../../util'
5+
import { useDom } from '../../../../test/fixtures'
6+
import { createHeadWithContext, createServerHeadWithContext } from '../../../../test/util'
7+
import { useScript } from '../../src/useScript'
78

89
describe('unhead e2e scripts', () => {
910
it('does not duplicate innerHTML', async () => {
1011
// scenario: we are injecting root head schema which will not have a hydration step,
1112
// but we are also injecting a child head schema which will have a hydration step
12-
const ssrHead = createHeadWithContext()
13+
const ssrHead = createServerHeadWithContext()
1314
const input = {
1415
script: [
1516
{

Diff for: ‎packages/scripts/test/unit/events.test.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// @vitest-environment jsdom
2+
3+
import { describe, it } from 'vitest'
4+
import { createHeadWithContext } from '../../../../test/util'
5+
import { useScript } from '../../src/useScript'
6+
7+
describe('useScript events', () => {
8+
it('simple', async () => {
9+
createHeadWithContext()
10+
const instance = useScript('/script.js', {
11+
trigger: 'server',
12+
})
13+
expect(await new Promise<true>((resolve) => {
14+
instance.status = 'loaded'
15+
instance.onLoaded(() => {
16+
resolve(true)
17+
})
18+
})).toBeTruthy()
19+
})
20+
it('dedupe', async () => {
21+
createHeadWithContext()
22+
const instance = useScript('/script.js', {
23+
trigger: 'server',
24+
})
25+
const calls: any[] = []
26+
instance.onLoaded(() => {
27+
calls.push('a')
28+
}, {
29+
key: 'once',
30+
})
31+
instance.onLoaded(() => {
32+
calls.push('b')
33+
}, {
34+
key: 'once',
35+
})
36+
instance.status = 'loaded'
37+
await new Promise<void>((resolve) => {
38+
instance.onLoaded(() => {
39+
calls.push('c')
40+
resolve()
41+
})
42+
})
43+
expect(calls).toMatchInlineSnapshot(`
44+
[
45+
"a",
46+
"c",
47+
]
48+
`)
49+
})
50+
})

Diff for: ‎packages/scripts/test/unit/proxy.test.ts

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import type { AsVoidFunctions } from '../../src'
2+
import { describe, expect, expectTypeOf, it } from 'vitest'
3+
import { createServerHeadWithContext } from '../../../../test/util'
4+
import { createForwardingProxy } from '../../src'
5+
import { createNoopedRecordingProxy, replayProxyRecordings } from '../../src/proxy'
6+
import { useScript } from '../../src/useScript'
7+
import { createSpyProxy } from '../../src/utils'
8+
9+
interface Api {
10+
_paq: any[]
11+
doSomething: () => Promise<'foo'>
12+
say: (message: string) => string
13+
foo: {
14+
bar: {
15+
fn: () => true
16+
}
17+
}
18+
}
19+
20+
describe('proxy chain', () => {
21+
it('augments types', () => {
22+
const proxy = createNoopedRecordingProxy<Api>()
23+
expectTypeOf(proxy.proxy._paq).toBeArray()
24+
expectTypeOf(proxy.proxy.doSomething).toBeFunction()
25+
expectTypeOf(proxy.proxy.doSomething).returns.toBeVoid()
26+
expectTypeOf(proxy.proxy.say).parameter(0).toBeString()
27+
expectTypeOf(proxy.proxy.foo.bar.fn).toBeFunction()
28+
})
29+
it('e2e', async () => {
30+
// do recording
31+
const { proxy, stack } = createNoopedRecordingProxy<Api>()
32+
const script = { proxy, instance: null }
33+
script.proxy._paq.push(['test'])
34+
script.proxy.say('hello world')
35+
expect(stack.length).toBe(2)
36+
let called
37+
const w: any = {
38+
_paq: createSpyProxy([], () => {
39+
called = true
40+
}),
41+
say: (s: string) => {
42+
console.log(s)
43+
return s
44+
},
45+
}
46+
// did load
47+
script.instance = {
48+
_paq: w._paq,
49+
say: w.say,
50+
}
51+
const log = console.log
52+
// replay recording
53+
const consoleMock = vi.spyOn(console, 'log').mockImplementation((...args) => {
54+
log('mocked', ...args)
55+
})
56+
replayProxyRecordings(script.instance, stack)
57+
// @ts-expect-error untyped
58+
script.proxy = createForwardingProxy(script.instance)
59+
expect(consoleMock).toHaveBeenCalledWith('hello world')
60+
script.proxy.say('proxy updated!')
61+
expect(consoleMock).toHaveBeenCalledWith('proxy updated!')
62+
expect(script.instance).toMatchInlineSnapshot(`
63+
{
64+
"_paq": [
65+
[
66+
"test",
67+
],
68+
],
69+
"say": [Function],
70+
}
71+
`)
72+
script.proxy._paq.push(['test'])
73+
consoleMock.mockReset()
74+
expect(called).toBe(true)
75+
})
76+
it('spy', () => {
77+
const w: any = {}
78+
w._paq = []
79+
const stack: any[] = []
80+
w._paq = createSpyProxy(w._paq, (s) => {
81+
stack.push(s)
82+
})
83+
w._paq.push(['test'])
84+
expect(stack).toMatchInlineSnapshot(`
85+
[
86+
[
87+
[
88+
{
89+
"key": "push",
90+
"type": "get",
91+
},
92+
{
93+
"args": [
94+
[
95+
"test",
96+
],
97+
],
98+
"key": "",
99+
"type": "apply",
100+
},
101+
],
102+
[
103+
{
104+
"key": "length",
105+
"type": "get",
106+
"value": 0,
107+
},
108+
],
109+
],
110+
]
111+
`)
112+
})
113+
it('use() provided', () => {
114+
const head = createServerHeadWithContext()
115+
const instance = useScript({
116+
src: 'https://cdn.example.com/script.js',
117+
head,
118+
}, {
119+
use() {
120+
return {
121+
greet: (foo: string) => {
122+
console.log(foo)
123+
return foo
124+
},
125+
}
126+
},
127+
})
128+
instance.onLoaded((vm) => {
129+
vm.greet('hello-world')
130+
})
131+
const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined)
132+
expectTypeOf(instance.proxy.greet).toBeFunction()
133+
instance.proxy.greet('hello-world')
134+
expect(consoleMock).toHaveBeenCalledWith('hello-world')
135+
})
136+
})
137+
138+
describe('types: AsVoidFunctions', () => {
139+
it('should keep array properties unchanged', () => {
140+
type Result = AsVoidFunctions<Api>
141+
expectTypeOf<Result['_paq']>().toEqualTypeOf<any[]>()
142+
})
143+
144+
it('should convert function properties to void functions', () => {
145+
type Result = AsVoidFunctions<Api>
146+
expectTypeOf<Result['doSomething']>().toBeFunction()
147+
expectTypeOf<Result['doSomething']>().returns.toBeVoid()
148+
expectTypeOf<Result['say']>().toBeFunction()
149+
expectTypeOf<Result['say']>().parameters.toEqualTypeOf<[string]>()
150+
expectTypeOf<Result['say']>().returns.toBeVoid()
151+
})
152+
153+
it('should recursively convert nested function properties to void functions', () => {
154+
type Result = AsVoidFunctions<Api>
155+
expectTypeOf<Result['foo']['bar']['fn']>().toBeFunction()
156+
expectTypeOf<Result['foo']['bar']['fn']>().returns.toBeVoid()
157+
})
158+
})

Diff for: ‎test/unhead/ssr/useScript.test.ts renamed to ‎packages/scripts/test/unit/ssr.test.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { renderSSRHead } from '@unhead/ssr'
2-
import { useScript } from 'unhead'
3-
import { describe, it } from 'vitest'
4-
import { createHeadWithContext, createServerHeadWithContext } from '../../util'
2+
import { createHeadWithContext, createServerHeadWithContext } from '../../../../test/util'
3+
import { useScript } from '../../src/useScript'
54

65
describe('ssr useScript', () => {
76
it('default', async () => {
@@ -17,7 +16,7 @@ describe('ssr useScript', () => {
1716
"bodyAttrs": "",
1817
"bodyTags": "",
1918
"bodyTagsOpen": "",
20-
"headTags": "",
19+
"headTags": "<link href="https://cdn.example.com/script.js" rel="preload" crossorigin="anonymous" referrerpolicy="no-referrer" fetchpriority="low" as="script">",
2120
"htmlAttrs": "",
2221
}
2322
`)
@@ -65,7 +64,7 @@ describe('ssr useScript', () => {
6564
})
6665
it('google ', async () => {
6766
const head = createServerHeadWithContext()
68-
67+
const window: any = {}
6968
const gtag = useScript<{ dataLayer: any[] }>({
7069
src: 'https://www.googletagmanager.com/gtm.js?id=GTM-MNJD4B',
7170
}, {

Diff for: ‎packages/scripts/test/unit/use.test.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
import { createServerHeadWithContext } from '../../../../test/util'
3+
import { useScript } from '../../src/useScript'
4+
5+
describe('useScript', () => {
6+
it('types: inferred use()', async () => {
7+
const instance = useScript({
8+
src: 'https://cdn.example.com/script.js',
9+
}, {
10+
head: createServerHeadWithContext(),
11+
use() {
12+
return {
13+
// eslint-disable-next-line unused-imports/no-unused-vars
14+
test: (foo: string) => 'foo',
15+
}
16+
},
17+
})
18+
expectTypeOf(instance.proxy.test).toBeFunction()
19+
expectTypeOf(instance.proxy.test).parameter(0).toBeString()
20+
expectTypeOf(instance.proxy.test).returns.toBeVoid()
21+
})
22+
})

Diff for: ‎packages/vue/test/e2e/scripts.test.ts renamed to ‎packages/scripts/test/unit/vue.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { useScript } from '@unhead/vue'
21
import { createHead } from '@unhead/vue/client'
32
import { describe, it } from 'vitest'
43
import { ref, watch } from 'vue'
54
import { useDom } from '../../../../test/fixtures'
5+
import { useScript } from '../../src/vue/useScript'
66

7-
describe('unhead vue e2e scripts', () => {
7+
describe('vue e2e scripts', () => {
88
it('multiple active promise handles', async () => {
99
const dom = useDom()
1010
const head = createHead({

Diff for: ‎packages/scripts/test/unit/warmup.test.ts

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { LinkBase } from 'zhead'
2+
import { describe, it } from 'vitest'
3+
import { createServerHeadWithContext } from '../../../../test/util'
4+
import { useScript } from '../../src/useScript'
5+
6+
describe('warmup', () => {
7+
it('server', () => {
8+
const head = createServerHeadWithContext()
9+
useScript('https://cdn.example.com/script.js', {
10+
head,
11+
trigger: 'server',
12+
})
13+
const entry = head.headEntries()[0]!.input
14+
expect(entry.script[0].src).toBe('https://cdn.example.com/script.js')
15+
expect(entry.link).toBeUndefined()
16+
})
17+
it('default / client', () => {
18+
const head = createServerHeadWithContext()
19+
useScript('https://cdn.example.com/script.js', {
20+
head,
21+
trigger: 'client',
22+
})
23+
const link = head.headEntries()[0]!.input!.link![0] as LinkBase
24+
expect(link.href).toEqual('https://cdn.example.com/script.js')
25+
expect(link.rel).toEqual('preload')
26+
})
27+
it('relative: default / client', () => {
28+
const head = createServerHeadWithContext()
29+
useScript('/script.js', {
30+
head,
31+
trigger: 'client',
32+
})
33+
const link = head.headEntries()[0]!.input!.link![0] as LinkBase
34+
expect(link.href).toEqual('/script.js')
35+
expect(link.rel).toEqual('preload')
36+
})
37+
it('absolute: dns-prefetch', () => {
38+
const head = createServerHeadWithContext()
39+
useScript('https://cdn.example.com/script.js', {
40+
head,
41+
trigger: 'client',
42+
warmupStrategy: 'dns-prefetch',
43+
})
44+
const link = head.headEntries()[0]!.input!.link![0] as LinkBase
45+
expect(link.href).toEqual('https://cdn.example.com')
46+
expect(link.rel).toEqual('dns-prefetch')
47+
})
48+
})

Diff for: ‎packages/scripts/vue-legacy.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './dist/legacy'

Diff for: ‎packages/scripts/vue.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './dist/vue'

Diff for: ‎packages/unhead/src/index.ts

-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ export * from './autoImports'
22

33
export * from './composables/useHead'
44
export * from './composables/useHeadSafe'
5-
export * from './composables/useScript'
65
export * from './composables/useSeoMeta'
76
export * from './composables/useServerHead'
87
export * from './composables/useServerHeadSafe'

Diff for: ‎packages/vue/src/index.ts

-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export * from './composables/injectHead'
1717
export * from './composables/useHead'
1818

1919
export * from './composables/useHeadSafe'
20-
export * from './composables/useScript'
2120
export * from './composables/useSeoMeta'
2221
export * from './composables/useServerHead'
2322
export * from './composables/useServerHeadSafe'

Diff for: ‎pnpm-lock.yaml

+51
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.