Skip to content

Commit 3e64bde

Browse files
authored
feat(scripts): deprecate implicit proxy and $script (#379)
* feat(scripts): drop implicit proxying * fix: chain proxy * fix: chain proxy * chore: lint * fix: proxy not queueing * fix: misc broken things * chore: re-add hook * doc: remove artifact * chore: tidy pr * chore: bump snapshots
1 parent b6ca341 commit 3e64bde

File tree

8 files changed

+347
-203
lines changed

8 files changed

+347
-203
lines changed

docs/content/1.usage/2.composables/4.use-script.md

Lines changed: 129 additions & 134 deletions
Large diffs are not rendered by default.

examples/vite-ssr-vue/src/pages/fathom.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts" setup>
2+
import { onMounted } from 'vue'
23
import { useScript } from '@unhead/vue'
34
45
export interface FathomAnalyticsApi {
@@ -8,13 +9,13 @@ export interface FathomAnalyticsApi {
89
isTrackingEnabled: () => boolean
910
send: (type: string, data: unknown) => void
1011
setSite: (siteId: string) => void
11-
sideId: string
12+
siteId: string
1213
trackPageview: (ctx?: { url: string, referrer?: string }) => void
1314
trackGoal: (goalId: string, cents: number) => void
1415
trackEvent: (eventName: string, value: { _value: number }) => void
1516
}
1617
17-
const { trackPageview, blockTrackingForMe } = useScript<FathomAnalyticsApi>({
18+
const { trackPageview, blockTrackingForMe, siteId } = useScript<FathomAnalyticsApi>({
1819
src: 'https://cdn.usefathom.com/script.js',
1920
['data-site']: 'KGILBQDV',
2021
}, {
@@ -28,6 +29,9 @@ trackPageview({
2829
url: '/test',
2930
referrer: '',
3031
})
32+
onMounted(async () => {
33+
console.log(siteId, await siteId())
34+
})
3135
</script>
3236
<template>
3337
<div>test</div>

examples/vite-ssr-vue/src/pages/gtag.vue

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
<script setup lang="ts">
22
import { useScript } from '@unhead/vue'
33
4-
const { dataLayer, $script } = useScript<{ dataLayer: any[] }>({
4+
const gtag = useScript<{ dataLayer: any[] }>({
55
src: 'https://www.googletagmanager.com/gtm.js?id=GTM-MNJD4B',
66
}, {
7-
stub({ fn }) {
8-
return fn === 'dataLayer' && typeof window === 'undefined' ? [] : undefined
9-
},
107
beforeInit() {
118
if (typeof window !== 'undefined') {
129
window.dataLayer = window.dataLayer || []
@@ -20,13 +17,17 @@ const { dataLayer, $script } = useScript<{ dataLayer: any[] }>({
2017
},
2118
trigger: typeof window !== 'undefined' ? window.requestIdleCallback : 'manual'
2219
})
20+
const { $script } = gtag
21+
const dataLayer = gtag.proxy
22+
23+
const status = gtag.status
2324
2425
dataLayer.push({
2526
event: 'page_view',
2627
page_path: '/stripe',
2728
})
2829
29-
$script.then((res) => {
30+
gtag.then((res) => {
3031
console.log('ready!', res)
3132
})
3233
@@ -39,7 +40,7 @@ useHead({
3940
<div>
4041
<h1>gtm</h1>
4142
<div>
42-
script status: {{ $script.status }}
43+
script status: {{ status }}
4344
</div>
4445
<div>
4546
data layer:

packages/schema/src/script.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,27 @@ export type UseScriptStatus = 'awaitingLoad' | 'loading' | 'loaded' | 'error' |
88
*/
99
export type UseScriptInput = string | (Omit<Script, 'src'> & { src: string })
1010
export type UseScriptResolvedInput = Omit<Script, 'src'> & { src: string }
11+
type BaseScriptApi = Record<symbol | string, any>
1112

12-
export interface ScriptInstance<T> {
13+
export type AsAsyncFunctionValues<T extends BaseScriptApi> = {
14+
[key in keyof T]:
15+
// arays return literals
16+
T[key] extends any[] ? T[key] :
17+
T[key] extends object ? AsAsyncFunctionValues<T[key]> :
18+
T[key] extends (...args: infer A) => infer R ? (...args: A) => Promise<R> : () => Promise<T[key]>
19+
}
20+
21+
export interface ScriptInstance<T extends BaseScriptApi> {
22+
proxy: AsAsyncFunctionValues<T>
23+
instance?: T
1324
id: string
1425
status: UseScriptStatus
1526
entry?: ActiveHeadEntry<any>
1627
load: () => Promise<T>
1728
remove: () => boolean
1829
}
1930

20-
export interface UseScriptOptions<T> extends HeadEntryOptions {
31+
export interface UseScriptOptions<T extends BaseScriptApi> extends HeadEntryOptions {
2132
/**
2233
* Resolve the script instance from the window.
2334
*/

packages/unhead/src/composables/useScript.ts

Lines changed: 79 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ScriptNetworkEvents, hashCode } from '@unhead/shared'
22
import type {
3+
AsAsyncFunctionValues,
34
Head,
45
ScriptInstance,
56
UseScriptInput,
@@ -8,20 +9,37 @@ import type {
89
} from '@unhead/schema'
910
import { getActiveHead } from './useActiveHead'
1011

12+
export type UseScriptContext<T extends Record<symbol | string, any>> =
13+
(Promise<T> & ScriptInstance<T>)
14+
& AsAsyncFunctionValues<T>
15+
& {
16+
/**
17+
* @deprecated Use top-level functions instead.
18+
*/
19+
$script: Promise<T> & ScriptInstance<T>
20+
}
21+
22+
const ScriptProxyTarget = Symbol('ScriptProxyTarget')
23+
function sharedTarget() {}
24+
sharedTarget[ScriptProxyTarget] = true
25+
26+
export function resolveScriptKey(input: UseScriptResolvedInput) {
27+
return input.key || hashCode(input.src || (typeof input.innerHTML === 'string' ? input.innerHTML : ''))
28+
}
29+
1130
/**
1231
* Load third-party scripts with SSR support and a proxied API.
1332
*
14-
* @experimental
1533
* @see https://unhead.unjs.io/usage/composables/use-script
1634
*/
17-
export function useScript<T extends Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): T & { $script: Promise<T> & ScriptInstance<T> } {
35+
export function useScript<T extends Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<T> {
1836
const input: UseScriptResolvedInput = typeof _input === 'string' ? { src: _input } : _input
1937
const options = _options || {}
2038
const head = options.head || getActiveHead()
2139
if (!head)
2240
throw new Error('Missing Unhead context.')
2341

24-
const id = input.key || hashCode(input.src || (typeof input.innerHTML === 'string' ? input.innerHTML : ''))
42+
const id = resolveScriptKey(input)
2543
if (head._scripts?.[id])
2644
return head._scripts[id]
2745
options.beforeInit?.()
@@ -39,8 +57,10 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
3957
}
4058
})
4159

42-
const proxy = { instance: (!head.ssr && options?.use?.()) || {} } as { instance: T, $script: ScriptInstance<T> }
4360
const loadPromise = new Promise<T>((resolve, reject) => {
61+
// promise never resolves
62+
if (head.ssr)
63+
return
4464
const emit = (api: T) => requestAnimationFrame(() => resolve(api))
4565
const _ = head.hooks.hook('script:updated', ({ script }) => {
4666
if (script.id === id && (script.status === 'loaded' || script.status === 'error')) {
@@ -60,8 +80,10 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
6080
_()
6181
}
6282
})
63-
}).then(api => (proxy.instance = api))
64-
const script: ScriptInstance<T> = {
83+
})
84+
const script = Object.assign(loadPromise, {
85+
instance: (!head.ssr && options?.use?.()) || null,
86+
proxy: null,
6587
id,
6688
status: 'awaitingLoad',
6789
remove() {
@@ -92,7 +114,8 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
92114
}
93115
return loadPromise
94116
},
95-
}
117+
}) as any as UseScriptContext<T>
118+
loadPromise.then(api => (script.instance = api))
96119
const hookCtx = { script }
97120
if ((trigger === 'client' && !head.ssr) || (trigger === 'server' && head.ssr))
98121
script.load()
@@ -101,31 +124,54 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
101124
else if (typeof trigger === 'function')
102125
trigger(async () => script.load())
103126

104-
// 3. Proxy the script API
105-
proxy.$script = Object.assign(loadPromise, script)
106-
const instance = new Proxy<{ instance: T }>(proxy, {
107-
get({ instance: _ }, k) {
108-
const stub = options.stub?.({ script: proxy.$script, fn: k })
109-
if (stub)
110-
return stub
111-
if (k === '$script')
112-
return proxy.$script
113-
const exists = _ && k in _ && _[k] !== undefined
114-
head.hooks.callHook('script:instance-fn', { script, fn: k, exists })
115-
return exists
116-
? Reflect.get(_, k)
117-
: (...args: any[]) => loadPromise.then((api) => {
118-
const _k = Reflect.get(api, k)
119-
return typeof _k === 'function'
120-
? Reflect.apply(api[k], api, args)
121-
: _k
122-
})
127+
// support deprecated behavior
128+
script.$script = script
129+
const proxyChain = (instance: any, accessor?: string | symbol, accessors?: (string | symbol)[]) => {
130+
return new Proxy((!accessor ? instance : instance?.[accessor]) || sharedTarget, {
131+
get(_, k, r) {
132+
head.hooks.callHook('script:instance-fn', { script, fn: k, exists: k in _ })
133+
if (!accessor) {
134+
const stub = options.stub?.({ script, fn: k })
135+
if (stub)
136+
return stub
137+
}
138+
if (_ && k in _) {
139+
return Reflect.get(_, k, r)
140+
}
141+
if (k === Symbol.iterator) {
142+
return [][Symbol.iterator]
143+
}
144+
return proxyChain(accessor ? instance?.[accessor] : instance, k, accessors || [k])
145+
},
146+
async apply(_, _this, args) {
147+
// we are faking, just return, avoid promise handles
148+
if (head.ssr && _[ScriptProxyTarget])
149+
return
150+
let instance: any
151+
const access = (fn?: T) => {
152+
instance = fn || instance
153+
for (let i = 0; i < (accessors || []).length; i++) {
154+
const k = (accessors || [])[i]
155+
fn = fn?.[k]
156+
}
157+
return fn
158+
}
159+
const fn = access(script.instance) || access(await loadPromise)
160+
return typeof fn === 'function' ? Reflect.apply(fn, instance, args) : fn
161+
},
162+
})
163+
}
164+
script.proxy = proxyChain(script.instance)
165+
// remove in v2, just return the script
166+
const res = new Proxy(script, {
167+
get(_, k) {
168+
const target = k in script ? script : script.proxy
169+
if (k === 'then' || k === 'catch') {
170+
return script[k].bind(script)
171+
}
172+
return Reflect.get(target, k, target)
123173
},
124-
}) as any as T & { $script: ScriptInstance<T> & Promise<T> }
125-
// 4. Providing a unique context for the script
126-
head._scripts = Object.assign(
127-
head._scripts || {},
128-
{ [id]: instance },
129-
)
130-
return instance
174+
})
175+
head._scripts = Object.assign(head._scripts || {}, { [id]: res })
176+
return res
131177
}
Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,64 @@
11
import type {
2+
AsAsyncFunctionValues,
23
UseScriptInput as BaseUseScriptInput,
34
DataKeys,
45
SchemaAugmentations,
56
ScriptBase,
67
ScriptInstance,
78
UseScriptOptions,
9+
UseScriptResolvedInput,
810
UseScriptStatus,
911
} from '@unhead/schema'
10-
import { useScript as _useScript } from 'unhead'
12+
import { useScript as _useScript, resolveScriptKey } from 'unhead'
1113
import type { Ref } from 'vue'
1214
import { getCurrentInstance, onMounted, ref } from 'vue'
1315
import type { MaybeComputedRefEntriesOnly } from '../types'
1416
import { injectHead } from './injectHead'
1517

16-
export interface VueScriptInstance<T> extends Omit<ScriptInstance<T>, 'status'> {
18+
export interface VueScriptInstance<T extends Record<symbol | string, any>> extends Omit<ScriptInstance<T>, 'status'> {
1719
status: Ref<UseScriptStatus>
1820
}
1921

2022
export type UseScriptInput = string | (MaybeComputedRefEntriesOnly<Omit<ScriptBase & DataKeys & SchemaAugmentations['script'], 'src'>> & { src: string })
2123

22-
export function useScript<T extends Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): T & { $script: VueScriptInstance<T> & Promise<T> } {
23-
const input = typeof _input === 'string' ? { src: _input } : _input
24+
export type UseScriptContext<T extends Record<symbol | string, any>> =
25+
(Promise<T> & VueScriptInstance<T>)
26+
& AsAsyncFunctionValues<T>
27+
& {
28+
/**
29+
* @deprecated Use top-level functions instead.
30+
*/
31+
$script: Promise<T> & VueScriptInstance<T>
32+
}
33+
34+
export function useScript<T extends Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<T> {
35+
const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptResolvedInput
2436
const head = injectHead()
2537
const options = _options || {}
2638
// @ts-expect-error untyped
2739
options.head = head
2840
options.eventContext = getCurrentInstance()
29-
const status = ref('awaitingLoad')
30-
31-
const stubOptions = options.stub
32-
options.stub = ({ script, fn }) => {
33-
// @ts-expect-error untyped
34-
script.status = status
35-
// need to add reactive properties
36-
if (fn === '$script')
37-
return script
38-
return stubOptions?.({ script, fn })
39-
}
40-
let instance: T & { $script: VueScriptInstance<T> & Promise<T> }
41+
const scope = getCurrentInstance()
42+
if (scope && !options.trigger)
43+
options.trigger = onMounted
44+
const key = resolveScriptKey(input)
45+
if (head._scripts?.[key])
46+
return head._scripts[key]
47+
let script: UseScriptContext<T>
48+
// we may be re-using an existing script
49+
const status = ref<UseScriptStatus>('awaitingLoad')
4150
// sync the status, need to register before useScript
42-
const _ = head.hooks.hook('script:updated', ({ script }) => {
43-
if (instance && script.id === instance.$script.id) {
44-
status.value = script.status
51+
const _ = head.hooks.hook('script:updated', ({ script: s }) => {
52+
if (script && s.id === script.id) {
53+
status.value = s.status
4554
// clean up
46-
script.status === 'removed' && _()
55+
if (s.status === 'removed') {
56+
_()
57+
}
4758
}
4859
})
49-
const scope = getCurrentInstance()
50-
if (scope && !options.trigger)
51-
options.trigger = onMounted
52-
instance = _useScript(input as BaseUseScriptInput, options) as any as T & { $script: VueScriptInstance<T> & Promise<T> }
60+
script = _useScript(input as BaseUseScriptInput, options) as any as UseScriptContext<T>
5361
// Note: we don't remove scripts on unmount as it's not a common use case and reloading the script may be expensive
54-
return instance
62+
script.status = status
63+
return script
5564
}

test/unhead/dom/useScript.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ describe('dom useScript', () => {
88

99
const instance = useScript<{ test: (foo: string) => void }>({
1010
src: 'https://cdn.example.com/script.js',
11+
}, {
12+
use() {
13+
return {
14+
test: (foo: string) => {},
15+
}
16+
},
1117
})
1218

1319
expect(await useDelayedSerializedDom()).toMatchInlineSnapshot(`
@@ -38,4 +44,19 @@ describe('dom useScript', () => {
3844
await hookPromise
3945
expect(calledFn).toBe('test')
4046
})
47+
it('proxy', async () => {
48+
const head = useDOMHead()
49+
50+
const instance = useScript<{ test: (foo: string) => string }>({
51+
src: 'https://cdn.example.com/script.js',
52+
}, {
53+
use() {
54+
return {
55+
test: (foo: string) => foo,
56+
}
57+
},
58+
})
59+
60+
expect(await instance.proxy.test('hello-world')).toEqual('hello-world')
61+
})
4162
})

0 commit comments

Comments
 (0)