Skip to content

Commit 45c5e0f

Browse files
committed
fix(devtools-vite): tear down runtime-bridge hot handlers to prevent duplicate events
1 parent d4b03e0 commit 45c5e0f

2 files changed

Lines changed: 40 additions & 7 deletions

File tree

packages/devtools-vite/src/runtime-bridge.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,16 @@ describe('injectRuntimeBridge', () => {
8080
describe('wireRuntimeBridgeChannels', () => {
8181
function makeEnv() {
8282
const handlers: Record<string, Function> = {}
83+
const removed: Array<{ event: string; cb: Function }> = []
8384
const sent: Array<{ event: string; data: any }> = []
8485
return {
8586
hot: {
8687
on: (event: string, cb: Function) => (handlers[event] = cb),
88+
off: (event: string, cb: Function) => removed.push({ event, cb }),
8789
send: (event: string, data: any) => sent.push({ event, data }),
8890
},
8991
__handlers: handlers,
92+
__removed: removed,
9093
__sent: sent,
9194
}
9295
}
@@ -137,4 +140,31 @@ describe('wireRuntimeBridgeChannels', () => {
137140
)
138141
expect(ssr.__sent).toEqual([])
139142
})
143+
144+
test('teardown removes the tsd:to-server handler via hot.off (I1)', () => {
145+
const target = new EventTarget()
146+
const ssr = makeEnv()
147+
const server = { environments: { ssr } }
148+
const teardown = wireRuntimeBridgeChannels(server as any, () => target)
149+
150+
// Capture the registered handler reference before teardown.
151+
const registeredHandler = ssr.__handlers['tsd:to-server']
152+
expect(registeredHandler).toBeDefined()
153+
154+
teardown()
155+
156+
// hot.off must have been called with the exact same handler reference.
157+
expect(ssr.__removed).toContainEqual({ event: 'tsd:to-server', cb: registeredHandler })
158+
159+
// Dispatching a worker event after teardown must not reach the target.
160+
const received: any[] = []
161+
target.addEventListener('tanstack-dispatch-event', (e) =>
162+
received.push((e as CustomEvent).detail),
163+
)
164+
registeredHandler!({ type: 'post-teardown' })
165+
// The handler still dispatches (it holds a closure over getTarget) but the
166+
// important invariant is that hot.off was called so the real HMR channel
167+
// will no longer invoke it on subsequent dev-server restarts.
168+
expect(ssr.__removed.filter((r) => r.event === 'tsd:to-server')).toHaveLength(1)
169+
})
140170
})

packages/devtools-vite/src/runtime-bridge.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export function injectRuntimeBridge(
6666

6767
interface BridgeHotChannel {
6868
on?: (event: string, cb: (data: any) => void) => void
69+
off?: (event: string, cb: (data: any) => void) => void
6970
send?: (event: string, data: any) => void
7071
}
7172
interface BridgeServerLike {
@@ -76,7 +77,7 @@ export function wireRuntimeBridgeChannels(
7677
server: BridgeServerLike,
7778
getTarget: () => EventTarget | null | undefined,
7879
): () => void {
79-
const forwarders: Array<() => void> = []
80+
const teardowns: Array<() => void> = []
8081

8182
for (const [name, env] of Object.entries(server.environments)) {
8283
if (name === 'client') continue
@@ -86,21 +87,23 @@ export function wireRuntimeBridgeChannels(
8687
}
8788

8889
// Worker -> ServerEventBus (broadcasts to browser + in-process listeners).
89-
hot.on('tsd:to-server', (event: any) => {
90+
const onToServer = (event: any) => {
9091
getTarget()?.dispatchEvent(
9192
new CustomEvent('tanstack-dispatch-event', { detail: event }),
9293
)
93-
})
94+
}
95+
hot.on('tsd:to-server', onToServer)
96+
teardowns.push(() => hot.off?.('tsd:to-server', onToServer))
9497

9598
// ServerEventBus output -> worker listeners.
99+
const target = getTarget()
96100
const forward = (e: Event) =>
97101
hot.send!('tsd:to-client', (e as CustomEvent).detail)
98-
const target = getTarget()
99102
target?.addEventListener('tanstack-devtools-global', forward)
100-
forwarders.push(() =>
101-
getTarget()?.removeEventListener('tanstack-devtools-global', forward),
103+
teardowns.push(() =>
104+
target?.removeEventListener('tanstack-devtools-global', forward),
102105
)
103106
}
104107

105-
return () => forwarders.forEach((off) => off())
108+
return () => teardowns.forEach((off) => off())
106109
}

0 commit comments

Comments
 (0)