diff --git a/.changeset/full-humans-create.md b/.changeset/full-humans-create.md new file mode 100644 index 00000000..e0ec8881 --- /dev/null +++ b/.changeset/full-humans-create.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools-utils': patch +--- + +fix issue with client connectivity by not calling the connect method multiple times diff --git a/packages/event-bus-client/src/plugin.ts b/packages/event-bus-client/src/plugin.ts index a4dc564b..6372ea1f 100644 --- a/packages/event-bus-client/src/plugin.ts +++ b/packages/event-bus-client/src/plugin.ts @@ -29,9 +29,12 @@ export class EventClient< #connectEveryMs: number #retryCount = 0 #maxRetries = 5 + #connecting = false + #onConnected = () => { this.debugLog('Connected to event bus') this.#connected = true + this.#connecting = false this.debugLog('Emitting queued events', this.#queuedEvents) this.#queuedEvents.forEach((event) => this.emitEventToBus(event)) this.#queuedEvents = [] @@ -41,30 +44,39 @@ export class EventClient< this.#onConnected, ) } - #connectFunction = () => { - this.#eventTarget().addEventListener( - 'tanstack-connect-success', - this.#onConnected, - ) + // fired off right away and then at intervals + #retryConnection = () => { if (this.#retryCount < this.#maxRetries) { this.#retryCount++ this.dispatchCustomEvent('tanstack-connect', {}) + return } - this.#eventTarget().removeEventListener( 'tanstack-connect', - this.#connectFunction, + this.#retryConnection, ) + this.debugLog('Max retries reached, giving up on connection') this.stopConnectLoop() } + // This is run to register connection handlers on first emit attempt + #connectFunction = () => { + if (this.#connecting) return + this.#connecting = true + this.#eventTarget().addEventListener( + 'tanstack-connect-success', + this.#onConnected, + ) + this.#retryConnection() + } + constructor({ pluginId, debug = false, enabled = true, - reconnectEveryMs = 1000, + reconnectEveryMs = 300, }: { pluginId: TPluginId debug?: boolean @@ -83,16 +95,18 @@ export class EventClient< } private startConnectLoop() { + // if connected, trying to connect, or the internalId is already set, do nothing if (this.#connectIntervalId !== null || this.#connected) return this.debugLog(`Starting connect loop (every ${this.#connectEveryMs}ms)`) this.#connectIntervalId = setInterval( - this.#connectFunction, + this.#retryConnection, this.#connectEveryMs, ) as unknown as number } private stopConnectLoop() { + this.#connecting = false if (this.#connectIntervalId === null) { return } @@ -203,7 +217,7 @@ export class EventClient< pluginId: this.#pluginId, }) // start connection to event bus - if (typeof CustomEvent !== 'undefined') { + if (typeof CustomEvent !== 'undefined' && !this.#connecting) { this.#connectFunction() this.startConnectLoop() } diff --git a/packages/event-bus-client/tests/index.test.ts b/packages/event-bus-client/tests/index.test.ts index f08dd947..219ae67d 100644 --- a/packages/event-bus-client/tests/index.test.ts +++ b/packages/event-bus-client/tests/index.test.ts @@ -1,13 +1,24 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ClientEventBus } from '@tanstack/devtools-event-bus/client' import { EventClient } from '../src' -// start the client bus for testing -const bus = new ClientEventBus() -bus.start() // client bus uses window to dispatch events const clientBusEmitTarget = window + describe('EventClient', () => { + let bus: ClientEventBus + + beforeEach(() => { + // Create a fresh bus for each test to ensure isolation + bus = new ClientEventBus() + bus.start() + }) + + afterEach(() => { + // Clean up after each test + bus.stop() + }) + describe('debug config', () => { it('should emit logs when debug set to true and have the correct plugin name', () => { const consoleSpy = vi.spyOn(console, 'log') @@ -198,51 +209,268 @@ describe('EventClient', () => { describe('queued events', () => { it('emits queued events when connected to the event bus', async () => { bus.stop() + // Wait for bus to fully stop + await new Promise((resolve) => setTimeout(resolve, 50)) + const client = new EventClient({ debug: false, - pluginId: 'test', + pluginId: 'test-queued', + reconnectEveryMs: 50, }) const eventHandler = vi.fn() client.on('event', eventHandler) - client.emit('event', { foo: 'bar' }) + // Start bus first, then emit (to ensure bus is ready) bus.start() - // wait to connect to the bus - await new Promise((resolve) => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Now emit - this will queue and trigger connection + client.emit('event', { foo: 'bar' }) + + // wait for connection to establish and queued events to be emitted + await new Promise((resolve) => setTimeout(resolve, 300)) expect(eventHandler).toHaveBeenCalledWith({ - type: 'test:event', + type: 'test-queued:event', payload: { foo: 'bar' }, - pluginId: 'test', + pluginId: 'test-queued', }) }) + + it('should queue multiple events and emit them all when connected', async () => { + // Skipping: Test isolation issue - receiving events from other tests + // The ClientEventBus dispatches to global window events which persist across tests + // This needs a more robust cleanup mechanism + bus.stop() + await new Promise((resolve) => setTimeout(resolve, 100)) + + const client = new EventClient({ + debug: false, + pluginId: 'test-queue-multi-unique', + reconnectEveryMs: 50, + }) + const eventHandler = vi.fn() + const cleanup = client.on('event', eventHandler) + + // Start bus FIRST, wait for it to be ready + bus.start() + await new Promise((resolve) => setTimeout(resolve, 100)) + + // NOW emit multiple events (they'll queue and then connect) + client.emit('event', { count: 1 }) + client.emit('event', { count: 2 }) + client.emit('event', { count: 3 }) + + // Wait for connection and all queued events to be emitted + await new Promise((resolve) => setTimeout(resolve, 300)) + + // All 3 events should have been received + expect(eventHandler).nthCalledWith(1, { + type: 'test-queue-multi-unique:event', + payload: { count: 1 }, + pluginId: 'test-queue-multi-unique', + }) + expect(eventHandler).nthCalledWith(2, { + type: 'test-queue-multi-unique:event', + payload: { count: 2 }, + pluginId: 'test-queue-multi-unique', + }) + expect(eventHandler).nthCalledWith(3, { + type: 'test-queue-multi-unique:event', + payload: { count: 3 }, + pluginId: 'test-queue-multi-unique', + }) + + cleanup() + }) }) + + describe('connecting behavior', () => { + it('should only attempt connection once when #connecting flag is set', async () => { + bus.stop() + await new Promise((resolve) => setTimeout(resolve, 50)) + + const client = new EventClient({ + debug: false, + pluginId: 'test-connect', + reconnectEveryMs: 100, + }) + + const dispatchSpy = vi.spyOn(window, 'dispatchEvent') + + // Emit multiple events rapidly while disconnected + client.emit('event1', { id: 1 }) + client.emit('event2', { id: 2 }) + client.emit('event3', { id: 3 }) + client.emit('event4', { id: 4 }) + client.emit('event5', { id: 5 }) + + // Check that only one 'tanstack-connect' event was dispatched immediately + const connectCalls = dispatchSpy.mock.calls.filter( + (call) => + call[0] instanceof CustomEvent && call[0].type === 'tanstack-connect', + ) + // Should be exactly 1 from the first emit (others are blocked by #connecting flag) + expect(connectCalls.length).toBe(1) + + dispatchSpy.mockRestore() + }) + + it('should stop connect loop after successful connection', async () => { + bus.stop() + await new Promise((resolve) => setTimeout(resolve, 50)) + + const client = new EventClient({ + debug: false, + pluginId: 'test-stop', + reconnectEveryMs: 100, + }) + + // Trigger connection attempt + client.emit('event', { foo: 'bar' }) + + // Start bus to allow connection + bus.start() + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Wait for connection to establish + await new Promise((resolve) => setTimeout(resolve, 200)) + + const dispatchSpy = vi.spyOn(window, 'dispatchEvent') + + // Wait for what would be several retry intervals + await new Promise((resolve) => setTimeout(resolve, 400)) + + const connectCalls = dispatchSpy.mock.calls.filter( + (call) => + call[0] instanceof CustomEvent && call[0].type === 'tanstack-connect', + ) + + // Should be 0 because connection was already established + expect(connectCalls.length).toBe(0) + + dispatchSpy.mockRestore() + }) + + it('should respect max retries limit', async () => { + // Don't start the bus so connection always fails + bus.stop() + await new Promise((resolve) => setTimeout(resolve, 50)) + + const client = new EventClient({ + debug: false, + pluginId: 'test-max-retry', + reconnectEveryMs: 50, + }) + + const dispatchSpy = vi.spyOn(window, 'dispatchEvent') + + // Trigger connection attempt + client.emit('event', { foo: 'bar' }) + + // Wait long enough for max retries (5 attempts at 50ms intervals = 250ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 400)) + + const connectCalls = dispatchSpy.mock.calls.filter( + (call) => + call[0] instanceof CustomEvent && call[0].type === 'tanstack-connect', + ) + + // Should have initial + 4 retries = 5 total (max is 5) + expect(connectCalls.length).toBeLessThanOrEqual(5) + + // Wait longer to ensure no more attempts + dispatchSpy.mockClear() + await new Promise((resolve) => setTimeout(resolve, 200)) + + const additionalCalls = dispatchSpy.mock.calls.filter( + (call) => + call[0] instanceof CustomEvent && call[0].type === 'tanstack-connect', + ) + + // Should be 0 because max retries reached + expect(additionalCalls.length).toBe(0) + + dispatchSpy.mockRestore() + }) + + it('should reset connecting flag when connection succeeds and allow new connections', async () => { + bus.stop() + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Create first client and connect it + const client1 = new EventClient({ + debug: false, + pluginId: 'test-reset-1', + reconnectEveryMs: 50, + }) + + // Start bus before emitting so connection succeeds + bus.start() + await new Promise((resolve) => setTimeout(resolve, 50)) + + // First connection attempt + client1.emit('event1', { id: 1 }) + + // Wait for connection + await new Promise((resolve) => setTimeout(resolve, 150)) + + // Now create a SECOND client (which will need to connect) + // Stop/start bus to simulate a scenario where new client needs to connect + bus.stop() + await new Promise((resolve) => setTimeout(resolve, 50)) + + const dispatchSpy = vi.spyOn(window, 'dispatchEvent') + + const client2 = new EventClient({ + debug: false, + pluginId: 'test-reset-2', + reconnectEveryMs: 50, + }) + + // This should trigger a new connection attempt for client2 + client2.emit('event2', { id: 2 }) + + // Wait a bit for the connection attempt + await new Promise((resolve) => setTimeout(resolve, 100)) + + const connectCalls = dispatchSpy.mock.calls.filter( + (call) => + call[0] instanceof CustomEvent && call[0].type === 'tanstack-connect', + ) + + // Should have connection attempts from client2 + expect(connectCalls.length).toBeGreaterThanOrEqual(1) + + dispatchSpy.mockRestore() + }) + }) + describe('onAllPluginEvents', () => { it('should listen to all events that come from the plugin', () => { const client = new EventClient({ debug: false, - pluginId: 'test', - reconnectEveryMs: 500, + pluginId: 'test-all-events', }) const eventHandler = vi.fn() client.onAllPluginEvents(eventHandler) client.emit('event', { foo: 'bar' }) client.emit('event2', { foo: 'bar' }) expect(eventHandler).nthCalledWith(1, { - type: 'test:event', + type: 'test-all-events:event', payload: { foo: 'bar' }, - pluginId: 'test', + pluginId: 'test-all-events', }) expect(eventHandler).nthCalledWith(2, { - type: 'test:event2', + type: 'test-all-events:event2', payload: { foo: 'bar' }, - pluginId: 'test', + pluginId: 'test-all-events', }) }) it('should ignore events that do not come from the plugin', () => { const client = new EventClient({ debug: false, - pluginId: 'test', + pluginId: 'test-ignore', }) const eventHandler = vi.fn() client.onAllPluginEvents(eventHandler)