@@ -4,15 +4,15 @@ import type { $Fetch } from 'nitropack/types'
44import type { Ref } from 'vue'
55import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
66import { ofetch } from 'ofetch'
7- import { onScopeDispose , ref , watch , watchEffect } from 'vue'
7+ import { ref , watch , watchEffect } from 'vue'
88import { firstPartyData , isConnected , path , query , refreshSources , standaloneUrl , syncScripts , version } from './state'
99
1010export const appFetch : Ref < $Fetch | undefined > = ref ( )
1111export const devtools : Ref < NuxtDevtoolsClient | undefined > = ref ( )
1212export const colorMode : Ref < 'dark' | 'light' > = ref ( 'dark' )
1313
1414export 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 )
0 commit comments