From 2ef924f080aabaf89b6c46783611157d7c0ecba0 Mon Sep 17 00:00:00 2001 From: Gustavo Aguiar Date: Tue, 2 Jun 2026 17:50:54 -0300 Subject: [PATCH 1/4] Fix TypeScript React provider reconnect Mark DbConnection inactive before disconnect/connectError callbacks so consumers see a closed connection. Teach ConnectionManager to clear closed retained provider connections and rebuild them after a short delay while a provider remains mounted. Cancel pending reconnects during release to preserve StrictMode cleanup behavior. Add lifecycle/reconnect regression tests and document that this recovery is provider-level only. --- .../src/sdk/connection_manager.ts | 206 +++++++---- .../src/sdk/db_connection_impl.ts | 4 +- .../src/sdk/websocket_test_adapter.ts | 9 +- .../connection_manager_reconnect.test.ts | 322 ++++++++++++++++++ .../tests/db_connection.test.ts | 52 +++ .../00700-typescript-reference.md | 2 + 6 files changed, 534 insertions(+), 61 deletions(-) create mode 100644 crates/bindings-typescript/tests/connection_manager_reconnect.test.ts diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 7f43c274104..5e6f439efb8 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -46,12 +46,16 @@ export type ConnectionState = { type Listener = () => void; +export const CONNECTION_MANAGER_RECONNECT_DELAY_MS = 1000; + type ManagedConnection = { connection?: DbConnectionImpl; + builder?: DbConnectionBuilder; refCount: number; state: ConnectionState; listeners: Set; pendingRelease: ReturnType | null; + reconnectTimer: ReturnType | null; onConnect?: (conn: DbConnectionImpl) => void; onDisconnect?: (ctx: ErrorContextInterface, error?: Error) => void; onConnectError?: (ctx: ErrorContextInterface, error: Error) => void; @@ -91,10 +95,12 @@ class ConnectionManagerImpl { } const managed: ManagedConnection = { connection: undefined, + builder: undefined, refCount: 0, state: defaultState(), listeners: new Set(), pendingRelease: null, + reconnectTimer: null, }; this.#connections.set(key, managed); return managed; @@ -106,47 +112,24 @@ class ConnectionManagerImpl { } } - /** - * Retains a connection, incrementing its reference count. - * Creates the connection on first call; returns existing connection on subsequent calls. - * Cancels any pending release if the connection was about to be cleaned up. - * - * @param key - Unique identifier for the connection (use getKey to generate) - * @param builder - Connection builder to create the connection if needed - * @returns The managed connection instance - */ - retain>( - key: string, - builder: DbConnectionBuilder - ): T { - const managed = this.#ensureEntry(key); - if (managed.pendingRelease) { - clearTimeout(managed.pendingRelease); - managed.pendingRelease = null; - } - managed.refCount += 1; - if (managed.connection) { - return managed.connection as T; - } - - const connection = builder.build(); - managed.connection = connection; - - const updateState = (updates: Partial) => { - managed.state = { ...managed.state, ...updates }; - this.#notify(managed); - }; + #updateState( + managed: ManagedConnection, + updates: Partial + ): void { + managed.state = { ...managed.state, ...updates }; + this.#notify(managed); + } - updateState({ - isActive: connection.isActive, - identity: connection.identity, - token: connection.token, - connectionId: connection.connectionId, - connectionError: undefined, - }); + #ensureCallbacks(managed: ManagedConnection): void { + if (managed.onConnect) { + return; + } managed.onConnect = conn => { - updateState({ + if (conn !== managed.connection) { + return; + } + this.#updateState(managed, { isActive: conn.isActive, identity: conn.identity, token: conn.token, @@ -156,26 +139,136 @@ class ConnectionManagerImpl { }; managed.onDisconnect = (ctx, error) => { - updateState({ - isActive: ctx.isActive, + if (ctx !== managed.connection) { + return; + } + this.#updateState(managed, { + isActive: false, connectionError: error ?? undefined, }); + this.#scheduleReconnect(managed); }; managed.onConnectError = (ctx, error) => { - updateState({ - isActive: ctx.isActive, + if (ctx !== managed.connection) { + return; + } + this.#updateState(managed, { + isActive: false, connectionError: error, }); + this.#scheduleReconnect(managed); }; + } + + #attachCallbacks>( + managed: ManagedConnection, + builder: DbConnectionBuilder + ): void { + this.#ensureCallbacks(managed); + builder.onConnect(managed.onConnect!); + builder.onDisconnect(managed.onDisconnect!); + builder.onConnectError(managed.onConnectError!); + } + + #detachCallbacks( + managed: ManagedConnection, + connection: DbConnectionImpl + ): void { + if (managed.onConnect) { + connection.removeOnConnect(managed.onConnect as any); + } + if (managed.onDisconnect) { + connection.removeOnDisconnect(managed.onDisconnect as any); + } + if (managed.onConnectError) { + connection.removeOnConnectError(managed.onConnectError as any); + } + } - builder.onConnect(managed.onConnect); - builder.onDisconnect(managed.onDisconnect); - builder.onConnectError(managed.onConnectError); + #buildManagedConnection>( + managed: ManagedConnection, + builder: DbConnectionBuilder + ): T { + managed.builder = builder; + const connection = builder.build(); + managed.connection = connection; + this.#attachCallbacks(managed, builder); + + this.#updateState(managed, { + isActive: connection.isActive, + identity: connection.identity, + token: connection.token, + connectionId: connection.connectionId, + connectionError: undefined, + }); return connection as T; } + #scheduleReconnect(managed: ManagedConnection): void { + if ( + managed.refCount <= 0 || + managed.pendingRelease || + managed.reconnectTimer || + !managed.builder + ) { + return; + } + + const connection = managed.connection; + if (connection) { + this.#detachCallbacks(managed, connection); + } + managed.connection = undefined; + managed.reconnectTimer = setTimeout(() => { + managed.reconnectTimer = null; + if ( + managed.refCount <= 0 || + managed.pendingRelease || + managed.connection || + !managed.builder + ) { + return; + } + + this.#buildManagedConnection(managed, managed.builder); + }, CONNECTION_MANAGER_RECONNECT_DELAY_MS); + } + + /** + * Retains a connection, incrementing its reference count. + * Creates the connection on first call; returns existing connection on subsequent calls. + * Cancels any pending release if the connection was about to be cleaned up. + * + * @param key - Unique identifier for the connection (use getKey to generate) + * @param builder - Connection builder to create the connection if needed + * @returns The managed connection instance + */ + retain>( + key: string, + builder: DbConnectionBuilder + ): T { + const managed = this.#ensureEntry(key); + if (managed.pendingRelease) { + clearTimeout(managed.pendingRelease); + managed.pendingRelease = null; + } + if (managed.reconnectTimer) { + clearTimeout(managed.reconnectTimer); + managed.reconnectTimer = null; + } + + managed.refCount += 1; + managed.builder = builder; + + if (managed.connection) { + return managed.connection as T; + } + + return this.#buildManagedConnection(managed, builder); + } + release(key: string): void { const managed = this.#connections.get(key); if (!managed) { @@ -187,24 +280,21 @@ class ConnectionManagerImpl { return; } + if (managed.reconnectTimer) { + clearTimeout(managed.reconnectTimer); + managed.reconnectTimer = null; + } + managed.pendingRelease = setTimeout(() => { managed.pendingRelease = null; if (managed.refCount > 0) { return; } - if (managed.connection) { - if (managed.onConnect) { - managed.connection.removeOnConnect(managed.onConnect as any); - } - if (managed.onDisconnect) { - managed.connection.removeOnDisconnect(managed.onDisconnect as any); - } - if (managed.onConnectError) { - managed.connection.removeOnConnectError( - managed.onConnectError as any - ); - } - managed.connection.disconnect(); + const connection = managed.connection; + managed.connection = undefined; + if (connection) { + this.#detachCallbacks(managed, connection); + connection.disconnect(); } this.#connections.delete(key); }, 0); diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index 1e604349f66..df5221a0aa7 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -321,12 +321,12 @@ export class DbConnectionImpl this.ws = v; this.ws.onclose = () => { - this.#emitter.emit('disconnect', this); this.isActive = false; + this.#emitter.emit('disconnect', this); }; this.ws.onerror = (e: ErrorEvent) => { - this.#emitter.emit('connectError', this, e); this.isActive = false; + this.#emitter.emit('connectError', this, e); }; this.ws.onopen = this.#handleOnOpen.bind(this); this.ws.onmessage = this.#handleOnMessage.bind(this); diff --git a/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts b/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts index b32e7744a00..e04f411c7cc 100644 --- a/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts +++ b/crates/bindings-typescript/src/sdk/websocket_test_adapter.ts @@ -19,6 +19,7 @@ class WebsocketTestAdapter implements WebSocketAdapter { #onclose: (ev: CloseEvent) => void = () => {}; #onopen: () => void = () => {}; #onmessage: (msg: { data: Uint8Array }) => void = () => {}; + #onerror: (msg: ErrorEvent) => void = () => {}; constructor() { this.messageQueue = []; @@ -39,7 +40,13 @@ class WebsocketTestAdapter implements WebSocketAdapter { this.#onmessage = handler; } - set onerror(_handler: (msg: ErrorEvent) => void) {} + set onerror(handler: (msg: ErrorEvent) => void) { + this.#onerror = handler; + } + + error(error: Error): void { + this.#onerror(error as unknown as ErrorEvent); + } send(message: Uint8Array): void { const rawMessage = message.slice(); diff --git a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts new file mode 100644 index 00000000000..27d2ed62d95 --- /dev/null +++ b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts @@ -0,0 +1,322 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ConnectionId } from '../src'; +import { + CONNECTION_MANAGER_RECONNECT_DELAY_MS, + ConnectionManager, +} from '../src/sdk/connection_manager.ts'; + +type ErrorContextInterface = { + isActive: boolean; +}; + +class MockConnection { + isActive = false; + identity = undefined; + token = undefined; + connectionId = ConnectionId.random(); + disconnected = false; + + #onConnectCallbacks = new Set<(conn: MockConnection) => void>(); + #onDisconnectCallbacks = new Set< + (ctx: ErrorContextInterface, error?: Error) => void + >(); + #onConnectErrorCallbacks = new Set< + (ctx: ErrorContextInterface, error: Error) => void + >(); + + disconnect(): void { + if (this.disconnected) { + return; + } + this.disconnected = true; + this.isActive = false; + for (const cb of this.#onDisconnectCallbacks) { + cb(this as unknown as ErrorContextInterface); + } + } + + removeOnConnect(cb: (conn: MockConnection) => void): void { + this.#onConnectCallbacks.delete(cb); + } + + removeOnDisconnect( + cb: (ctx: ErrorContextInterface, error?: Error) => void + ): void { + this.#onDisconnectCallbacks.delete(cb); + } + + removeOnConnectError( + cb: (ctx: ErrorContextInterface, error: Error) => void + ): void { + this.#onConnectErrorCallbacks.delete(cb); + } + + callbackCounts(): { + connect: number; + disconnect: number; + connectError: number; + } { + return { + connect: this.#onConnectCallbacks.size, + disconnect: this.#onDisconnectCallbacks.size, + connectError: this.#onConnectErrorCallbacks.size, + }; + } + + simulateConnect(): void { + this.isActive = true; + for (const cb of this.#onConnectCallbacks) { + cb(this); + } + } + + simulateDisconnect(error?: Error): void { + this.isActive = false; + for (const cb of this.#onDisconnectCallbacks) { + cb(this as unknown as ErrorContextInterface, error); + } + } + + simulateConnectError(error: Error): void { + this.isActive = false; + for (const cb of this.#onConnectErrorCallbacks) { + cb(this as unknown as ErrorContextInterface, error); + } + } + + registerOnConnect(cb: (conn: MockConnection) => void): void { + this.#onConnectCallbacks.add(cb); + } + + registerOnDisconnect( + cb: (ctx: ErrorContextInterface, error?: Error) => void + ): void { + this.#onDisconnectCallbacks.add(cb); + } + + registerOnConnectError( + cb: (ctx: ErrorContextInterface, error: Error) => void + ): void { + this.#onConnectErrorCallbacks.add(cb); + } +} + +class MockBuilder { + buildCount = 0; + connections: MockConnection[] = []; + + #onConnectCallbacks = new Set<(conn: MockConnection) => void>(); + #onDisconnectCallbacks = new Set< + (ctx: ErrorContextInterface, error?: Error) => void + >(); + #onConnectErrorCallbacks = new Set< + (ctx: ErrorContextInterface, error: Error) => void + >(); + + build(): MockConnection { + const connection = new MockConnection(); + this.buildCount += 1; + this.connections.push(connection); + + for (const cb of this.#onConnectCallbacks) { + connection.registerOnConnect(cb); + } + for (const cb of this.#onDisconnectCallbacks) { + connection.registerOnDisconnect(cb); + } + for (const cb of this.#onConnectErrorCallbacks) { + connection.registerOnConnectError(cb); + } + + return connection; + } + + onConnect(cb: (conn: MockConnection) => void): MockBuilder { + this.#onConnectCallbacks.add(cb); + for (const connection of this.connections) { + connection.registerOnConnect(cb); + } + return this; + } + + onDisconnect( + cb: (ctx: ErrorContextInterface, error?: Error) => void + ): MockBuilder { + this.#onDisconnectCallbacks.add(cb); + for (const connection of this.connections) { + connection.registerOnDisconnect(cb); + } + return this; + } + + onConnectError( + cb: (ctx: ErrorContextInterface, error: Error) => void + ): MockBuilder { + this.#onConnectErrorCallbacks.add(cb); + for (const connection of this.connections) { + connection.registerOnConnectError(cb); + } + return this; + } +} + +let keyCounter = 0; + +function nextKey(): string { + keyCounter += 1; + return `connection-manager-reconnect-${keyCounter}`; +} + +function retainMock(key: string, builder: MockBuilder): MockConnection { + return ConnectionManager.retain( + key, + builder as any + ) as unknown as MockConnection; +} + +describe('ConnectionManager retained reconnect behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + test('rebuilds a retained connection after disconnect', () => { + const key = nextKey(); + const builder = new MockBuilder(); + + const first = retainMock(key, builder); + expect(builder.buildCount).toBe(1); + + first.simulateDisconnect(); + + expect(ConnectionManager.getSnapshot(key)?.isActive).toBe(false); + expect(ConnectionManager.getConnection(key)).toBeNull(); + + vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS - 1); + expect(builder.buildCount).toBe(1); + + vi.advanceTimersByTime(1); + expect(builder.buildCount).toBe(2); + + const second = ConnectionManager.getConnection( + key + ) as unknown as MockConnection; + expect(second).toBe(builder.connections[1]); + expect(second).not.toBe(first); + + ConnectionManager.release(key); + }); + + test('rebuilds a retained connection after connectError', () => { + const key = nextKey(); + const builder = new MockBuilder(); + const error = new Error('network unavailable'); + + const first = retainMock(key, builder); + first.simulateConnectError(error); + + expect(ConnectionManager.getSnapshot(key)?.isActive).toBe(false); + expect(ConnectionManager.getSnapshot(key)?.connectionError).toBe(error); + expect(ConnectionManager.getConnection(key)).toBeNull(); + + vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS); + + expect(builder.buildCount).toBe(2); + expect(ConnectionManager.getSnapshot(key)?.connectionError).toBeUndefined(); + expect(ConnectionManager.getConnection(key)).toBe(builder.connections[1]); + + ConnectionManager.release(key); + }); + + test('same-key retain after disconnect returns a fresh connection immediately', () => { + const key = nextKey(); + const builder = new MockBuilder(); + + const first = retainMock(key, builder); + first.simulateDisconnect(); + + const second = retainMock(key, builder); + + expect(builder.buildCount).toBe(2); + expect(second).not.toBe(first); + expect(ConnectionManager.getConnection(key)).toBe(second); + + vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS); + expect(builder.buildCount).toBe(2); + + ConnectionManager.release(key); + ConnectionManager.release(key); + }); + + test('reconnect uses callbacks from a replacement same-key builder', () => { + const key = nextKey(); + const firstBuilder = new MockBuilder(); + const secondBuilder = new MockBuilder(); + + const first = retainMock(key, firstBuilder); + first.simulateConnect(); + + ConnectionManager.release(key); + const retained = retainMock(key, secondBuilder); + + expect(retained).toBe(first); + expect(firstBuilder.buildCount).toBe(1); + expect(secondBuilder.buildCount).toBe(0); + + first.simulateDisconnect(); + vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS); + + expect(secondBuilder.buildCount).toBe(1); + const second = secondBuilder.connections[0]; + expect(ConnectionManager.getConnection(key)).toBe(second); + + second.simulateConnect(); + + expect(ConnectionManager.getSnapshot(key)?.isActive).toBe(true); + expect(ConnectionManager.getSnapshot(key)?.connectionId).toBe( + second.connectionId + ); + + ConnectionManager.release(key); + }); + + test('disconnect removes manager callbacks from the old connection before pending reconnect', () => { + const key = nextKey(); + const builder = new MockBuilder(); + + const first = retainMock(key, builder); + expect(first.callbackCounts()).toEqual({ + connect: 1, + disconnect: 1, + connectError: 1, + }); + + first.simulateDisconnect(); + + expect(first.callbackCounts()).toEqual({ + connect: 0, + disconnect: 0, + connectError: 0, + }); + + ConnectionManager.release(key); + }); + + test('release cancels a pending reconnect', () => { + const key = nextKey(); + const builder = new MockBuilder(); + + const first = retainMock(key, builder); + first.simulateDisconnect(); + + ConnectionManager.release(key); + vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS); + + expect(builder.buildCount).toBe(1); + expect(ConnectionManager.getConnection(key)).toBeNull(); + }); +}); diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index 7a0da07bf52..c11e4e7fdf1 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -175,6 +175,58 @@ describe('DbConnection', () => { expect(connectCalled).toBeFalsy(); }); + test('marks connection inactive before invoking onDisconnect callback', async () => { + const onDisconnectPromise = new Deferred(); + const wsAdapter = new WebsocketTestAdapter(); + let callbackIsActive: boolean | undefined; + + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.openWebSocket) + .onDisconnect(ctx => { + callbackIsActive = ctx.isActive; + onDisconnectPromise.resolve(); + }) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + wsAdapter.close(); + + await onDisconnectPromise.promise; + + expect(callbackIsActive).toBe(false); + expect(client.isActive).toBe(false); + }); + + test('marks connection inactive before invoking onConnectError callback from websocket error', async () => { + const onConnectErrorPromise = new Deferred(); + const wsAdapter = new WebsocketTestAdapter(); + let callbackIsActive: boolean | undefined; + const error = new Error('websocket failed'); + + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.openWebSocket) + .onConnectError(ctx => { + callbackIsActive = ctx.isActive; + onConnectErrorPromise.resolve(); + }) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + client.isActive = true; + wsAdapter.error(error); + + await onConnectErrorPromise.promise; + + expect(callbackIsActive).toBe(false); + expect(client.isActive).toBe(false); + }); + test('call onConnect callback after getting an identity', async () => { const onConnectPromise = new Deferred(); diff --git a/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md b/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md index 1c0461eebc0..ac70e5f66af 100644 --- a/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md @@ -1020,6 +1020,8 @@ The SpacetimeDB TypeScript SDK includes React bindings under the `spacetimedb/re The React integration is fully compatible with React StrictMode and correctly handles the double-mount behavior (only one WebSocket connection is created). +While a `SpacetimeDBProvider` is mounted, the React connection manager also replaces the managed `DbConnection` if the underlying WebSocket closes or reports a connection error. Hooks such as `useTable` observe the provider state, receive the fresh connection, and establish their subscriptions again. This provider-level recovery does not change the lower-level `DbConnection` contract: applications that create a `DbConnection` directly are still responsible for creating a new connection if they need reconnection behavior. + | Name | Description | | ----------------------------------------------------------- | --------------------------------------------------------- | | [`SpacetimeDBProvider` component](#component-spacetimedbprovider) | Context provider that manages the database connection. | From efc9046e06992ea9f7610ab644390454cc2a55fd Mon Sep 17 00:00:00 2001 From: Gustavo Aguiar Date: Wed, 10 Jun 2026 08:53:35 -0300 Subject: [PATCH 2/4] ts-sdk: use exponential backoff for provider reconnects Replace the fixed 1s reconnect delay in the React ConnectionManager with exponential backoff (1s base, doubling per consecutive failed attempt, capped at 30s). The attempt counter resets once a connection successfully connects. Addresses review feedback on #5185. --- .../src/sdk/connection_manager.ts | 22 +++++- .../connection_manager_reconnect.test.ts | 72 +++++++++++++++++-- .../00700-typescript-reference.md | 2 +- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 5e6f439efb8..5f7aba4a18a 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -46,7 +46,20 @@ export type ConnectionState = { type Listener = () => void; -export const CONNECTION_MANAGER_RECONNECT_DELAY_MS = 1000; +export const CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS = 1000; +export const CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS = 30_000; + +/** + * Computes the reconnect delay for the given attempt (0-based) using + * exponential backoff: the base delay doubles with each consecutive failed + * attempt, capped at the maximum delay. + */ +export function connectionManagerReconnectDelayMs(attempt: number): number { + return Math.min( + CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS * 2 ** attempt, + CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS + ); +} type ManagedConnection = { connection?: DbConnectionImpl; @@ -56,6 +69,7 @@ type ManagedConnection = { listeners: Set; pendingRelease: ReturnType | null; reconnectTimer: ReturnType | null; + reconnectAttempt: number; onConnect?: (conn: DbConnectionImpl) => void; onDisconnect?: (ctx: ErrorContextInterface, error?: Error) => void; onConnectError?: (ctx: ErrorContextInterface, error: Error) => void; @@ -101,6 +115,7 @@ class ConnectionManagerImpl { listeners: new Set(), pendingRelease: null, reconnectTimer: null, + reconnectAttempt: 0, }; this.#connections.set(key, managed); return managed; @@ -129,6 +144,7 @@ class ConnectionManagerImpl { if (conn !== managed.connection) { return; } + managed.reconnectAttempt = 0; this.#updateState(managed, { isActive: conn.isActive, identity: conn.identity, @@ -221,6 +237,8 @@ class ConnectionManagerImpl { this.#detachCallbacks(managed, connection); } managed.connection = undefined; + const delay = connectionManagerReconnectDelayMs(managed.reconnectAttempt); + managed.reconnectAttempt += 1; managed.reconnectTimer = setTimeout(() => { managed.reconnectTimer = null; if ( @@ -233,7 +251,7 @@ class ConnectionManagerImpl { } this.#buildManagedConnection(managed, managed.builder); - }, CONNECTION_MANAGER_RECONNECT_DELAY_MS); + }, delay); } /** diff --git a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts index 27d2ed62d95..bf38c6bb317 100644 --- a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { ConnectionId } from '../src'; import { - CONNECTION_MANAGER_RECONNECT_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS, + connectionManagerReconnectDelayMs, ConnectionManager, } from '../src/sdk/connection_manager.ts'; @@ -196,7 +197,7 @@ describe('ConnectionManager retained reconnect behavior', () => { expect(ConnectionManager.getSnapshot(key)?.isActive).toBe(false); expect(ConnectionManager.getConnection(key)).toBeNull(); - vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS - 1); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0) - 1); expect(builder.buildCount).toBe(1); vi.advanceTimersByTime(1); @@ -223,7 +224,7 @@ describe('ConnectionManager retained reconnect behavior', () => { expect(ConnectionManager.getSnapshot(key)?.connectionError).toBe(error); expect(ConnectionManager.getConnection(key)).toBeNull(); - vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); expect(builder.buildCount).toBe(2); expect(ConnectionManager.getSnapshot(key)?.connectionError).toBeUndefined(); @@ -245,7 +246,7 @@ describe('ConnectionManager retained reconnect behavior', () => { expect(second).not.toBe(first); expect(ConnectionManager.getConnection(key)).toBe(second); - vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); expect(builder.buildCount).toBe(2); ConnectionManager.release(key); @@ -268,7 +269,7 @@ describe('ConnectionManager retained reconnect behavior', () => { expect(secondBuilder.buildCount).toBe(0); first.simulateDisconnect(); - vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); expect(secondBuilder.buildCount).toBe(1); const second = secondBuilder.connections[0]; @@ -314,9 +315,68 @@ describe('ConnectionManager retained reconnect behavior', () => { first.simulateDisconnect(); ConnectionManager.release(key); - vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_DELAY_MS); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); expect(builder.buildCount).toBe(1); expect(ConnectionManager.getConnection(key)).toBeNull(); }); + + test('reconnect delay backs off exponentially across consecutive failures', () => { + const key = nextKey(); + const builder = new MockBuilder(); + + const first = retainMock(key, builder); + first.simulateDisconnect(); + + // First reconnect fires after the base delay. + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); + expect(builder.buildCount).toBe(2); + + // Second failure: the delay doubles. + builder.connections[1].simulateConnectError(new Error('still down')); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(1) - 1); + expect(builder.buildCount).toBe(2); + vi.advanceTimersByTime(1); + expect(builder.buildCount).toBe(3); + + // Third failure: the delay doubles again. + builder.connections[2].simulateConnectError(new Error('still down')); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(2) - 1); + expect(builder.buildCount).toBe(3); + vi.advanceTimersByTime(1); + expect(builder.buildCount).toBe(4); + + ConnectionManager.release(key); + }); + + test('successful connect resets the reconnect backoff', () => { + const key = nextKey(); + const builder = new MockBuilder(); + + const first = retainMock(key, builder); + first.simulateDisconnect(); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); + + builder.connections[1].simulateConnectError(new Error('still down')); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(1)); + expect(builder.buildCount).toBe(3); + + // A successful connect resets the backoff to the base delay. + builder.connections[2].simulateConnect(); + builder.connections[2].simulateDisconnect(); + + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); + expect(builder.buildCount).toBe(4); + + ConnectionManager.release(key); + }); + + test('reconnect delay is capped at the maximum delay', () => { + expect(connectionManagerReconnectDelayMs(0)).toBeLessThan( + CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS + ); + expect(connectionManagerReconnectDelayMs(100)).toBe( + CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS + ); + }); }); diff --git a/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md b/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md index ac70e5f66af..dd7e8564c90 100644 --- a/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md @@ -1020,7 +1020,7 @@ The SpacetimeDB TypeScript SDK includes React bindings under the `spacetimedb/re The React integration is fully compatible with React StrictMode and correctly handles the double-mount behavior (only one WebSocket connection is created). -While a `SpacetimeDBProvider` is mounted, the React connection manager also replaces the managed `DbConnection` if the underlying WebSocket closes or reports a connection error. Hooks such as `useTable` observe the provider state, receive the fresh connection, and establish their subscriptions again. This provider-level recovery does not change the lower-level `DbConnection` contract: applications that create a `DbConnection` directly are still responsible for creating a new connection if they need reconnection behavior. +While a `SpacetimeDBProvider` is mounted, the React connection manager also replaces the managed `DbConnection` if the underlying WebSocket closes or reports a connection error. Reconnect attempts use exponential backoff, starting at 1 second and doubling after each consecutive failure up to a 30 second maximum; the backoff resets after a successful connection. Hooks such as `useTable` observe the provider state, receive the fresh connection, and establish their subscriptions again. This provider-level recovery does not change the lower-level `DbConnection` contract: applications that create a `DbConnection` directly are still responsible for creating a new connection if they need reconnection behavior. | Name | Description | | ----------------------------------------------------------- | --------------------------------------------------------- | From 7520a58b468a1a618416f718f4128a56c440718e Mon Sep 17 00:00:00 2001 From: Gustavo Aguiar Date: Wed, 10 Jun 2026 08:55:03 -0300 Subject: [PATCH 3/4] ts-sdk: do not reconnect after an intentional disconnect DbConnectionImpl now records when disconnect() has been requested, and the React ConnectionManager skips scheduling a reconnect for connections that were intentionally disconnected. A subsequent retain() (e.g. a remount) still builds a fresh connection. Addresses review feedback on #5185. --- .../src/sdk/connection_manager.ts | 7 ++++ .../src/sdk/db_connection_impl.ts | 9 +++++ .../connection_manager_reconnect.test.ts | 38 +++++++++++++++++++ .../tests/db_connection.test.ts | 24 ++++++++++++ 4 files changed, 78 insertions(+) diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 5f7aba4a18a..25d19260d7a 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -237,6 +237,13 @@ class ConnectionManagerImpl { this.#detachCallbacks(managed, connection); } managed.connection = undefined; + + // The application asked this connection to close; don't fight it. A + // subsequent retain() will still build a fresh connection. + if (connection?.isDisconnectRequested) { + return; + } + const delay = connectionManagerReconnectDelayMs(managed.reconnectAttempt); managed.reconnectAttempt += 1; managed.reconnectTimer = setTimeout(() => { diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index df5221a0aa7..b444794500c 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -138,6 +138,14 @@ export class DbConnectionImpl */ isActive = false; + /** + * Whether `disconnect()` has been called on this connection. + * Once requested, the connection will not be reused: managed environments + * (such as the React `SpacetimeDBProvider`) use this to avoid reconnecting + * after an intentional disconnect. + */ + isDisconnectRequested = false; + /** * This connection's public identity. */ @@ -1292,6 +1300,7 @@ export class DbConnectionImpl * ``` */ disconnect(): void { + this.isDisconnectRequested = true; this.wsPromise.then(ws => ws?.close()); } diff --git a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts index bf38c6bb317..675ac998446 100644 --- a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts @@ -16,6 +16,7 @@ class MockConnection { token = undefined; connectionId = ConnectionId.random(); disconnected = false; + isDisconnectRequested = false; #onConnectCallbacks = new Set<(conn: MockConnection) => void>(); #onDisconnectCallbacks = new Set< @@ -26,6 +27,7 @@ class MockConnection { >(); disconnect(): void { + this.isDisconnectRequested = true; if (this.disconnected) { return; } @@ -321,6 +323,42 @@ describe('ConnectionManager retained reconnect behavior', () => { expect(ConnectionManager.getConnection(key)).toBeNull(); }); + test('manual disconnect does not trigger a reconnect', () => { + const key = nextKey(); + const builder = new MockBuilder(); + + const first = retainMock(key, builder); + first.simulateConnect(); + + first.disconnect(); + + expect(ConnectionManager.getSnapshot(key)?.isActive).toBe(false); + expect(ConnectionManager.getConnection(key)).toBeNull(); + + vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS); + expect(builder.buildCount).toBe(1); + + ConnectionManager.release(key); + }); + + test('retain after a manual disconnect builds a fresh connection', () => { + const key = nextKey(); + const builder = new MockBuilder(); + + const first = retainMock(key, builder); + first.simulateConnect(); + first.disconnect(); + + const second = retainMock(key, builder); + + expect(builder.buildCount).toBe(2); + expect(second).not.toBe(first); + expect(ConnectionManager.getConnection(key)).toBe(second); + + ConnectionManager.release(key); + ConnectionManager.release(key); + }); + test('reconnect delay backs off exponentially across consecutive failures', () => { const key = nextKey(); const builder = new MockBuilder(); diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index c11e4e7fdf1..716f8f43e45 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -200,6 +200,30 @@ describe('DbConnection', () => { expect(client.isActive).toBe(false); }); + test('marks disconnect as requested when disconnect() is called', async () => { + const onDisconnectPromise = new Deferred(); + const wsAdapter = new WebsocketTestAdapter(); + + const client = DbConnection.builder() + .withUri('ws://127.0.0.1:1234') + .withDatabaseName('db') + .withWSFn(wsAdapter.openWebSocket) + .onDisconnect(() => { + onDisconnectPromise.resolve(); + }) + .build(); + + await client['wsPromise']; + wsAdapter.acceptConnection(); + + expect(client.isDisconnectRequested).toBe(false); + client.disconnect(); + expect(client.isDisconnectRequested).toBe(true); + + await onDisconnectPromise.promise; + expect(client.isActive).toBe(false); + }); + test('marks connection inactive before invoking onConnectError callback from websocket error', async () => { const onConnectErrorPromise = new Deferred(); const wsAdapter = new WebsocketTestAdapter(); From ffb56cf9b83fa1532acf26d31dc79c3e7eead238 Mon Sep 17 00:00:00 2001 From: Gustavo Aguiar Date: Wed, 10 Jun 2026 08:56:17 -0300 Subject: [PATCH 4/4] ts-sdk: reset useTable isReady when the connection is lost When the React ConnectionManager replaces the managed connection, the new connection starts with an empty client cache, but useTable kept reporting isReady=true from the previous subscription. Reset subscribeApplied whenever the connection is inactive or missing, so isReady stays false until the replacement connection's subscription is applied. Addresses review feedback on #5185. --- .../bindings-typescript/src/react/useTable.ts | 27 +++++++++++-------- .../00700-typescript-reference.md | 2 +- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/crates/bindings-typescript/src/react/useTable.ts b/crates/bindings-typescript/src/react/useTable.ts index 906d5d74892..313627cead4 100644 --- a/crates/bindings-typescript/src/react/useTable.ts +++ b/crates/bindings-typescript/src/react/useTable.ts @@ -127,18 +127,23 @@ export function useTable( setSubscribeApplied(false); return; } - const connection = connectionState.getConnection()!; - if (connectionState.isActive && connection) { - const cancel = connection - .subscriptionBuilder() - .onApplied(() => { - setSubscribeApplied(true); - }) - .subscribe(querySql); - return () => { - cancel.unsubscribe(); - }; + const connection = connectionState.getConnection(); + if (!connectionState.isActive || !connection) { + // The connection dropped (or was replaced and has not reconnected + // yet), so any previously applied subscription no longer reflects the + // current cache. Report not-ready until the new subscription applies. + setSubscribeApplied(false); + return; } + const cancel = connection + .subscriptionBuilder() + .onApplied(() => { + setSubscribeApplied(true); + }) + .subscribe(querySql); + return () => { + cancel.unsubscribe(); + }; }, [querySql, connectionState.isActive, connectionState, enabled]); const subscribe = useCallback( diff --git a/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md b/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md index dd7e8564c90..fcbd24baec0 100644 --- a/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md @@ -1020,7 +1020,7 @@ The SpacetimeDB TypeScript SDK includes React bindings under the `spacetimedb/re The React integration is fully compatible with React StrictMode and correctly handles the double-mount behavior (only one WebSocket connection is created). -While a `SpacetimeDBProvider` is mounted, the React connection manager also replaces the managed `DbConnection` if the underlying WebSocket closes or reports a connection error. Reconnect attempts use exponential backoff, starting at 1 second and doubling after each consecutive failure up to a 30 second maximum; the backoff resets after a successful connection. Hooks such as `useTable` observe the provider state, receive the fresh connection, and establish their subscriptions again. This provider-level recovery does not change the lower-level `DbConnection` contract: applications that create a `DbConnection` directly are still responsible for creating a new connection if they need reconnection behavior. +While a `SpacetimeDBProvider` is mounted, the React connection manager also replaces the managed `DbConnection` if the underlying WebSocket closes or reports a connection error. Reconnect attempts use exponential backoff, starting at 1 second and doubling after each consecutive failure up to a 30 second maximum; the backoff resets after a successful connection. Hooks such as `useTable` observe the provider state, receive the fresh connection, and establish their subscriptions again; while the replacement connection is being established, `useTable` reports `isReady` as `false` until its subscription is applied on the new connection. This provider-level recovery does not change the lower-level `DbConnection` contract: applications that create a `DbConnection` directly are still responsible for creating a new connection if they need reconnection behavior. | Name | Description | | ----------------------------------------------------------- | --------------------------------------------------------- |