Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions packages/devtools/client/composables/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { computed, ref } from 'vue'
import { useState } from '#imports'
import { renderMarkdown } from './client-services/markdown'
import { renderCodeHighlight } from './client-services/shiki'
import { connectPromise, rpc, rpcClient } from './rpc'
import { connectPromise, rpc, rpcClient, upsertClientFunction } from './rpc'

export function useClient() {
return useState<NuxtDevtoolsHostClient>('devtools-client')
Expand Down Expand Up @@ -62,13 +62,8 @@ export function useInjectionClient(): ComputedRef<NuxtDevtoolsIframeClient> {
extendClientRpc(namespace, functions) {
const register = (client: DevToolsRpcClient) => {
for (const [name, handler] of Object.entries(functions)) {
if (typeof handler === 'function') {
client.client.register({
name: `${namespace}:${name}`,
type: 'event',
handler: handler as any,
})
}
if (typeof handler === 'function')
upsertClientFunction(client, `${namespace}:${name}`, handler as any)
}
}

Expand Down
44 changes: 20 additions & 24 deletions packages/devtools/client/composables/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,10 @@ async function connectDevToolsRpc(): Promise<DevToolsRpcClient> {

rpcClient.value = client

// Register client functions so the server can call them
// Register client functions so the server can call them.
for (const [name, handler] of Object.entries(clientFunctions)) {
if (typeof handler === 'function') {
client.client.register({
name,
type: 'event',
handler: handler as any,
})
}
if (typeof handler === 'function')
upsertClientFunction(client, name, handler)
}

// eslint-disable-next-line no-console
Expand All @@ -70,21 +65,22 @@ async function connectDevToolsRpc(): Promise<DevToolsRpcClient> {
export async function registerClientFunctions() {
const client = rpcClient.value || await connectPromise
for (const [name, handler] of Object.entries(clientFunctions)) {
if (typeof handler === 'function') {
try {
client.client.update({
name,
type: 'event',
handler: handler as any,
})
}
catch {
client.client.register({
name,
type: 'event',
handler: handler as any,
})
}
}
if (typeof handler === 'function')
upsertClientFunction(client, name, handler)
}
}

/**
* Register (or replace) a client-side event function on the devframe RPC host.
*
* devframe 0.6 split registration into `register()` (throws DF0021 if the name
* already exists) and `update()` (throws DF0022 if it does not), so pick the
* right one based on the current definitions to keep this an idempotent upsert.
*/
export function upsertClientFunction(client: DevToolsRpcClient, name: string, handler: (...args: any[]) => any) {
const definition = { name, type: 'event', handler } as const
if (client.client.definitions.has(name))
client.client.update(definition as any)
else
client.client.register(definition as any)
}
26 changes: 26 additions & 0 deletions packages/devtools/src/runtime/plugins/view/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export async function setupDevToolsClient({

setupRouteTracking(timeline, router)
setupReactivity(client, router, timeline)
bindVueDevToolsIframe()

clientRef.value = client

Expand All @@ -285,6 +286,31 @@ export async function setupDevToolsClient({
if (e.code === 'KeyD' && e.altKey && e.shiftKey)
client.devtools.toggle()
})

// The Nuxt DevTools panel is rendered as an iframe **inside Vite DevTools'
// dock**, so we never create it ourselves (`getIframe()` above is legacy).
// The `@vue/devtools-kit` iframe messaging channel, however, only talks to
// whichever iframe was registered via `setIframeServerContext()` — without
// that the in-panel Vue DevTools applets (Pinia, component inspector, …) sit
// on "Connecting..." forever because the host backend never learns which
// iframe to answer. Point it at the dock iframe as soon as Vite DevTools
// mounts it (and keep it pointed there if the element is recreated).
function bindVueDevToolsIframe() {
let bound: HTMLIFrameElement | undefined
const bind = () => {
const ctx = getViteDevToolsContext()
const dockIframe = ctx?.docks?.getStateById?.('nuxt:devtools')?.domElements?.iframe as HTMLIFrameElement | null | undefined
if (!dockIframe || dockIframe === bound)
return
bound = dockIframe
iframe = dockIframe
// The channel reads `.contentWindow` lazily on every message, so binding
// the element once is enough even across in-iframe navigations/reloads.
setIframeServerContext(dockIframe)
}
bind()
setInterval(bind, 500)
}
Comment on lines +298 to +313

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

setInterval is never cleared — resource leak and duplicate-interval risk.

The interval created at line 312 runs indefinitely with no cleanup path. If setupDevToolsClient is invoked more than once (e.g., during HMR or module re-initialization), each call spawns a new 500ms interval that stacks on top of previous ones, causing unbounded polling and redundant setIframeServerContext calls.

Even in the single-invocation case, the interval persists forever after binding succeeds, continually traversing getViteDevToolsContext() and the dock state tree every 500ms for the lifetime of the page.

Consider capturing the interval handle and clearing it when the dock iframe is found, then re-establishing polling only if the element is later recreated (e.g., via a MutationObserver or a slower heartbeat). At minimum, guard against duplicate intervals:

🔒 Proposed fix: clear interval after binding, use MutationObserver for recreation
 function bindVueDevToolsIframe() {
     let bound: HTMLIFrameElement | undefined
+    let timer: ReturnType<typeof setInterval> | undefined
+
     const bind = () => {
       const ctx = getViteDevToolsContext()
       const dockIframe = ctx?.docks?.getStateById?.('nuxt:devtools')?.domElements?.iframe as HTMLIFrameElement | null | undefined
       if (!dockIframe || dockIframe === bound)
         return
       bound = dockIframe
       iframe = dockIframe
       setIframeServerContext(dockIframe)
+      // Once bound, stop the initial fast-poll interval and watch for
+      // future iframe recreation via MutationObserver instead.
+      if (timer) {
+        clearInterval(timer)
+         timer = undefined
+      }
+      const observer = new MutationObserver(() => bind())
+      observer.observe(document.body, { childList: true, subtree: true })
     }
-    bind()
-    setInterval(bind, 500)
+    bind()
+    timer = setInterval(bind, 500)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/runtime/plugins/view/client.ts` around lines 298 - 313,
Update bindVueDevToolsIframe to retain and clear the polling interval once the
dock iframe is found and bound, preventing indefinite polling and duplicate
intervals across repeated setup calls. Preserve rebinding when the iframe is
later recreated by using an appropriate lifecycle mechanism, such as a
MutationObserver or controlled slower retry, while ensuring only one active
polling mechanism exists.

}

export function useClientColorMode(): Ref<ColorScheme> {
Expand Down
Loading
Loading