Skip to content

Commit e8f5b4b

Browse files
authored
fix(scripts): prevent scope disposal from aborting unrelated trigger in useScript (#660)
1 parent 0456041 commit e8f5b4b

File tree

4 files changed

+81
-17
lines changed

4 files changed

+81
-17
lines changed

packages/unhead/src/scripts/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export interface ScriptInstance<T extends BaseScriptApi> {
9595
/**
9696
* @internal
9797
*/
98-
_triggerAbortPromise?: Promise<void>
98+
_triggerAbortControllers?: Set<AbortController>
9999
/**
100100
* @internal
101101
*/

packages/unhead/src/scripts/useScript.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,9 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
116116
status: 'awaitingLoad',
117117

118118
remove() {
119-
// cancel any pending triggers as we've started loading
120-
script._triggerAbortController?.abort()
119+
// cancel all pending triggers
120+
script._triggerAbortControllers?.forEach(ac => ac.abort())
121+
script._triggerAbortControllers?.clear()
121122
script._triggerPromises = [] // clear any pending promises
122123
script._warmupEl?.dispose()
123124
if (script.entry) {
@@ -154,8 +155,9 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
154155
return script._warmupEl
155156
},
156157
load(cb?: () => void | Promise<void>) {
157-
// cancel any pending triggers as we've started loading
158-
script._triggerAbortController?.abort()
158+
// cancel all pending triggers as we've started loading
159+
script._triggerAbortControllers?.forEach(ac => ac.abort())
160+
script._triggerAbortControllers?.clear()
159161
script._triggerPromises = [] // clear any pending promises
160162
if (!script.entry) {
161163
syncStatus('loading')
@@ -195,19 +197,23 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
195197
if (head.ssr) {
196198
return
197199
}
198-
if (!script._triggerAbortController) {
199-
script._triggerAbortController = new AbortController()
200-
script._triggerAbortPromise = new Promise<void>((resolve) => {
201-
script._triggerAbortController!.signal.addEventListener('abort', () => {
202-
script._triggerAbortController = null
203-
resolve()
204-
})
200+
// each trigger gets its own abort controller so that disposing one scope
201+
// does not cancel triggers from other scopes
202+
const abortController = new AbortController()
203+
script._triggerAbortControllers = script._triggerAbortControllers || new Set()
204+
script._triggerAbortControllers.add(abortController)
205+
const abortPromise = new Promise<void>((resolve) => {
206+
abortController.signal.addEventListener('abort', () => {
207+
script._triggerAbortControllers?.delete(abortController)
208+
resolve()
205209
})
206-
}
210+
})
211+
// store the latest controller for external access
212+
script._triggerAbortController = abortController
207213
script._triggerPromises = script._triggerPromises || []
208214
const idx = script._triggerPromises.push(Promise.race([
209215
trigger.then(v => typeof v === 'undefined' || v ? script.load : undefined),
210-
script._triggerAbortPromise,
216+
abortPromise,
211217
])
212218
// OK
213219
.catch(() => {})

packages/vue/src/scripts/useScript.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,11 @@ function registerVueScopeHandlers<T extends Record<symbol | string, any> = Recor
6161
// if we have a scope we should make these callbacks reactive
6262
script.onLoaded = (cb: (instance: T) => void | Promise<void>) => _registerCb('loaded', cb)
6363
script.onError = (cb: (err?: Error) => void | Promise<void>) => _registerCb('error', cb)
64+
// capture the controller at registration time so this scope only aborts
65+
// the controller it was associated with, not a newer one created by a later scope
66+
const triggerAbortController = script._triggerAbortController
6467
onScopeDispose(() => {
65-
// stop any trigger promises
66-
script._triggerAbortController?.abort()
68+
triggerAbortController?.abort()
6769
})
6870
}
6971

packages/vue/test/unit/useScript.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
// @vitest-environment jsdom
12
import { createHead } from '@unhead/vue/client'
23
import { renderSSRHead } from '@unhead/vue/server'
34

45
import { describe, it } from 'vitest'
5-
import { ref, watch } from 'vue'
6+
import { createApp, h, ref, watch } from 'vue'
67
import { useDom } from '../../../unhead/test/util'
78
import { createHeadCore } from '../../src'
89
import { useScript } from '../../src/scripts/useScript'
@@ -201,4 +202,59 @@ describe('vue e2e scripts', () => {
201202
`)
202203
script.remove()
203204
})
205+
206+
it('setupTriggerHandler race condition: old scope disposal should not abort new scope trigger', async () => {
207+
const dom = useDom()
208+
const head = createHead({
209+
document: dom.window.document,
210+
})
211+
212+
// simulate a first page visit: component uses useScript with a trigger that never resolves
213+
// and the user navigates away before interacting
214+
const el1 = dom.window.document.createElement('div')
215+
const app1 = createApp({
216+
setup() {
217+
useScript({
218+
src: '//race-condition-script.js',
219+
}, {
220+
trigger: new Promise(() => {}), // never resolves
221+
head,
222+
})
223+
return () => h('div')
224+
},
225+
})
226+
app1.mount(el1)
227+
228+
const script = (head as any)._scripts['//race-condition-script.js']
229+
expect(script.status).toBe('awaitingLoad')
230+
expect(script._triggerAbortController).toBeTruthy()
231+
232+
const { resolve: resolveTrigger2, promise: trigger2 } = Promise.withResolvers<void>()
233+
234+
// simulate client-side navigation: the new page mounts BEFORE the old page unmounts
235+
const el2 = dom.window.document.createElement('div')
236+
const app2 = createApp({
237+
setup() {
238+
useScript({
239+
src: '//race-condition-script.js',
240+
}, {
241+
trigger: trigger2,
242+
head,
243+
})
244+
return () => h('div')
245+
},
246+
})
247+
app2.mount(el2)
248+
249+
expect(script.status).toBe('awaitingLoad')
250+
251+
app1.unmount()
252+
253+
// the user interacts on the new page, resolving `trigger2`
254+
resolveTrigger2()
255+
await new Promise<void>(resolve => setTimeout(resolve, 0))
256+
257+
// as `trigger2` is now resolved, we expect `script.load()` to have been called
258+
expect(script.status).toBe('loading')
259+
})
204260
})

0 commit comments

Comments
 (0)