Skip to content

Commit

Permalink
feat(scripts): safer lifecycle hooks onLoaded, onError (#381)
Browse files Browse the repository at this point in the history
  • Loading branch information
harlan-zw authored Aug 20, 2024
1 parent 0251227 commit 3fe9217
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 30 deletions.
85 changes: 64 additions & 21 deletions docs/content/1.usage/2.composables/4.use-script.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,23 +135,25 @@ When you're using a `trigger` that isn't `server`, the script will not exist wit
::code-group

```ts [Manual]
const { load } = useScript('https://example.com/script.js', {
const { load } = useScript('/script.js', {
trigger: 'manual'
})
// ...
load()
load((instance) => {
// use the script instance
})
```

```ts [Promise]
useScript('https://example.com/script.js', {
useScript('/script.js', {
trigger: new Promise((resolve) => {
setTimeout(resolve, 10000) // load after 10 seconds
})
})
```

```ts [Idle]
useScript('https://example.com/script.js', {
useScript('/script.js', {
trigger: typeof window !== 'undefined' ? window.requestIdleCallback : 'manual'
})
```
Expand All @@ -160,34 +162,64 @@ useScript('https://example.com/script.js', {

### Waiting for Script Load

To use the underlying API exposed by a script, we need to either use the [Proxy API](#proxy-api) or register a hook for when
the script loads.
To use the underlying API exposed by a script, it's recommended to use the `onLoaded` function, which accepts
a callback function once the script is loaded.

To do this we can use `then()` which accepts a function callback.
::code-block

```ts
const myScript = useScript<{ myFunction: (s: string) => void }>('/script.js', {
use() {
return window.myAwesomeScript
},
```ts [Vanilla]
const { onLoaded } = useScript('/script.js')
onLoaded(() => {
// script ready!
})
myScript.then((myAwesomeScript) => {
// accesses the script directly, proxy is not used
myAwesomeScript.myFunction('hello')
```

```ts [Vue]
const { onLoaded } = useScript('/script.js')
onLoaded(() => {
// script ready!
})
```

::

If you have registered your script using a `manual` trigger, then you can call `load()` with the same syntax.

```ts
const myScript = useScript<{ myFunction: (s: string) => void }>('/script.js', {
const { load } = useScript('/script.js', {
trigger: 'manual'
})
myScript.then(() => {
// will never fire unless you call myScript.load()
load((instance) => {
// runs once the script loads
})
```

The `onLoaded` function returns a function that you can use to dispose of the callback. For reactive integrations
such as Vue, this will automatically bind to the scope lifecycle.

::code-block

```ts [Vanilla]
const { onLoaded } = useScript('/script.js')
const dispose = onLoaded(() => {
// script ready!
})
// ...
dispose() // nevermind!
```

```ts [Vue]
const { onLoaded } = useScript('/script.js')

onLoaded(() => {
// this will never be called once the scope unmounts
})
```

::

If you'd like to always run the code regardless of lifecycle events, you may consider the [Proxy API](#proxy-api) instead.

### Removing a Script

When you're done with a script, you can remove it from the document using the `remove()` function.
Expand All @@ -210,7 +242,7 @@ As the script instance is a native promise, you can use the `.catch()` function.

```ts
const myScript = useScript('/script.js')
.catch((err) => {
.onError((err) => {
console.error('Failed to load script', err)
})
```
Expand Down Expand Up @@ -391,7 +423,18 @@ The status of the script. Can be one of the following: `'awaitingLoad' | 'loadin

In Vue, this is a `Ref`.

### then(callback: Function)
### onLoaded(cb: (instance: ReturnType<typeof use>) => void | Promise<void>): () => void

A function that is called when the script is loaded. This is useful when you want to access the script directly.

```ts
const myScript = useScript('/script.js')
myScript.onLoaded(() => {
// ready
})
```

### then(cb: (instance: ReturnType<typeof use>) => void | Promise<void>)

A function that is called when the script is loaded. This is useful when you want to access the script directly.

Expand All @@ -402,7 +445,7 @@ myScript.onLoaded(() => {
})
```

### load(callback?: Function)
### load(callback?: (instance: ReturnType<typeof use>) => void | Promise<void>): Promise<ReturnType<typeof use>>

Trigger the script to load. This is useful when using the `manual` loading strategy.

Expand Down
3 changes: 3 additions & 0 deletions examples/vite-ssr-vue/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
</RouterLink>|
<RouterLink to="/js-confetti">
JS Confetti
</RouterLink>|
<RouterLink to="/manual-script">
Manual
</RouterLink>
<RouterView v-slot="{ Component }">
<Suspense>
Expand Down
17 changes: 13 additions & 4 deletions examples/vite-ssr-vue/src/pages/manual-script.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<script setup>
import { ref } from 'vue'
import { ref, onUnmounted } from 'vue'
import { useScript } from '@unhead/vue'
const isScriptLoaded = ref(false)
const { $script } = useScript({
const { $script, onLoaded } = useScript({
key: 'stripe',
src: 'https://js.stripe.com/v3/',
onload() {
console.log('script loaded')
console.log('script onload input')
isScriptLoaded.value = true
},
onerror() {
Expand All @@ -18,7 +18,16 @@ const { $script } = useScript({
trigger: 'manual',
})
console.log($script.status)
onLoaded(() => {
console.log('on loaded callback')
})
$script.then(() => {
console.log('script promise callback')
})
onUnmounted(() => {
$script.load()
})
useHead({
title: () => $script.status.value,
Expand Down
10 changes: 10 additions & 0 deletions packages/schema/src/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ export interface ScriptInstance<T extends BaseScriptApi> {
entry?: ActiveHeadEntry<any>
load: () => Promise<T>
remove: () => boolean
// cbs
onLoaded: (fn: (instance: T) => void | Promise<void>) => void
onError: (fn: (err?: Error) => void | Promise<void>) => void
/**
* @internal
*/
_cbs: {
loaded: ((instance: T) => void | Promise<void>)[]
error: ((err?: Error) => void | Promise<void>)[]
}
}

export interface UseScriptOptions<T extends BaseScriptApi> extends HeadEntryOptions {
Expand Down
32 changes: 28 additions & 4 deletions packages/unhead/src/composables/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
}
})

const _cbs: ScriptInstance<T>['_cbs'] = { loaded: [], error: [] }
const _registerCb = (key: 'loaded' | 'error', cb: any) => {
const i: number = _cbs[key].push(cb)
return () => _cbs[key].splice(i - 1, 1)
}
const loadPromise = new Promise<T>((resolve, reject) => {
// promise never resolves
if (head.ssr)
Expand All @@ -82,7 +87,7 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
}
})
})
const script = Object.assign(loadPromise, {
const script = Object.assign(loadPromise, <Partial<UseScriptContext<T>>> {
instance: (!head.ssr && options?.use?.()) || null,
proxy: null,
id,
Expand All @@ -96,7 +101,7 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
}
return false
},
load() {
load(cb?: () => void | Promise<void>) {
if (!script.entry) {
syncStatus('loading')
const defaults: Required<Head>['script'][0] = {
Expand All @@ -113,10 +118,29 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
script: [{ ...defaults, ...input, key: `script.${id}` }],
}, options)
}
if (cb)
_registerCb('loaded', cb)
return loadPromise
},
}) as any as UseScriptContext<T>
loadPromise.then(api => (script.instance = api))
onLoaded(cb: (instance: T) => void | Promise<void>) {
return _registerCb('loaded', cb)
},
onError(cb: (err?: Error) => void | Promise<void>) {
return _registerCb('error', cb)
},
_cbs,
}) as UseScriptContext<T>
// script is ready
loadPromise
.then((api) => {
script.instance = api
_cbs.loaded.forEach(cb => cb(api))
_cbs.loaded = []
})
.catch((err) => {
_cbs.error.forEach(cb => cb(err))
_cbs.error = []
})
const hookCtx = { script }
if ((trigger === 'client' && !head.ssr) || (trigger === 'server' && head.ssr))
script.load()
Expand Down
19 changes: 18 additions & 1 deletion packages/vue/src/composables/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
} from '@unhead/schema'
import { useScript as _useScript, resolveScriptKey } from 'unhead'
import type { Ref } from 'vue'
import { getCurrentInstance, onMounted, ref } from 'vue'
import { getCurrentInstance, onMounted, onScopeDispose, ref } from 'vue'
import type { MaybeComputedRefEntriesOnly } from '../types'
import { injectHead } from './injectHead'

Expand Down Expand Up @@ -60,5 +60,22 @@ export function useScript<T extends Record<symbol | string, any>>(_input: UseScr
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
script.status = status
if (scope) {
const _registerCb = (key: 'loaded' | 'error', cb: any) => {
let i: number | null = script._cbs[key].push(cb)
const destroy = () => {
// avoid removing the wrong callback
if (i) {
script._cbs[key].splice(i - 1, 1)
i = null
}
}
onScopeDispose(destroy)
return destroy
}
// if we have a scope we should make these callbacks reactive
script.onLoaded = (cb: (instance: T) => void | Promise<void>) => _registerCb('loaded', cb)
script.onError = (cb: (err?: Error) => void | Promise<void>) => _registerCb('error', cb)
}
return script
}

0 comments on commit 3fe9217

Please sign in to comment.