Skip to content

Commit 4bab673

Browse files
committed
fix(script): harden lifecycle and SDK loading (#850)
1 parent cd17e4b commit 4bab673

60 files changed

Lines changed: 2478 additions & 1149 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/devtools-app/composables/rpc.ts

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ import type { $Fetch } from 'nitropack/types'
44
import type { Ref } from 'vue'
55
import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
66
import { ofetch } from 'ofetch'
7-
import { onScopeDispose, ref, watch, watchEffect } from 'vue'
7+
import { ref, watch, watchEffect } from 'vue'
88
import { firstPartyData, isConnected, path, query, refreshSources, standaloneUrl, syncScripts, version } from './state'
99

1010
export const appFetch: Ref<$Fetch | undefined> = ref()
1111
export const devtools: Ref<NuxtDevtoolsClient | undefined> = ref()
1212
export const colorMode: Ref<'dark' | 'light'> = ref('dark')
1313

1414
export interface DevtoolsConnectionOptions {
15-
onConnected?: (client: any) => void
15+
onConnected?: (client: any) => void | (() => void)
1616
onRouteChange?: (route: any) => void
1717
}
1818

@@ -27,66 +27,111 @@ const STANDALONE_POLL_INTERVAL = 2000
2727
* - **Embedded**: running inside Nuxt DevTools iframe (automatic)
2828
* - **Standalone**: running directly in a browser tab with a manual dev server URL
2929
*/
30-
export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): void {
30+
export function useDevtoolsConnection(options: DevtoolsConnectionOptions = {}): () => void {
3131
const inIframe = window.parent !== window
32+
let disposed = false
33+
const connectionCleanups: Array<() => void> = []
34+
let pollTimer: ReturnType<typeof setInterval> | undefined
35+
let pollController: AbortController | undefined
36+
37+
const stopPolling = () => {
38+
if (pollTimer) {
39+
clearInterval(pollTimer)
40+
pollTimer = undefined
41+
}
42+
pollController?.abort()
43+
pollController = undefined
44+
}
45+
46+
const cleanupConnection = () => {
47+
connectionCleanups.splice(0).forEach(cleanup => cleanup())
48+
devtools.value = undefined
49+
appFetch.value = undefined
50+
isConnected.value = false
51+
}
3252

3353
// Embedded mode: connect via devtools-kit iframe client
54+
let stopClientConnection = () => {}
3455
if (inIframe) {
35-
onDevtoolsClientConnected(async (client) => {
56+
stopClientConnection = onDevtoolsClientConnected((client) => {
57+
if (disposed)
58+
return
59+
stopPolling()
60+
cleanupConnection()
3661
isConnected.value = true
3762
// @ts-expect-error untyped
3863
appFetch.value = client.host.app.$fetch
39-
watchEffect(() => {
64+
connectionCleanups.push(watchEffect(() => {
4065
colorMode.value = client.host.app.colorMode.value
41-
})
66+
}))
4267
devtools.value = client.devtools
43-
options.onConnected?.(client)
68+
const cleanupConnected = options.onConnected?.(client)
69+
if (cleanupConnected)
70+
connectionCleanups.push(cleanupConnected)
4471

4572
if (options.onRouteChange) {
4673
const $route = client.host.nuxt.vueApp.config.globalProperties?.$route
4774
options.onRouteChange($route)
4875
const removeAfterEach = client.host.nuxt.$router.afterEach((route: any) => {
4976
options.onRouteChange!(route)
5077
})
51-
// Clean up when devtools client disconnects
52-
// @ts-expect-error app:unmount exists at runtime but is not in RuntimeNuxtHooks
53-
client.host.nuxt.hook('app:unmount', removeAfterEach)
78+
connectionCleanups.push(removeAfterEach)
5479
}
55-
})
80+
// @ts-expect-error app:unmount exists at runtime but is not in RuntimeNuxtHooks
81+
connectionCleanups.push(client.host.nuxt.hook('app:unmount', cleanupConnection))
82+
}) || (() => {})
5683
}
5784

5885
// Standalone mode: create appFetch from manually entered URL and poll for state
59-
let pollTimer: ReturnType<typeof setInterval> | undefined
86+
const poll = async (url: string) => {
87+
// A slow/unreachable app must not accumulate overlapping interval requests.
88+
if (pollController)
89+
return
90+
const controller = new AbortController()
91+
pollController = controller
92+
try {
93+
await pollStandaloneState(url, controller.signal)
94+
}
95+
finally {
96+
if (pollController === controller)
97+
pollController = undefined
98+
}
99+
}
60100

61-
watch(() => standaloneUrl.value, (url) => {
101+
const stopStandaloneWatch = watch(() => standaloneUrl.value, (url) => {
62102
// Clean up previous polling
63-
if (pollTimer) {
64-
clearInterval(pollTimer)
65-
pollTimer = undefined
66-
}
103+
stopPolling()
67104

68105
if (url && !isConnected.value) {
69106
appFetch.value = ofetch.create({ baseURL: url }) as unknown as $Fetch
70107
// Use system color scheme preference
71108
colorMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
72109
refreshSources()
73110
// Start polling the standalone API for script state
74-
pollStandaloneState(url)
75-
pollTimer = setInterval(pollStandaloneState, STANDALONE_POLL_INTERVAL, url)
111+
void poll(url)
112+
pollTimer = setInterval(() => void poll(url), STANDALONE_POLL_INTERVAL)
76113
}
77114
}, { immediate: true })
78115

79-
onScopeDispose(() => {
80-
if (pollTimer) {
81-
clearInterval(pollTimer)
82-
}
83-
})
116+
return () => {
117+
if (disposed)
118+
return
119+
disposed = true
120+
stopPolling()
121+
stopStandaloneWatch()
122+
stopClientConnection()
123+
cleanupConnection()
124+
}
84125
}
85126

86-
async function pollStandaloneState(baseUrl: string) {
127+
async function pollStandaloneState(baseUrl: string, signal: AbortSignal) {
128+
const timeoutController = new AbortController()
129+
const timeout = setTimeout(() => timeoutController.abort(), 3000)
130+
const onAbort = () => timeoutController.abort()
131+
signal.addEventListener('abort', onAbort, { once: true })
87132
try {
88133
const res = await fetch(`${baseUrl}${STANDALONE_API_PATH}`, {
89-
signal: AbortSignal.timeout(3000),
134+
signal: timeoutController.signal,
90135
})
91136
if (!res.ok)
92137
return
@@ -106,15 +151,29 @@ async function pollStandaloneState(baseUrl: string) {
106151
catch {
107152
// Standalone API not available or not enabled, silently ignore
108153
}
154+
finally {
155+
clearTimeout(timeout)
156+
signal.removeEventListener('abort', onAbort)
157+
}
109158
}
110159

111-
useDevtoolsConnection({
160+
const disposeConnection = useDevtoolsConnection({
112161
onConnected: (client) => {
113-
client.host.nuxt.hooks.hook('scripts:updated', (ctx: any) => {
162+
const stopScriptsHook = client.host.nuxt.hooks.hook('scripts:updated', (ctx: any) => {
114163
syncScripts(ctx.scripts)
115164
})
116165
version.value = client.host.nuxt.$config.public['nuxt-scripts'].version
117166
firstPartyData.value = client.host.nuxt.$config.public['nuxt-scripts-devtools'] || null
118167
syncScripts(client.host.nuxt._scripts || {})
168+
return stopScriptsHook
119169
},
120170
})
171+
172+
function disposeModuleConnection() {
173+
window.removeEventListener('beforeunload', disposeModuleConnection)
174+
disposeConnection()
175+
}
176+
177+
window.addEventListener('beforeunload', disposeModuleConnection, { once: true })
178+
if (import.meta.hot)
179+
import.meta.hot.dispose(disposeModuleConnection)

packages/devtools-app/composables/state.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const version = ref<string | null>(null)
8484
export const firstPartyData = ref<FirstPartyDevtoolsData | null>(null)
8585

8686
let _lastSyncedScripts: any[] | null = null
87+
const scriptFetches = new Map<string, AbortController>()
8788

8889
export async function initRegistry() {
8990
scriptRegistry.value = await _registryPromise
@@ -107,9 +108,16 @@ export function syncScripts(_scripts: any[]) {
107108
if (!_scripts || typeof _scripts !== 'object') {
108109
_lastSyncedScripts = null
109110
scripts.value = {}
111+
pruneScriptState(new Set())
110112
return
111113
}
112114
_lastSyncedScripts = _scripts
115+
const activeSources = new Set(
116+
Object.values(_scripts)
117+
.map((script: any) => script?.src)
118+
.filter((src): src is string => typeof src === 'string' && !!src),
119+
)
120+
pruneScriptState(activeSources)
113121
scripts.value = Object.fromEntries(
114122
Object.entries({ ..._scripts })
115123
.map(([key, script]: [string, any]) => {
@@ -124,9 +132,13 @@ export function syncScripts(_scripts: any[]) {
124132
script.loadTime = msToHumanReadable(loadedAt - loadingAt)
125133
const scriptSizeKey = script.src
126134
// Skip size fetching in standalone mode (cross-origin fetch blocked by CORS)
127-
if (!scriptSizes[scriptSizeKey] && script.src && !isStandalone.value) {
128-
fetchScript(script.src)
135+
if (!scriptSizes[scriptSizeKey] && !scriptErrors[scriptSizeKey] && script.src && !isStandalone.value && !scriptFetches.has(scriptSizeKey)) {
136+
const controller = new AbortController()
137+
scriptFetches.set(scriptSizeKey, controller)
138+
fetchScript(script.src, controller.signal)
129139
.then((res) => {
140+
if (controller.signal.aborted || !activeSources.has(scriptSizeKey))
141+
return
130142
if (res.size) {
131143
scriptSizes[scriptSizeKey] = res.size
132144
script.size = res.size
@@ -136,12 +148,31 @@ export function syncScripts(_scripts: any[]) {
136148
script.error = scriptErrors[scriptSizeKey]
137149
}
138150
})
151+
.finally(() => {
152+
if (scriptFetches.get(scriptSizeKey) === controller)
153+
scriptFetches.delete(scriptSizeKey)
154+
})
139155
}
140156
return [key, script]
141157
}),
142158
)
143159
}
144160

161+
function pruneScriptState(activeSources: Set<string>) {
162+
for (const [src, controller] of scriptFetches) {
163+
if (!activeSources.has(src)) {
164+
controller.abort()
165+
scriptFetches.delete(src)
166+
}
167+
}
168+
for (const state of [scriptSizes, scriptErrors, scriptTabs]) {
169+
for (const src of Object.keys(state)) {
170+
if (!activeSources.has(src))
171+
delete state[src]
172+
}
173+
}
174+
}
175+
145176
// Script status helper (handles both reactive refs from embedded mode and plain strings from standalone)
146177
export function getScriptStatus(script: any): string {
147178
const status = script?.$script?.status

packages/devtools-app/utils/fetch.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
export async function fetchScript(url: string) {
2-
const compressedResponse = await fetch(url, { headers: { 'Accept-Encoding': 'gzip' } }).catch((err) => {
1+
export async function fetchScript(url: string, signal?: AbortSignal) {
2+
const compressedResponse = await fetch(url, {
3+
headers: { 'Accept-Encoding': 'gzip' },
4+
signal,
5+
}).catch((err) => {
36
return {
47
size: null,
58
error: err,
@@ -9,6 +12,7 @@ export async function fetchScript(url: string) {
912
return compressedResponse as { size: null, error: Error }
1013
}
1114
if (!compressedResponse.ok) {
15+
await cancelResponseBody(compressedResponse)
1216
return {
1317
size: null,
1418
error: new Error(`Failed to fetch ${compressedResponse.status} ${compressedResponse.statusText}`),
@@ -17,9 +21,19 @@ export async function fetchScript(url: string) {
1721
// Guard against measuring HTML error pages as script sizes
1822
const contentType = compressedResponse.headers.get('Content-Type') || ''
1923
if (contentType.includes('text/html')) {
24+
await cancelResponseBody(compressedResponse)
2025
return { size: null }
2126
}
22-
const size = await getResponseSize(compressedResponse)
27+
let size: number | null
28+
try {
29+
size = await getResponseSize(compressedResponse)
30+
}
31+
catch (error) {
32+
return {
33+
size: null,
34+
error: error instanceof Error ? error : new Error(String(error)),
35+
}
36+
}
2337
if (!size) {
2438
return {
2539
size: null,
@@ -31,23 +45,38 @@ export async function fetchScript(url: string) {
3145
}
3246

3347
async function getResponseSize(response: Response) {
34-
const reader = response.body?.getReader()
3548
const contentLength = response.headers.get('Content-Length')
3649

3750
if (contentLength) {
51+
await cancelResponseBody(response)
3852
return Number(contentLength)
3953
}
54+
const reader = response.body?.getReader()
4055
if (!reader) {
4156
return null
4257
}
43-
let total = 0
44-
let done = false
45-
while (!done) {
46-
const data = await reader.read()
47-
done = data.done
48-
total += data.value?.length || 0
58+
try {
59+
let total = 0
60+
let done = false
61+
while (!done) {
62+
const data = await reader.read()
63+
done = data.done
64+
total += data.value?.length || 0
65+
}
66+
return total > 0 ? total : null
67+
}
68+
finally {
69+
reader.releaseLock()
70+
}
71+
}
72+
73+
async function cancelResponseBody(response: Response) {
74+
try {
75+
await response.body?.cancel()
76+
}
77+
catch {
78+
// The response is being discarded, so cancellation failure is non-fatal.
4979
}
50-
return total > 0 ? total : null
5180
}
5281

5382
function bytesToSize(bytes: number) {

0 commit comments

Comments
 (0)