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): safer lifecycle hooks onLoaded, onError #381

Merged
merged 1 commit into from
Aug 20, 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
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
}