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(scripts): deprecate implicit proxy and $script #379

Merged
merged 10 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 129 additions & 134 deletions docs/content/1.usage/2.composables/4.use-script.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions examples/vite-ssr-vue/src/pages/fathom.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { onMounted } from 'vue'
import { useScript } from '@unhead/vue'

export interface FathomAnalyticsApi {
Expand All @@ -8,13 +9,13 @@ export interface FathomAnalyticsApi {
isTrackingEnabled: () => boolean
send: (type: string, data: unknown) => void
setSite: (siteId: string) => void
sideId: string
siteId: string
trackPageview: (ctx?: { url: string, referrer?: string }) => void
trackGoal: (goalId: string, cents: number) => void
trackEvent: (eventName: string, value: { _value: number }) => void
}

const { trackPageview, blockTrackingForMe } = useScript<FathomAnalyticsApi>({
const { trackPageview, blockTrackingForMe, siteId } = useScript<FathomAnalyticsApi>({
src: 'https://cdn.usefathom.com/script.js',
['data-site']: 'KGILBQDV',
}, {
Expand All @@ -28,6 +29,9 @@ trackPageview({
url: '/test',
referrer: '',
})
onMounted(async () => {
console.log(siteId, await siteId())
})
</script>
<template>
<div>test</div>
Expand Down
13 changes: 7 additions & 6 deletions examples/vite-ssr-vue/src/pages/gtag.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
<script setup lang="ts">
import { useScript } from '@unhead/vue'

const { dataLayer, $script } = useScript<{ dataLayer: any[] }>({
const gtag = useScript<{ dataLayer: any[] }>({
src: 'https://www.googletagmanager.com/gtm.js?id=GTM-MNJD4B',
}, {
stub({ fn }) {
return fn === 'dataLayer' && typeof window === 'undefined' ? [] : undefined
},
beforeInit() {
if (typeof window !== 'undefined') {
window.dataLayer = window.dataLayer || []
Expand All @@ -20,13 +17,17 @@ const { dataLayer, $script } = useScript<{ dataLayer: any[] }>({
},
trigger: typeof window !== 'undefined' ? window.requestIdleCallback : 'manual'
})
const { $script } = gtag
const dataLayer = gtag.proxy

const status = gtag.status

dataLayer.push({
event: 'page_view',
page_path: '/stripe',
})

$script.then((res) => {
gtag.then((res) => {
console.log('ready!', res)
})

Expand All @@ -39,7 +40,7 @@ useHead({
<div>
<h1>gtm</h1>
<div>
script status: {{ $script.status }}
script status: {{ status }}
</div>
<div>
data layer:
Expand Down
15 changes: 13 additions & 2 deletions packages/schema/src/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,27 @@ export type UseScriptStatus = 'awaitingLoad' | 'loading' | 'loaded' | 'error' |
*/
export type UseScriptInput = string | (Omit<Script, 'src'> & { src: string })
export type UseScriptResolvedInput = Omit<Script, 'src'> & { src: string }
type BaseScriptApi = Record<symbol | string, any>

export interface ScriptInstance<T> {
export type AsAsyncFunctionValues<T extends BaseScriptApi> = {
[key in keyof T]:
// arays return literals
T[key] extends any[] ? T[key] :
T[key] extends object ? AsAsyncFunctionValues<T[key]> :
T[key] extends (...args: infer A) => infer R ? (...args: A) => Promise<R> : () => Promise<T[key]>
}

export interface ScriptInstance<T extends BaseScriptApi> {
proxy: AsAsyncFunctionValues<T>
instance?: T
id: string
status: UseScriptStatus
entry?: ActiveHeadEntry<any>
load: () => Promise<T>
remove: () => boolean
}

export interface UseScriptOptions<T> extends HeadEntryOptions {
export interface UseScriptOptions<T extends BaseScriptApi> extends HeadEntryOptions {
/**
* Resolve the script instance from the window.
*/
Expand Down
112 changes: 79 additions & 33 deletions packages/unhead/src/composables/useScript.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ScriptNetworkEvents, hashCode } from '@unhead/shared'
import type {
AsAsyncFunctionValues,
Head,
ScriptInstance,
UseScriptInput,
Expand All @@ -8,20 +9,37 @@ import type {
} from '@unhead/schema'
import { getActiveHead } from './useActiveHead'

export type UseScriptContext<T extends Record<symbol | string, any>> =
(Promise<T> & ScriptInstance<T>)
& AsAsyncFunctionValues<T>
& {
/**
* @deprecated Use top-level functions instead.
*/
$script: Promise<T> & ScriptInstance<T>
}

const ScriptProxyTarget = Symbol('ScriptProxyTarget')
function sharedTarget() {}
sharedTarget[ScriptProxyTarget] = true

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

/**
* Load third-party scripts with SSR support and a proxied API.
*
* @experimental
* @see https://unhead.unjs.io/usage/composables/use-script
*/
export function useScript<T extends Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): T & { $script: Promise<T> & ScriptInstance<T> } {
export function useScript<T extends Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<T> {
const input: UseScriptResolvedInput = typeof _input === 'string' ? { src: _input } : _input
const options = _options || {}
const head = options.head || getActiveHead()
if (!head)
throw new Error('Missing Unhead context.')

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

const proxy = { instance: (!head.ssr && options?.use?.()) || {} } as { instance: T, $script: ScriptInstance<T> }
const loadPromise = new Promise<T>((resolve, reject) => {
// promise never resolves
if (head.ssr)
return
const emit = (api: T) => requestAnimationFrame(() => resolve(api))
const _ = head.hooks.hook('script:updated', ({ script }) => {
if (script.id === id && (script.status === 'loaded' || script.status === 'error')) {
Expand All @@ -60,8 +80,10 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
_()
}
})
}).then(api => (proxy.instance = api))
const script: ScriptInstance<T> = {
})
const script = Object.assign(loadPromise, {
instance: (!head.ssr && options?.use?.()) || null,
proxy: null,
id,
status: 'awaitingLoad',
remove() {
Expand Down Expand Up @@ -92,7 +114,8 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
}
return loadPromise
},
}
}) as any as UseScriptContext<T>
loadPromise.then(api => (script.instance = api))
const hookCtx = { script }
if ((trigger === 'client' && !head.ssr) || (trigger === 'server' && head.ssr))
script.load()
Expand All @@ -101,31 +124,54 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
else if (typeof trigger === 'function')
trigger(async () => script.load())

// 3. Proxy the script API
proxy.$script = Object.assign(loadPromise, script)
const instance = new Proxy<{ instance: T }>(proxy, {
get({ instance: _ }, k) {
const stub = options.stub?.({ script: proxy.$script, fn: k })
if (stub)
return stub
if (k === '$script')
return proxy.$script
const exists = _ && k in _ && _[k] !== undefined
head.hooks.callHook('script:instance-fn', { script, fn: k, exists })
return exists
? Reflect.get(_, k)
: (...args: any[]) => loadPromise.then((api) => {
const _k = Reflect.get(api, k)
return typeof _k === 'function'
? Reflect.apply(api[k], api, args)
: _k
})
// support deprecated behavior
script.$script = script
const proxyChain = (instance: any, accessor?: string | symbol, accessors?: (string | symbol)[]) => {
return new Proxy((!accessor ? instance : instance?.[accessor]) || sharedTarget, {
get(_, k, r) {
head.hooks.callHook('script:instance-fn', { script, fn: k, exists: k in _ })
if (!accessor) {
const stub = options.stub?.({ script, fn: k })
if (stub)
return stub
}
if (_ && k in _) {
return Reflect.get(_, k, r)
}
if (k === Symbol.iterator) {
return [][Symbol.iterator]
}
return proxyChain(accessor ? instance?.[accessor] : instance, k, accessors || [k])
},
async apply(_, _this, args) {
// we are faking, just return, avoid promise handles
if (head.ssr && _[ScriptProxyTarget])
return
let instance: any
const access = (fn?: T) => {
instance = fn || instance
for (let i = 0; i < (accessors || []).length; i++) {
const k = (accessors || [])[i]
fn = fn?.[k]
}
return fn
}
const fn = access(script.instance) || access(await loadPromise)
return typeof fn === 'function' ? Reflect.apply(fn, instance, args) : fn
},
})
}
script.proxy = proxyChain(script.instance)
// remove in v2, just return the script
const res = new Proxy(script, {
get(_, k) {
const target = k in script ? script : script.proxy
if (k === 'then' || k === 'catch') {
return script[k].bind(script)
}
return Reflect.get(target, k, target)
},
}) as any as T & { $script: ScriptInstance<T> & Promise<T> }
// 4. Providing a unique context for the script
head._scripts = Object.assign(
head._scripts || {},
{ [id]: instance },
)
return instance
})
head._scripts = Object.assign(head._scripts || {}, { [id]: res })
return res
}
59 changes: 34 additions & 25 deletions packages/vue/src/composables/useScript.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,64 @@
import type {
AsAsyncFunctionValues,
UseScriptInput as BaseUseScriptInput,
DataKeys,
SchemaAugmentations,
ScriptBase,
ScriptInstance,
UseScriptOptions,
UseScriptResolvedInput,
UseScriptStatus,
} from '@unhead/schema'
import { useScript as _useScript } from 'unhead'
import { useScript as _useScript, resolveScriptKey } from 'unhead'
import type { Ref } from 'vue'
import { getCurrentInstance, onMounted, ref } from 'vue'
import type { MaybeComputedRefEntriesOnly } from '../types'
import { injectHead } from './injectHead'

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

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

export function useScript<T extends Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): T & { $script: VueScriptInstance<T> & Promise<T> } {
const input = typeof _input === 'string' ? { src: _input } : _input
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>
}

export function useScript<T extends Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<T> {
const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptResolvedInput
const head = injectHead()
const options = _options || {}
// @ts-expect-error untyped
options.head = head
options.eventContext = getCurrentInstance()
const status = ref('awaitingLoad')

const stubOptions = options.stub
options.stub = ({ script, fn }) => {
// @ts-expect-error untyped
script.status = status
// need to add reactive properties
if (fn === '$script')
return script
return stubOptions?.({ script, fn })
}
let instance: T & { $script: VueScriptInstance<T> & Promise<T> }
const scope = getCurrentInstance()
if (scope && !options.trigger)
options.trigger = onMounted
const key = resolveScriptKey(input)
if (head._scripts?.[key])
return head._scripts[key]
let script: UseScriptContext<T>
// we may be re-using an existing script
const status = ref<UseScriptStatus>('awaitingLoad')
// sync the status, need to register before useScript
const _ = head.hooks.hook('script:updated', ({ script }) => {
if (instance && script.id === instance.$script.id) {
status.value = script.status
const _ = head.hooks.hook('script:updated', ({ script: s }) => {
if (script && s.id === script.id) {
status.value = s.status
// clean up
script.status === 'removed' && _()
if (s.status === 'removed') {
_()
}
}
})
const scope = getCurrentInstance()
if (scope && !options.trigger)
options.trigger = onMounted
instance = _useScript(input as BaseUseScriptInput, options) as any as T & { $script: VueScriptInstance<T> & Promise<T> }
script = _useScript(input as BaseUseScriptInput, options) as any as UseScriptContext<T>
// Note: we don't remove scripts on unmount as it's not a common use case and reloading the script may be expensive
return instance
script.status = status
return script
}
21 changes: 21 additions & 0 deletions test/unhead/dom/useScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ describe('dom useScript', () => {

const instance = useScript<{ test: (foo: string) => void }>({
src: 'https://cdn.example.com/script.js',
}, {
use() {
return {
test: (foo: string) => {},
}
},
})

expect(await useDelayedSerializedDom()).toMatchInlineSnapshot(`
Expand Down Expand Up @@ -38,4 +44,19 @@ describe('dom useScript', () => {
await hookPromise
expect(calledFn).toBe('test')
})
it('proxy', async () => {
const head = useDOMHead()

const instance = useScript<{ test: (foo: string) => string }>({
src: 'https://cdn.example.com/script.js',
}, {
use() {
return {
test: (foo: string) => foo,
}
},
})

expect(await instance.proxy.test('hello-world')).toEqual('hello-world')
})
})
Loading