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
28 changes: 28 additions & 0 deletions .changeset/marketplace-plugin-list.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions examples/react/basic/src/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export default function DevtoolsExample() {
return (
<>
<TanStackDevtools
eventBusConfig={{
connectToServerBus: true,
}}
config={{ sourceAction: 'copy-path' }}
plugins={[
{
Expand Down
25 changes: 23 additions & 2 deletions packages/devtools/src/tabs/plugin-marketplace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,35 @@ export const PluginMarketplace = () => {
},
)

// 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) => {
Expand Down
23 changes: 23 additions & 0 deletions packages/devtools/src/tabs/plugin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,29 @@ const PLUGIN_REGISTRY: Record<string, PluginMetadata> = {
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
// ==========================================
Expand Down
43 changes: 38 additions & 5 deletions packages/event-bus/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = []
#dispatcher = (e: Event) => {
const event = (e as CustomEvent).detail
this.emitToServer(event)
Expand Down Expand Up @@ -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<string, any>) {
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`, {
Expand Down Expand Up @@ -178,6 +204,7 @@ export class ClientEventBus {
this.#socket?.close()
this.#socket = null
this.#eventSource = null
this.#pendingServerEvents = []
}
private getGlobalTarget() {
if (typeof window !== 'undefined') {
Expand Down Expand Up @@ -209,13 +236,19 @@ 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)
}
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')
Expand Down
98 changes: 98 additions & 0 deletions packages/event-bus/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading