From 6c194028db01becca312ec2825f793008b37ad9c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 19 Jun 2026 14:02:21 +0200 Subject: [PATCH] fix(devtools): restore plugin marketplace and harden event bus connection The plugin marketplace rendered empty ("No additional plugins available") because the `mounted` -> `package-json-read` round-trip could be lost: - ClientEventBus.emitToServer silently dropped events emitted while the WebSocket was still connecting. They are now buffered and flushed once the socket opens. - The marketplace re-requests package.json on every open and retries until it arrives, so re-opening always re-fetches the plugin list. - The basic React example reconnects to the server bus (connectToServerBus). - Added TanStack AI Devtools to the plugin marketplace registry. --- .changeset/marketplace-plugin-list.md | 28 ++++++ examples/react/basic/src/setup.tsx | 3 + .../devtools/src/tabs/plugin-marketplace.tsx | 25 ++++- packages/devtools/src/tabs/plugin-registry.ts | 23 +++++ packages/event-bus/src/client/client.ts | 43 +++++++- packages/event-bus/tests/client.test.ts | 98 +++++++++++++++++++ 6 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 .changeset/marketplace-plugin-list.md diff --git a/.changeset/marketplace-plugin-list.md b/.changeset/marketplace-plugin-list.md new file mode 100644 index 00000000..bf692cb9 --- /dev/null +++ b/.changeset/marketplace-plugin-list.md @@ -0,0 +1,28 @@ +--- +'@tanstack/angular-devtools': patch +'@tanstack/devtools': patch +'@tanstack/devtools-a11y': patch +'@tanstack/devtools-client': patch +'@tanstack/devtools-ui': patch +'@tanstack/devtools-utils': patch +'@tanstack/devtools-vite': patch +'@tanstack/devtools-event-bus': patch +'@tanstack/devtools-event-client': patch +'@tanstack/preact-devtools': patch +'@tanstack/react-devtools': patch +'@tanstack/solid-devtools': patch +'@tanstack/vue-devtools': patch +--- + +Fix the plugin marketplace rendering empty ("No additional plugins available") +when it should list installable plugins. + +- The client event bus no longer silently drops events emitted while its + WebSocket is still connecting. Such events are now queued and flushed once + the socket opens, so the marketplace's `mounted` request reliably reaches the + server bus. +- The marketplace now re-requests `package.json` every time it is opened and + retries until the data arrives, so re-opening always re-fetches the plugin + list. +- Added TanStack AI Devtools (`@tanstack/react-ai-devtools`) to the plugin + marketplace registry. diff --git a/examples/react/basic/src/setup.tsx b/examples/react/basic/src/setup.tsx index d164bd3b..c246fcea 100644 --- a/examples/react/basic/src/setup.tsx +++ b/examples/react/basic/src/setup.tsx @@ -59,6 +59,9 @@ export default function DevtoolsExample() { return ( <> { }, ) + // Request the current package.json every time the marketplace opens. + // The `mounted` -> `package-json-read` round-trip is only triggered here, + // but the event can be dropped if the event bus WebSocket isn't connected + // yet when we emit (it is sent without queueing). When that happens the + // marketplace stays stuck on the empty "all installed" state. Retry until + // the package.json actually arrives so re-opening always re-fetches. + const requestPackageJson = () => + devtoolsEventClient.emit('mounted', undefined) + + let refetchAttempts = 0 + const refetchInterval = setInterval(() => { + if (currentPackageJson() || refetchAttempts >= 10) { + clearInterval(refetchInterval) + return + } + refetchAttempts++ + requestPackageJson() + }, 400) + onCleanup(() => { cleanupJsonRead() cleanupJsonUpdated() cleanupDevtoolsInstalled() cleanupPluginAdded() + clearInterval(refetchInterval) }) - // Emit mounted event to trigger package.json read - devtoolsEventClient.emit('mounted', undefined) + + // Kick off the initial request immediately on open. + requestPackageJson() }) const updatePluginCards = (pkg: PackageJson | null) => { diff --git a/packages/devtools/src/tabs/plugin-registry.ts b/packages/devtools/src/tabs/plugin-registry.ts index 58795db1..7b87e460 100644 --- a/packages/devtools/src/tabs/plugin-registry.ts +++ b/packages/devtools/src/tabs/plugin-registry.ts @@ -226,6 +226,29 @@ const PLUGIN_REGISTRY: Record = { tags: ['TanStack', 'a11y'], }, + // TanStack AI + '@tanstack/react-ai-devtools': { + packageName: '@tanstack/react-ai-devtools', + title: 'TanStack AI Devtools', + description: + 'Debug TanStack AI - inspect messages, token usage, streaming chunks, tool calls, and reasoning.', + requires: { + packageName: '@tanstack/ai-react', + minVersion: '0.8.0', + }, + pluginImport: { + importName: 'aiDevtoolsPlugin', + type: 'function', + }, + pluginId: 'tanstack-ai', + docsUrl: 'https://tanstack.com/ai', + repoUrl: 'https://github.com/TanStack/ai', + author: 'TanStack', + framework: 'react', + isNew: true, + tags: ['TanStack', 'AI', 'streaming'], + }, + // ========================================== // THIRD-PARTY PLUGINS - Examples // ========================================== diff --git a/packages/event-bus/src/client/client.ts b/packages/event-bus/src/client/client.ts index a3c2f7b9..038d3c2b 100644 --- a/packages/event-bus/src/client/client.ts +++ b/packages/event-bus/src/client/client.ts @@ -73,6 +73,10 @@ export class ClientEventBus { #debug: boolean #connectToServerBus: boolean #broadcastChannel: BroadcastChannel | null + // Events emitted while the WebSocket is still establishing its connection. + // They are buffered here and flushed once the socket opens so early events + // (e.g. the marketplace's `mounted` request) are never silently dropped. + #pendingServerEvents: Array = [] #dispatcher = (e: Event) => { const event = (e as CustomEvent).detail this.emitToServer(event) @@ -126,14 +130,36 @@ export class ClientEventBus { this.#eventTarget.dispatchEvent(globalEvent) } + private flushPendingServerEvents() { + if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) { + return + } + const pending = this.#pendingServerEvents + this.#pendingServerEvents = [] + for (const json of pending) { + this.debugLog('Flushing queued event to server via WS') + this.#socket.send(json) + } + } + private emitToServer(event: TanStackDevtoolsEvent) { const json = stringifyWithBigInt(event) // try to emit it to the event bus first - if (this.#socket && this.#socket.readyState === WebSocket.OPEN) { - this.debugLog('Emitting event to server via WS', event) - this.#socket.send(json) - // try to emit to SSE if WebSocket is not available (this will only happen on the client side) - } else if (this.#eventSource) { + if (this.#socket) { + if (this.#socket.readyState === WebSocket.OPEN) { + this.debugLog('Emitting event to server via WS', event) + this.#socket.send(json) + } else if (this.#socket.readyState === WebSocket.CONNECTING) { + // The socket handshake is still in flight. Buffer the event instead of + // dropping it; it will be sent once the connection opens. + this.debugLog('WebSocket still connecting, queueing event', event) + this.#pendingServerEvents.push(json) + } + // CLOSING/CLOSED sockets cannot deliver; the event is dropped. + return + } + // try to emit to SSE if WebSocket is not available (this will only happen on the client side) + if (this.#eventSource) { this.debugLog('Emitting event to server via SSE', event) fetch(`${this.#protocol}://${this.#host}:${this.#port}/__devtools/send`, { @@ -178,6 +204,7 @@ export class ClientEventBus { this.#socket?.close() this.#socket = null this.#eventSource = null + this.#pendingServerEvents = [] } private getGlobalTarget() { if (typeof window !== 'undefined') { @@ -209,6 +236,10 @@ export class ClientEventBus { this.#socket = new WebSocket( `${wsProtocol}://${this.#host}:${this.#port}/__devtools/ws`, ) + this.#socket.onopen = () => { + this.debugLog('WebSocket connection opened') + this.flushPendingServerEvents() + } this.#socket.onmessage = (e) => { this.debugLog('Received message from server', e.data) this.handleEventReceived(e.data) @@ -216,6 +247,8 @@ export class ClientEventBus { this.#socket.onclose = () => { this.debugLog('WebSocket connection closed') this.#socket = null + // Drop any still-queued events — there is no open socket to deliver them. + this.#pendingServerEvents = [] } this.#socket.onerror = () => { this.debugLog('WebSocket connection error') diff --git a/packages/event-bus/tests/client.test.ts b/packages/event-bus/tests/client.test.ts index 36f47c96..d1cebc82 100644 --- a/packages/event-bus/tests/client.test.ts +++ b/packages/event-bus/tests/client.test.ts @@ -48,6 +48,27 @@ function createMockEventSourceClass() { } } +function createConnectingWebSocketClass() { + // Starts in CONNECTING state; tests flip readyState and fire onopen manually. + return class ConnectingWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSED = 3 + url: string + readyState = 0 // CONNECTING + onopen: any = null + onmessage: any = null + onclose: any = null + onerror: any = null + send = vi.fn() + close = vi.fn() + constructor(url: string) { + this.url = url + mockWebSocketInstances.push(this) + } + } +} + function createThrowingWebSocketClass() { return class ThrowingWebSocket { static OPEN = 1 @@ -262,6 +283,83 @@ describe('ClientEventBus', () => { }) }) + describe('emitToServer while WebSocket is connecting', () => { + it('should queue events while connecting and flush them once the socket opens', () => { + vi.stubGlobal('WebSocket', createConnectingWebSocketClass()) + + const bus = new ClientEventBus({ connectToServerBus: true }) + bus.start() + + const socket = mockWebSocketInstances[0] + expect(socket.readyState).toBe(0) // CONNECTING + + // Events emitted while connecting must not be sent yet... + window.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { type: 'queued:event', payload: { n: 1 } }, + }), + ) + window.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { type: 'queued:event', payload: { n: 2 } }, + }), + ) + expect(socket.send).not.toHaveBeenCalled() + + // ...but flushed in order as soon as the connection opens. + socket.readyState = 1 // OPEN + socket.onopen?.() + + expect(socket.send).toHaveBeenCalledTimes(2) + expect(socket.send.mock.calls[0][0]).toContain('"n":1') + expect(socket.send.mock.calls[1][0]).toContain('"n":2') + bus.stop() + }) + + it('should send immediately once the socket is open', () => { + vi.stubGlobal('WebSocket', createConnectingWebSocketClass()) + + const bus = new ClientEventBus({ connectToServerBus: true }) + bus.start() + + const socket = mockWebSocketInstances[0] + socket.readyState = 1 // OPEN + socket.onopen?.() + + window.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { type: 'live:event', payload: {} }, + }), + ) + + expect(socket.send).toHaveBeenCalledTimes(1) + bus.stop() + }) + + it('should drop queued events if the socket closes before opening', () => { + vi.stubGlobal('WebSocket', createConnectingWebSocketClass()) + + const bus = new ClientEventBus({ connectToServerBus: true }) + bus.start() + + const socket = mockWebSocketInstances[0] + window.dispatchEvent( + new CustomEvent('tanstack-dispatch-event', { + detail: { type: 'queued:event', payload: {} }, + }), + ) + + // Connection fails before ever opening. + socket.readyState = 3 // CLOSED + socket.onclose?.() + + // Re-flushing (e.g. a late open) must not send the dropped events. + socket.onopen?.() + expect(socket.send).not.toHaveBeenCalled() + bus.stop() + }) + }) + describe('event dispatching', () => { it('should emit events to a subscribed listener', () => { const bus = new ClientEventBus()