Skip to content

Commit 410e1c3

Browse files
committed
fix(script): address lifecycle review findings
1 parent 7c31d15 commit 410e1c3

10 files changed

Lines changed: 241 additions & 172 deletions

File tree

packages/script/src/devtools.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Nuxt } from '@nuxt/schema'
22
import type { IncomingMessage, ServerResponse } from 'node:http'
33
import type { ProxyConfig, RegistryScript } from './runtime/types'
4+
import { Buffer } from 'node:buffer'
45
import { existsSync } from 'node:fs'
56
import { createResolver, extendViteConfig } from '@nuxt/kit'
67

@@ -82,7 +83,7 @@ export function setupStandaloneApi(nuxt: Nuxt) {
8283
}
8384

8485
if (req.method === 'POST') {
85-
let body = ''
86+
let chunks: Buffer[] = []
8687
let size = 0
8788
let finished = false
8889

@@ -94,7 +95,7 @@ export function setupStandaloneApi(nuxt: Nuxt) {
9495
}
9596
function onAborted() {
9697
finished = true
97-
body = ''
98+
chunks = []
9899
cleanup()
99100
}
100101
function onData(chunk: Buffer) {
@@ -103,7 +104,7 @@ export function setupStandaloneApi(nuxt: Nuxt) {
103104
size += chunk.byteLength
104105
if (size > DEVTOOLS_API_MAX_BODY_SIZE) {
105106
finished = true
106-
body = ''
107+
chunks = []
107108
cleanup()
108109
// Drain the remainder so the keep-alive connection can be reused.
109110
// `cleanup()` removed the normal error handler, so keep one listener
@@ -114,15 +115,15 @@ export function setupStandaloneApi(nuxt: Nuxt) {
114115
res.end('payload too large')
115116
return
116117
}
117-
body += chunk.toString()
118+
chunks.push(chunk)
118119
}
119120
function onEnd() {
120121
if (finished)
121122
return
122123
finished = true
123124
cleanup()
124125
try {
125-
const data = JSON.parse(body)
126+
const data = JSON.parse(Buffer.concat(chunks, size).toString('utf8'))
126127
scriptsState = { ...data, updatedAt: Date.now() }
127128
res.statusCode = 200
128129
res.end('ok')
@@ -131,7 +132,7 @@ export function setupStandaloneApi(nuxt: Nuxt) {
131132
res.statusCode = 400
132133
res.end('invalid json')
133134
}
134-
body = ''
135+
chunks = []
135136
}
136137

137138
req.on('data', onData)

packages/script/src/runtime/components/ScriptCarbonAds.vue

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,6 @@ onBeforeUnmount(() => {
8585
scriptEl.remove()
8686
scriptEl = undefined
8787
}
88-
if (carbonadsEl.value) {
89-
carbonadsEl.value.remove()
90-
carbonadsEl.value = null
91-
}
9288
})
9389
</script>
9490

packages/script/src/runtime/components/ScriptLemonSqueezy.vue

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
11
<script lang="ts">
2-
let activeLemonSqueezyOwner: symbol | undefined
2+
import type { LemonSqueezyApi, LemonSqueezyEventPayload } from '../registry/lemon-squeezy'
3+
4+
type LemonSqueezyHandler = (event: LemonSqueezyEventPayload) => void
5+
6+
const lemonSqueezyHandlers = new Map<symbol, LemonSqueezyHandler>()
7+
const dispatchLemonSqueezyEvent: LemonSqueezyHandler = (event) => {
8+
for (const handler of [...lemonSqueezyHandlers.values()])
9+
handler(event)
10+
}
11+
12+
function registerLemonSqueezyHandler(
13+
owner: symbol,
14+
handler: LemonSqueezyHandler,
15+
setup: LemonSqueezyApi['Setup'],
16+
) {
17+
lemonSqueezyHandlers.set(owner, handler)
18+
// Lemon.js replaces its global handler on load and reload. Reinstall the
19+
// stable dispatcher each time while retaining every live subscriber.
20+
setup({ eventHandler: dispatchLemonSqueezyEvent })
21+
}
22+
23+
function unregisterLemonSqueezyHandler(owner: symbol) {
24+
const removed = lemonSqueezyHandlers.delete(owner)
25+
if (!removed || lemonSqueezyHandlers.size > 0)
26+
return
27+
if (import.meta.client && typeof window.LemonSqueezy?.Setup === 'function')
28+
window.LemonSqueezy.Setup({ eventHandler() {} })
29+
}
330
</script>
431

532
<script lang="ts" setup>
6-
import type { LemonSqueezyEventPayload } from '../registry/lemon-squeezy'
733
import type { ElementScriptTrigger } from '../types'
834
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
935
import { useScriptTriggerElement } from '../composables/useScriptTriggerElement'
@@ -40,27 +66,15 @@ onMounted(() => {
4066
instance.onLoaded(({ Setup, Refresh }) => {
4167
if (disposed)
4268
return
43-
Setup({
44-
eventHandler(event) {
45-
emits('lemonSqueezyEvent', event)
46-
},
47-
})
48-
activeLemonSqueezyOwner = owner
69+
registerLemonSqueezyHandler(owner, event => emits('lemonSqueezyEvent', event), Setup)
4970
Refresh()
5071
emits('ready', instance)
5172
})
5273
})
5374
5475
onBeforeUnmount(() => {
5576
disposed = true
56-
// Lemon.js `Setup()` stores a single global `eventHandler`, which captures
57-
// this component's `emits` (and therefore the instance). Setup() replaces
58-
// rather than appends. Only the component that most recently installed the
59-
// handler may clear it; an older instance can unmount after a newer one.
60-
if (activeLemonSqueezyOwner === owner && import.meta.client && typeof window.LemonSqueezy?.Setup === 'function') {
61-
window.LemonSqueezy.Setup({ eventHandler() {} })
62-
activeLemonSqueezyOwner = undefined
63-
}
77+
unregisterLemonSqueezyHandler(owner)
6478
})
6579
6680
const rootAttrs = computed(() => {

packages/script/src/runtime/registry/usercentrics.ts

Lines changed: 1 addition & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types'
22
import { useHead } from '@unhead/vue'
33
import { useNuxtApp } from 'nuxt/app'
4-
import { logger } from '../logger'
54
import { useRegistryScript } from '../utils'
6-
import { createAbortablePromise, createAbortError } from '../utils/abortable-promise'
5+
import { attachUsercentricsConsent } from '../utils/usercentrics-consent'
76
import { UsercentricsOptions } from './schemas'
87

98
export { UsercentricsOptions }
@@ -81,95 +80,6 @@ export interface UsercentricsConsent {
8180
denyAll: () => Promise<void> | void
8281
}
8382

84-
interface UsercentricsEventTarget {
85-
__ucCmp?: UsercentricsCmp
86-
addEventListener: Window['addEventListener']
87-
removeEventListener: Window['removeEventListener']
88-
}
89-
90-
type HookAppUnmount = (callback: () => void) => () => void
91-
92-
export function attachUsercentricsConsent<T extends UsercentricsApi>(
93-
instance: UseScriptContext<T, UsercentricsConsent>,
94-
target: UsercentricsEventTarget,
95-
hookAppUnmount: HookAppUnmount,
96-
): void {
97-
if (instance.consent)
98-
return
99-
100-
let readyApi: UsercentricsCmp | undefined
101-
let readyPromise: Promise<UsercentricsCmp> | undefined
102-
const readyController = new AbortController()
103-
let disposed = false
104-
const cleanupReadyListener = () => {
105-
if (disposed)
106-
return
107-
disposed = true
108-
readyController.abort()
109-
}
110-
const whenReady = (): Promise<UsercentricsCmp> => {
111-
if (disposed)
112-
return Promise.reject(createAbortError('Usercentrics readiness wait was aborted'))
113-
if (readyApi)
114-
return Promise.resolve(readyApi)
115-
if (!readyPromise) {
116-
// Install the event listener before checking isInitialized() so an
117-
// event fired during that async check cannot be missed.
118-
readyPromise = createAbortablePromise<UsercentricsCmp>((resolve) => {
119-
const onReady = () => {
120-
const api = target.__ucCmp
121-
if (!api)
122-
return
123-
readyApi = api
124-
resolve(api)
125-
}
126-
target.addEventListener('UC_CMP_API_READY', onReady)
127-
const api = target.__ucCmp
128-
if (api?.isInitialized) {
129-
Promise.resolve()
130-
.then(() => api.isInitialized())
131-
.then((initialized) => {
132-
if (initialized && !readyController.signal.aborted)
133-
onReady()
134-
})
135-
.catch((error) => {
136-
// Some bootstrap stubs throw until the ready event; the event
137-
// listener remains the authoritative readiness signal.
138-
if (!readyController.signal.aborted)
139-
logger.debug('[usercentrics] Waiting for UC_CMP_API_READY after isInitialized() failed', error)
140-
})
141-
}
142-
return () => target.removeEventListener('UC_CMP_API_READY', onReady)
143-
}, {
144-
signal: readyController.signal,
145-
abortMessage: 'Usercentrics readiness wait was aborted',
146-
})
147-
}
148-
return readyPromise
149-
}
150-
151-
const stopAppUnmount = hookAppUnmount(cleanupReadyListener)
152-
const originalRemove = instance.remove
153-
instance.remove = () => {
154-
cleanupReadyListener()
155-
stopAppUnmount()
156-
return originalRemove()
157-
}
158-
159-
instance.consent = {
160-
whenReady,
161-
onConsentChange(cb) {
162-
const handler = (event: Event) => cb((event as CustomEvent).detail, event)
163-
target.addEventListener('UC_UI_CMP_EVENT', handler)
164-
return () => target.removeEventListener('UC_UI_CMP_EVENT', handler)
165-
},
166-
showFirstLayer: () => target.__ucCmp?.showFirstLayer?.(),
167-
showSecondLayer: () => target.__ucCmp?.showSecondLayer?.(),
168-
acceptAll: () => target.__ucCmp?.acceptAllConsents?.(),
169-
denyAll: () => target.__ucCmp?.denyAllConsents?.(),
170-
}
171-
}
172-
17383
/**
17484
* Load the Usercentrics CMP v3 ("Web CMP") loader and expose typed access to
17585
* the `window.__ucCmp` programmatic API plus a `consent` helper with

packages/script/src/runtime/registry/youtube-player.ts

Lines changed: 15 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useHead } from '@unhead/vue'
44
/// <reference types="youtube" />
55
import { watch } from 'vue'
66
import { useRegistryScript } from '../utils'
7-
import { createAbortablePromise } from '../utils/abortable-promise'
7+
import { armYouTubeReadiness, useYouTubeReadinessState } from '../utils/youtube-readiness'
88

99
export interface YouTubePlayerApi {
1010
YT: MaybePromise<{
@@ -37,19 +37,19 @@ declare global {
3737

3838
export type YouTubePlayerInput = RegistryScriptInput
3939
const cleanupDecoration = Symbol('nuxt-scripts:youtube-cleanup')
40+
const readinessDecoration = Symbol('nuxt-scripts:youtube-readiness')
4041

4142
export function useScriptYouTubePlayer<T extends YouTubePlayerApi>(_options: YouTubePlayerInput): UseScriptContext<T> {
42-
let readyPromise: Promise<void> = Promise.resolve()
43-
let readyController: AbortController | undefined
4443
const instance = useRegistryScript<T>('youtubePlayer', () => ({
4544
scriptInput: {
4645
src: 'https://www.youtube.com/iframe_api',
4746
crossorigin: false, // crossorigin can't be set or it breaks
4847
},
4948
scriptOptions: {
5049
use() {
50+
const readiness = useYouTubeReadinessState()
5151
return {
52-
YT: window.YT || readyPromise.then(() => {
52+
YT: window.YT || readiness.promise.then(() => {
5353
return window.YT
5454
}),
5555
}
@@ -58,49 +58,21 @@ export function useScriptYouTubePlayer<T extends YouTubePlayerApi>(_options: You
5858
clientInit: import.meta.server
5959
? undefined
6060
: () => {
61-
readyController?.abort()
62-
readyController = new AbortController()
63-
readyPromise = createAbortablePromise((resolve) => {
64-
const previousReady = window.onYouTubeIframeAPIReady
65-
let onReady: () => void
66-
const restoreReady = () => {
67-
if (window.onYouTubeIframeAPIReady !== onReady)
68-
return
69-
if (previousReady)
70-
window.onYouTubeIframeAPIReady = previousReady
71-
else
72-
delete (window as any).onYouTubeIframeAPIReady
73-
}
74-
onReady = () => {
75-
restoreReady()
76-
try {
77-
previousReady?.()
78-
}
79-
catch (error) {
80-
if (import.meta.dev)
81-
console.error('[nuxt-scripts] Previous onYouTubeIframeAPIReady handler failed:', error)
82-
}
83-
finally {
84-
resolve()
85-
}
86-
}
87-
window.onYouTubeIframeAPIReady = onReady
88-
return restoreReady
89-
}, {
90-
signal: readyController.signal,
91-
abortMessage: 'YouTube API readiness wait was aborted',
92-
})
93-
// Removal can reject readiness before any caller has requested YT.
94-
// Mark the internal promise handled while preserving rejection for
95-
// consumers that do await the promise returned by `use()`.
96-
void readyPromise.then(undefined, () => undefined)
61+
armYouTubeReadiness(useYouTubeReadinessState())
9762
},
9863
}), _options)
99-
if (import.meta.client && !(instance as any)[cleanupDecoration]) {
100-
;(instance as any)[cleanupDecoration] = true
64+
const clientInstance = import.meta.server || typeof window === 'undefined'
65+
? undefined
66+
: instance as UseScriptContext<T> & {
67+
[cleanupDecoration]?: boolean
68+
[readinessDecoration]?: ReturnType<typeof useYouTubeReadinessState>
69+
}
70+
if (clientInstance && !clientInstance[cleanupDecoration]) {
71+
clientInstance[cleanupDecoration] = true
72+
clientInstance[readinessDecoration] = useYouTubeReadinessState()
10173
const originalRemove = instance.remove
10274
instance.remove = () => {
103-
readyController?.abort()
75+
clientInstance[readinessDecoration]?.controller?.abort()
10476
return originalRemove()
10577
}
10678
}

0 commit comments

Comments
 (0)