Skip to content

Commit b8f3533

Browse files
authored
fix(script): harden lifecycle and SDK loading (#850)
1 parent a17c05a commit b8f3533

62 files changed

Lines changed: 4417 additions & 663 deletions

Some content is hidden

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

‎.nuxtrc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
imports.autoImport=true
22
modules.0="@nuxt/scripts"
3-
setups.@nuxt/test-utils="4.0.3"
3+
setups.@nuxt/test-utils="4.1.0"

‎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
importtype{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
exportconstappFetch: Ref<$Fetch|undefined>=ref()
1111
exportconstdevtools: Ref<NuxtDevtoolsClient|undefined>=ref()
1212
exportconstcolorMode: Ref<'dark'|'light'>=ref('dark')
1313

1414
exportinterfaceDevtoolsConnectionOptions{
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-
exportfunctionuseDevtoolsConnection(options: DevtoolsConnectionOptions={}): void{
30+
exportfunctionuseDevtoolsConnection(options: DevtoolsConnectionOptions={}): ()=>void{
3131
constinIframe=window.parent!==window
32+
letdisposed=false
33+
constconnectionCleanups: Array<()=>void>=[]
34+
letpollTimer: ReturnType<typeofsetInterval>|undefined
35+
letpollController: AbortController|undefined
36+
37+
conststopPolling=()=>{
38+
if(pollTimer){
39+
clearInterval(pollTimer)
40+
pollTimer=undefined
41+
}
42+
pollController?.abort()
43+
pollController=undefined
44+
}
45+
46+
constcleanupConnection=()=>{
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+
letstopClientConnection=()=>{}
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+
constcleanupConnected=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
constremoveAfterEach=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-
letpollTimer: ReturnType<typeofsetInterval>|undefined
86+
constpoll=async(url: string)=>{
87+
// A slow/unreachable app must not accumulate overlapping interval requests.
88+
if(pollController)
89+
return
90+
constcontroller=newAbortController()
91+
pollController=controller
92+
try{
93+
awaitpollStandaloneState(url,controller.signal)
94+
}
95+
finally{
96+
if(pollController===controller)
97+
pollController=undefined
98+
}
99+
}
60100

61-
watch(()=>standaloneUrl.value,(url)=>{
101+
conststopStandaloneWatch=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})asunknownas$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+
voidpoll(url)
112+
pollTimer=setInterval(()=>voidpoll(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-
asyncfunctionpollStandaloneState(baseUrl: string){
127+
asyncfunctionpollStandaloneState(baseUrl: string,signal: AbortSignal){
128+
consttimeoutController=newAbortController()
129+
consttimeout=setTimeout(()=>timeoutController.abort(),3000)
130+
constonAbort=()=>timeoutController.abort()
131+
signal.addEventListener('abort',onAbort,{once: true})
87132
try{
88133
constres=awaitfetch(`${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+
constdisposeConnection=useDevtoolsConnection({
112161
onConnected: (client)=>{
113-
client.host.nuxt.hooks.hook('scripts:updated',(ctx: any)=>{
162+
conststopScriptsHook=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+
returnstopScriptsHook
119169
},
120170
})
171+
172+
functiondisposeModuleConnection(){
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
exportconstfirstPartyData=ref<FirstPartyDevtoolsData|null>(null)
8585

8686
let_lastSyncedScripts: any[]|null=null
87+
constscriptFetches=newMap<string,AbortController>()
8788

8889
exportasyncfunctioninitRegistry(){
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(newSet())
110112
return
111113
}
112114
_lastSyncedScripts=_scripts
115+
constactiveSources=newSet(
116+
Object.values(_scripts)
117+
.map((script: any)=>script?.src)
118+
.filter((src): src is string=>typeofsrc==='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
constscriptSizeKey=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+
constcontroller=newAbortController()
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+
functionpruneScriptState(activeSources: Set<string>){
162+
for(const[src,controller]ofscriptFetches){
163+
if(!activeSources.has(src)){
164+
controller.abort()
165+
scriptFetches.delete(src)
166+
}
167+
}
168+
for(conststateof[scriptSizes,scriptErrors,scriptTabs]){
169+
for(constsrcofObject.keys(state)){
170+
if(!activeSources.has(src))
171+
deletestate[src]
172+
}
173+
}
174+
}
175+
145176
// Script status helper (handles both reactive refs from embedded mode and plain strings from standalone)
146177
exportfunctiongetScriptStatus(script: any): string{
147178
conststatus=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-
exportasyncfunctionfetchScript(url: string){
2-
constcompressedResponse=awaitfetch(url,{headers: {'Accept-Encoding': 'gzip'}}).catch((err)=>{
1+
exportasyncfunctionfetchScript(url: string,signal?: AbortSignal){
2+
constcompressedResponse=awaitfetch(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
returncompressedResponseas{size: null,error: Error}
1013
}
1114
if(!compressedResponse.ok){
15+
awaitcancelResponseBody(compressedResponse)
1216
return{
1317
size: null,
1418
error: newError(`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
constcontentType=compressedResponse.headers.get('Content-Type')||''
1923
if(contentType.includes('text/html')){
24+
awaitcancelResponseBody(compressedResponse)
2025
return{size: null}
2126
}
22-
constsize=awaitgetResponseSize(compressedResponse)
27+
letsize: number|null
28+
try{
29+
size=awaitgetResponseSize(compressedResponse)
30+
}
31+
catch(error){
32+
return{
33+
size: null,
34+
error: errorinstanceofError ? error : newError(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
asyncfunctiongetResponseSize(response: Response){
34-
constreader=response.body?.getReader()
3548
constcontentLength=response.headers.get('Content-Length')
3649

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

5382
functionbytesToSize(bytes: number){

0 commit comments

Comments
 (0)