From 46bdbb4d8bb04af8a22a78c80fffd57ff7886cce Mon Sep 17 00:00:00 2001 From: Kris Jenkins Date: Wed, 17 Jun 2026 11:07:04 +0100 Subject: [PATCH 1/2] Add ConnectionManager.rebuild() for token-swap reconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes An app that needs to reconnect with a different auth token — the common "start anonymous, then the user signs in" flow — currently can't do it cleanly through the connection pool. `ConnectionManager` (used by the React and Solid providers) can hold and reference-count a connection, but it has no way to swap a live connection's builder: `retain()` deliberately ignores the builder once a connection exists, and a plain `disconnect()` doesn't reconnect. So the only ways to apply a new token are to tear down and rebuild the whole provider subtree or reload the page — both of which throw away client state for what should be a routine auth transition. `rebuild(key, builder)` closes that gap. It tears down the live connection and builds a fresh one from a new builder under the same ref count and listener set, so framework hooks (`useTable`, `useReducer`, …) re-bind to the new connection automatically — swap the token in the builder, call `rebuild`, done. The old connection's callbacks are detached before it is closed (so its disconnect event can't leak into pool state), any deferred release or pending auto-reconnect is cancelled, and the backoff counter is reset. Returns the new connection, or `null` when the key has no retained entry. This is the `rebuild()` primitive from the open multi-module-provider PR #4887, lifted into a focused change, adapted to the current post-#5185 reconnect machinery, and reusing the existing `#buildManagedConnection` / `#detachCallbacks` helpers rather than the `#install` / `#teardown` refactor in that PR. Original design by @Ludv1gL. Motivated by #5373; hangs off the reconnect tracking issue #1936. # API and ABI breaking changes None. Purely additive: one new public method on `ConnectionManager`. # Expected complexity level and risk 2. Small method that reuses the same build/teardown helpers as `retain`/`release` and the auto-reconnect path. The only subtlety is ordering — detach-then-close the old connection and cancel pending timers so a stale disconnect event or a scheduled auto-reconnect can't race the freshly-built connection. # Testing - [x] Six new unit tests against the real `ConnectionManager` singleton (`tests/connection_manager_reconnect.test.ts`): builder swap, ref-count preservation, callback detachment, pending-reconnect cancellation + backoff reset, and both `null`-return cases. Full file: 17 pass. - [ ] Reviewer: confirm the `rebuild(key, builder)` signature matches what #4887 intends before it is surfaced through the framework providers. --- .../src/sdk/connection_manager.ts | 52 +++++++ .../connection_manager_reconnect.test.ts | 131 ++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 25d19260d7a..469f8326892 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -294,6 +294,58 @@ class ConnectionManagerImpl { return this.#buildManagedConnection(managed, builder); } + /** + * Tears down the current connection and builds a fresh one from `builder`, + * preserving the entry's ref count and listener set. + * + * `retain()` deliberately ignores the builder once a connection is live — + * the right behaviour for ref-counting, but it blocks "reconnect with a + * fresh token" flows (e.g. swapping an anonymous session for a signed-in one + * after an auth change). `rebuild()` is the supported escape hatch: pass a + * builder carrying the new token and the pool swaps the live connection + * under the same subscribers, so framework hooks (`useTable`, `useReducer`, + * …) re-bind to the new connection automatically. + * + * The old connection's callbacks are detached before it is closed, so its + * disconnect event never leaks into pool state, and any pending auto-reconnect + * is cancelled (the caller is driving the reconnect explicitly). Returns the + * newly-built connection, or `null` if the key has no retained entry. + * + * @param key - Unique identifier for the connection (use getKey to generate) + * @param builder - Fresh connection builder; its handlers are rewired into the pool + */ + rebuild>( + key: string, + builder: DbConnectionBuilder + ): T | null { + const managed = this.#connections.get(key); + if (!managed || managed.refCount <= 0) { + return null; + } + + // The caller is taking over the connection lifecycle explicitly; cancel a + // deferred release or a pending auto-reconnect so neither races the fresh + // connection, and reset the backoff so the next unexpected drop starts over. + if (managed.pendingRelease) { + clearTimeout(managed.pendingRelease); + managed.pendingRelease = null; + } + if (managed.reconnectTimer) { + clearTimeout(managed.reconnectTimer); + managed.reconnectTimer = null; + } + managed.reconnectAttempt = 0; + + const connection = managed.connection; + if (connection) { + this.#detachCallbacks(managed, connection); + connection.disconnect(); + } + managed.connection = undefined; + + return this.#buildManagedConnection(managed, builder) as T; + } + release(key: string): void { const managed = this.#connections.get(key); if (!managed) { diff --git a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts index 675ac998446..001ac750a08 100644 --- a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts @@ -418,3 +418,134 @@ describe('ConnectionManager retained reconnect behavior', () => { ); }); }); + +describe('ConnectionManager.rebuild', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + test('swaps the live connection for one built from a fresh builder', () => { + const key = nextKey(); + const firstBuilder = new MockBuilder(); + const secondBuilder = new MockBuilder(); + + const first = retainMock(key, firstBuilder); + first.simulateConnect(); + expect(ConnectionManager.getSnapshot(key)?.isActive).toBe(true); + + const second = ConnectionManager.rebuild( + key, + secondBuilder as any + ) as unknown as MockConnection; + + // The new connection comes from the replacement builder... + expect(secondBuilder.buildCount).toBe(1); + expect(second).toBe(secondBuilder.connections[0]); + expect(second).not.toBe(first); + expect(ConnectionManager.getConnection(key)).toBe(second); + // ...and the old one is torn down. + expect(first.disconnected).toBe(true); + + second.simulateConnect(); + expect(ConnectionManager.getSnapshot(key)?.isActive).toBe(true); + expect(ConnectionManager.getSnapshot(key)?.connectionId).toBe( + second.connectionId + ); + + ConnectionManager.release(key); + }); + + test('preserves the ref count so a single release still tears down', () => { + const key = nextKey(); + const firstBuilder = new MockBuilder(); + const secondBuilder = new MockBuilder(); + + retainMock(key, firstBuilder); + retainMock(key, secondBuilder); // refCount: 2 + + const rebuilt = ConnectionManager.rebuild( + key, + new MockBuilder() as any + ) as unknown as MockConnection; + expect(rebuilt).not.toBeNull(); + + // refCount was 2 and is untouched: one release leaves the entry live. + ConnectionManager.release(key); + vi.advanceTimersByTime(0); + expect(ConnectionManager.getConnection(key)).toBe(rebuilt); + + ConnectionManager.release(key); + vi.advanceTimersByTime(0); + expect(ConnectionManager.getConnection(key)).toBeNull(); + }); + + test('detaches the old connection callbacks before closing it', () => { + const key = nextKey(); + const first = retainMock(key, new MockBuilder()); + expect(first.callbackCounts()).toEqual({ + connect: 1, + disconnect: 1, + connectError: 1, + }); + + ConnectionManager.rebuild(key, new MockBuilder() as any); + + expect(first.callbackCounts()).toEqual({ + connect: 0, + disconnect: 0, + connectError: 0, + }); + + ConnectionManager.release(key); + }); + + test('cancels a pending auto-reconnect and resets the backoff', () => { + const key = nextKey(); + const builder = new MockBuilder(); + + const first = retainMock(key, builder); + // Two consecutive failures so the backoff has advanced past the base delay. + first.simulateDisconnect(); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); + builder.connections[1].simulateConnectError(new Error('still down')); + expect(builder.buildCount).toBe(2); + + // rebuild() takes over: the scheduled reconnect must not also fire. + const replacement = new MockBuilder(); + ConnectionManager.rebuild(key, replacement as any); + expect(replacement.buildCount).toBe(1); + + vi.advanceTimersByTime(CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS); + // No stale timer rebuilt the old builder... + expect(builder.buildCount).toBe(2); + expect(replacement.buildCount).toBe(1); + + // ...and the backoff was reset: a fresh drop reconnects after the base delay. + replacement.connections[0].simulateConnect(); + replacement.connections[0].simulateDisconnect(); + vi.advanceTimersByTime(connectionManagerReconnectDelayMs(0)); + expect(replacement.buildCount).toBe(2); + + ConnectionManager.release(key); + }); + + test('returns null when the key has no retained entry', () => { + const key = nextKey(); + expect(ConnectionManager.rebuild(key, new MockBuilder() as any)).toBeNull(); + }); + + test('returns null after the entry has been fully released', () => { + const key = nextKey(); + const first = retainMock(key, new MockBuilder()); + first.simulateConnect(); + ConnectionManager.release(key); + vi.advanceTimersByTime(0); // let the deferred release run + + expect(ConnectionManager.rebuild(key, new MockBuilder() as any)).toBeNull(); + }); +}); From 76480dc11a6d36e9499ff9d3514c13a03fa51c59 Mon Sep 17 00:00:00 2001 From: Kris Jenkins Date: Wed, 17 Jun 2026 12:06:01 +0100 Subject: [PATCH 2/2] Put the Svelte binding on ConnectionManager; add reconnect() for token swaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes A Svelte app that lets a user start anonymous and then sign in (e.g. via Google) has no clean way to apply the new token. The Svelte provider holds its connection in a module-level singleton that's reused across remounts, so even a `{#key}` swap keeps the old token, and there's no reconnect path — the only thing that actually works is a hard `window.location.reload()`, which throws away all client state and flickers the UI on every sign-in / sign-out. The Svelte binding also lacks the auto-reconnect that the React and Solid bindings already have, so a dropped socket just stays dropped. This rebuilds `createSpacetimeDBProvider` on top of the shared `ConnectionManager` — the same pool React and Solid already use — and surfaces a `reconnect(builder)` method on the context value, closing both gaps at once: - The connection is owned by `ConnectionManager`, keyed by uri + database name. Its reference counting and deferred release absorb rapid mount/unmount cycles (HMR, `{#key}`), replacing the bespoke singleton, and the binding gets the manager's exponential-backoff auto-reconnect on unexpected drops for free. - `ConnectionState` gains `reconnect(builder)` (backed by the new `ConnectionManager.rebuild`): tears down the current connection and reconnects with a fresh builder carrying a new token, no page reload. `useTable` / `useReducer` already react to the connection store, so subscriptions re-bind to the new connection automatically. - `getConnection` is now non-generic (`DbConnectionImpl | null`), matching the React and Solid `ConnectionState`, so the bindings line up. `useTable`, `useReducer` and `useSpacetimeDB` are unchanged. Motivated by #5373; hangs off the reconnect tracking issue #1936. # API and ABI breaking changes Minor, TypeScript-only: `ConnectionState.getConnection` is no longer generic — it returns `DbConnectionImpl | null`, as the React and Solid bindings already do. Call sites that wrote `getConnection()` lose the type argument; `getConnection()` is unchanged. The provider's return type stays `Writable`. # Expected complexity level and risk 2. Low. The Svelte provider is now a thin adapter: it mirrors the manager's store into a Svelte store and wires up `getConnection` / `reconnect` / `retain` / `release`. The connection-lifecycle logic that used to be bespoke here — the module-level singleton, deferred cleanup, and reconnect — now lives in the well-tested, shared `ConnectionManager` that the React and Solid bindings already depend on. # Testing - [x] `tsc -p tsconfig.build.json` typechecks; `build:js` emits the Svelte ESM/CJS + browser bundles cleanly; eslint + prettier clean. - [x] Full vitest suite green (225 tests), including the new `rebuild()` unit tests from the previous commit. - [ ] Reviewer: this package has no Svelte component-test harness today (the pre-existing Svelte binding had none), so the provider wiring is covered indirectly through the `ConnectionManager` unit tests. Worth a manual smoke test of an anonymous -> signed-in `reconnect(builder)` flow, and a call on whether to add @testing-library/svelte coverage here. --- .../src/svelte/SpacetimeDBProvider.ts | 110 +++++++----------- .../src/svelte/connection_state.ts | 32 ++--- 2 files changed, 61 insertions(+), 81 deletions(-) diff --git a/crates/bindings-typescript/src/svelte/SpacetimeDBProvider.ts b/crates/bindings-typescript/src/svelte/SpacetimeDBProvider.ts index d4e4bd1a82a..ea7521384c9 100644 --- a/crates/bindings-typescript/src/svelte/SpacetimeDBProvider.ts +++ b/crates/bindings-typescript/src/svelte/SpacetimeDBProvider.ts @@ -3,99 +3,75 @@ import { writable, type Writable } from 'svelte/store'; import { DbConnectionBuilder, type DbConnectionImpl, - type ErrorContextInterface, - type RemoteModuleOf, } from '../sdk/db_connection_impl'; import { ConnectionId } from '../lib/connection_id'; +import { + ConnectionManager, + type ConnectionState as ManagerConnectionState, +} from '../sdk/connection_manager'; import { SPACETIMEDB_CONTEXT_KEY, type ConnectionState, } from './connection_state'; -let connRef: DbConnectionImpl | null = null; -let cleanupTimeoutId: ReturnType | null = null; - +/** + * Establish a SpacetimeDB connection for the current component subtree and make + * it available to `useSpacetimeDB`, `useTable` and `useReducer`. + * + * The connection is owned by the shared `ConnectionManager` (the same pool the + * React and Solid bindings use), keyed by uri + database name. The manager's + * reference counting and deferred cleanup absorb rapid mount/unmount cycles + * (HMR, `{#key}` blocks), and it reconnects automatically with exponential + * backoff if the socket drops unexpectedly. + * + * To swap the connection's auth token without reloading the page (e.g. after a + * sign-in), call `reconnect(builder)` from the context value with a builder + * carrying the new token. + */ export function createSpacetimeDBProvider< DbConnection extends DbConnectionImpl, >( connectionBuilder: DbConnectionBuilder ): Writable { - const getConnection = () => connRef as DbConnection | null; + const key = ConnectionManager.getKey( + connectionBuilder.getUri(), + connectionBuilder.getModuleName() + ); - const store = writable({ + const fallback: ManagerConnectionState = { isActive: false, identity: undefined, token: undefined, connectionId: ConnectionId.random(), connectionError: undefined, - getConnection: getConnection as ConnectionState['getConnection'], - }); - - if (cleanupTimeoutId) { - clearTimeout(cleanupTimeoutId); - cleanupTimeoutId = null; - } - - if (!connRef) { - connRef = connectionBuilder.build(); - } - - const onConnect = (conn: DbConnection) => { - store.update(s => ({ - ...s, - isActive: conn.isActive, - identity: conn.identity, - token: conn.token, - connectionId: conn.connectionId, - })); }; - const onDisconnect = ( - ctx: ErrorContextInterface> - ) => { - store.update(s => ({ - ...s, - isActive: ctx.isActive, - })); + const getConnection = () => + ConnectionManager.getConnection(key); + const reconnect = (builder: DbConnectionBuilder): void => { + ConnectionManager.rebuild(key, builder); }; - const onConnectError = ( - ctx: ErrorContextInterface>, - err: Error - ) => { - store.update(s => ({ - ...s, - isActive: ctx.isActive, - connectionError: err, - })); - }; - - connectionBuilder.onConnect(onConnect); - connectionBuilder.onDisconnect(onDisconnect); - connectionBuilder.onConnectError(onConnectError); - - const conn = connRef; - store.update(s => ({ - ...s, - isActive: conn.isActive, - identity: conn.identity, - token: conn.token, - connectionId: conn.connectionId, - })); + const snapshot = (): ConnectionState => ({ + ...(ConnectionManager.getSnapshot(key) ?? fallback), + getConnection, + reconnect, + }); - setContext(SPACETIMEDB_CONTEXT_KEY, store); + // Retain for this provider's lifetime, then mirror the manager's external + // store into a Svelte store. `getConnection` / `reconnect` are stable across + // updates; only the plain state fields change. + ConnectionManager.retain(key, connectionBuilder); + const store = writable(snapshot()); + const unsubscribe = ConnectionManager.subscribe(key, () => + store.set(snapshot()) + ); onDestroy(() => { - connRef?.removeOnConnect(onConnect as any); - connRef?.removeOnDisconnect(onDisconnect as any); - connRef?.removeOnConnectError(onConnectError as any); - - cleanupTimeoutId = setTimeout(() => { - connRef?.disconnect(); - connRef = null; - cleanupTimeoutId = null; - }, 0); + unsubscribe(); + ConnectionManager.release(key); }); + setContext(SPACETIMEDB_CONTEXT_KEY, store); return store; } diff --git a/crates/bindings-typescript/src/svelte/connection_state.ts b/crates/bindings-typescript/src/svelte/connection_state.ts index bfd2098d929..502ec666d4b 100644 --- a/crates/bindings-typescript/src/svelte/connection_state.ts +++ b/crates/bindings-typescript/src/svelte/connection_state.ts @@ -1,16 +1,20 @@ -import type { ConnectionId } from '../lib/connection_id'; -import type { Identity } from '../lib/identity'; -import type { DbConnectionImpl } from '../sdk/db_connection_impl'; - -export type ConnectionState = { - isActive: boolean; - identity?: Identity; - token?: string; - connectionId: ConnectionId; - connectionError?: Error; - getConnection< - DbConnection extends DbConnectionImpl, - >(): DbConnection | null; -}; +import type { + DbConnectionBuilder, + DbConnectionImpl, +} from '../sdk/db_connection_impl'; +import type { ConnectionState as ManagerConnectionState } from '../sdk/connection_manager'; export const SPACETIMEDB_CONTEXT_KEY = Symbol('spacetimedb'); + +export type ConnectionState = ManagerConnectionState & { + /** The live connection, or `null` before it is first established. */ + getConnection(): DbConnectionImpl | null; + /** + * Tear down the current connection and reconnect using a fresh builder — + * typically to apply a new auth token after sign-in or sign-out. The builder + * should carry the new token and the same uri + database name. Table and + * reducer subscriptions re-bind automatically once the new connection is + * live, so there is no need to reload the page to swap a token. + */ + reconnect(builder: DbConnectionBuilder): void; +};