Skip to content

Commit

Permalink
refactor(scripts): improved types and reduce js
Browse files Browse the repository at this point in the history
  • Loading branch information
harlan-zw committed Apr 19, 2024
1 parent 9660405 commit 6997832
Show file tree
Hide file tree
Showing 4 changed files with 23 additions and 27 deletions.
7 changes: 3 additions & 4 deletions packages/schema/src/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@ export type UseScriptStatus = 'awaitingLoad' | 'loading' | 'loaded' | 'error' |
export type UseScriptInput = string | (Omit<Script, 'src'> & { src: string })
export type UseScriptResolvedInput = Omit<Script, 'src'> & { src: string }

export type ScriptInstance<T> = {
export interface ScriptInstance<T> {
id: string
status: UseScriptStatus
loadPromise: Promise<T>
entry?: ActiveHeadEntry<any>
load: () => Promise<T>
remove: () => boolean
} & Promise<T>
}

export interface UseScriptOptions<T> extends HeadEntryOptions {
/**
Expand All @@ -26,7 +25,7 @@ export interface UseScriptOptions<T> extends HeadEntryOptions {
/**
* Stub the script instance. Useful for SSR or testing.
*/
stub?: ((ctx: { script: ScriptInstance<T>, fn: string | symbol }) => any)
stub?: ((ctx: { script: Promise<T> & ScriptInstance<T>, fn: string | symbol }) => any)
/**
* The trigger to load the script:
* - `undefined` | `client` - (Default) Load the script on the client when this js is loaded.
Expand Down
24 changes: 10 additions & 14 deletions packages/unhead/src/composables/useScript.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { ScriptNetworkEvents, hashCode } from '@unhead/shared'
import type {
DomRenderTagContext,
Head,
ScriptInstance,
UseScriptInput,
Expand All @@ -15,16 +14,14 @@ import { getActiveHead } from './useActiveHead'
* @experimental
* @see https://unhead.unjs.io/usage/composables/use-script
*/
export function useScript<T>(_input: UseScriptInput, _options?: UseScriptOptions<T>): T & { $script: ScriptInstance<T> } {
export function useScript<T extends Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): T & { $script: Promise<T> & ScriptInstance<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 isAbsolute = input.src && (input.src.startsWith('http') || input.src.startsWith('//'))
const id = input.key || hashCode(input.src || (typeof input.innerHTML === 'string' ? input.innerHTML : ''))
const key = `use-script.${id}`
if (head._scripts?.[id])
return head._scripts[id]
options.beforeInit?.()
Expand All @@ -42,22 +39,22 @@ export function useScript<T>(_input: UseScriptInput, _options?: UseScriptOptions
}
})
const loadPromise = new Promise<T>((resolve, reject) => {
const cleanUp = head.hooks.hook('script:updated', ({ script }: { script: ScriptInstance<T> }) => {
const _ = head.hooks.hook('script:updated', ({ script }) => {
if (script.id === id && (script.status === 'loaded' || script.status === 'error')) {
if (script.status === 'loaded') {
const api = options.use?.()
api && resolve(api)
}
else if (script.status === 'error') {
reject(new Error(`Failed to load script: ${input.src}`))
cleanUp()
}
_()
}
})
})
const script = {
const script: ScriptInstance<T> = {
id,
status: 'awaitingLoad',
loaded: false,
remove() {
if (script.entry) {
script.entry.dispose()
Expand All @@ -69,25 +66,24 @@ export function useScript<T>(_input: UseScriptInput, _options?: UseScriptOptions
},
load() {
if (!script.entry) {
options.beforeInit?.()
syncStatus('loading')
const defaults: Required<Head>['script'][0] = {
defer: true,
fetchpriority: 'low',
}
if (isAbsolute) {
// is absolute, add privacy headers
if (input.src && (input.src.startsWith('http') || input.src.startsWith('//'))) {
defaults.crossorigin = 'anonymous'
defaults.referrerpolicy = 'no-referrer'
}
// status should get updated from script events
script.entry = head.push({
script: [{ ...defaults, ...input, key }],
script: [{ ...defaults, ...input, key: `script.${id}` }],
}, options)
}
return loadPromise
},
} as any as ScriptInstance<T>

}
const hookCtx = { script }

if ((trigger === 'client' && !head.ssr) || (trigger === 'server' && head.ssr))
Expand Down Expand Up @@ -121,7 +117,7 @@ export function useScript<T>(_input: UseScriptInput, _options?: UseScriptOptions
// for instance GTM will already have dataLayer available, we can expose it directly
return attempt() || ((...args: any[]) => loadPromise.then(() => attempt(args)(...args)))
},
}) as any as T & { $script: ScriptInstance<T> }
}) as any as T & { $script: ScriptInstance<T> & Promise<T> }
// 4. Providing a unique context for the script
head._scripts = Object.assign(
head._scripts || {},
Expand Down
17 changes: 9 additions & 8 deletions packages/vue/src/composables/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ import type {
} from '@unhead/schema'
import { useScript as _useScript } from 'unhead'
import type { Ref } from 'vue'
import { getCurrentInstance, 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>, 'loaded' | 'status'> {
export interface VueScriptInstance<T> 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>(_input: UseScriptInput, _options?: UseScriptOptions<T>): T & { $script: VueScriptInstance<T> } {
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
const head = injectHead()
const options = _options || {}
Expand All @@ -37,18 +37,19 @@ export function useScript<T>(_input: UseScriptInput, _options?: UseScriptOptions
return script
return stubOptions?.({ script, fn })
}
let instance: T & { $script: VueScriptInstance<T> }
let instance: T & { $script: VueScriptInstance<T> & Promise<T> }
// sync the status, need to register before useScript
const rmHook = head.hooks.hook('script:updated', ({ script }) => {
const _ = head.hooks.hook('script:updated', ({ script }) => {
if (instance && script.id === instance.$script.id) {
status.value = script.status
// clean up
if (script.status === 'removed')
rmHook()
script.status === 'removed' && _()
}
})
return (instance = _useScript(input as BaseUseScriptInput, options) as T & { $script: VueScriptInstance<T> })
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> }
// 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
}
2 changes: 1 addition & 1 deletion test/unhead/ssr/useScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('dom useScript', () => {
"bodyAttrs": "",
"bodyTags": "",
"bodyTagsOpen": "",
"headTags": "<script defer fetchpriority="low" crossorigin="anonymous" referrerpolicy="no-referrer" src="https://cdn.example.com/script.js" onload="this.dataset.onloadfired = true" onerror="this.dataset.onerrorfired = true" data-hid="438d65b"></script>",
"headTags": "<script defer fetchpriority="low" crossorigin="anonymous" referrerpolicy="no-referrer" src="https://cdn.example.com/script.js" onload="this.dataset.onloadfired = true" onerror="this.dataset.onerrorfired = true" data-hid="c5c65b0"></script>",
"htmlAttrs": "",
}
`)
Expand Down

0 comments on commit 6997832

Please sign in to comment.