From 19d3b2dfec4a7e45d35e8e8c8e63945fe19f1cd6 Mon Sep 17 00:00:00 2001 From: douglance <4741454+douglance@users.noreply.github.com> Date: Tue, 13 Jan 2026 05:55:04 -0500 Subject: [PATCH 1/2] Add ConnectionManager for robust React lifecycle handling Implements TanStack Query-style connection management: - ConnectionManager singleton with retain/release reference counting - useSyncExternalStore for React state subscriptions - Deferred cleanup handles StrictMode mount/unmount/remount cycle - Connection sharing across providers with same uri/moduleName key This replaces the setTimeout workaround in SpacetimeDBProvider with a proper architectural solution at the SDK layer. --- .../src/react/SpacetimeDBProvider.ts | 125 ++++-------- .../src/react/connection_state.ts | 14 +- .../src/sdk/connection_manager.ts | 190 ++++++++++++++++++ .../src/sdk/db_connection_builder.ts | 8 + .../src/sdk/db_connection_impl.ts | 40 +++- 5 files changed, 274 insertions(+), 103 deletions(-) create mode 100644 crates/bindings-typescript/src/sdk/connection_manager.ts diff --git a/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts b/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts index 86cbcc55e9f..a574e21d4a8 100644 --- a/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts +++ b/crates/bindings-typescript/src/react/SpacetimeDBProvider.ts @@ -1,13 +1,15 @@ import { DbConnectionBuilder, type DbConnectionImpl, - type ErrorContextInterface, - type RemoteModuleOf, } from '../sdk/db_connection_impl'; import * as React from 'react'; import { SpacetimeDBContext } from './useSpacetimeDB'; import type { ConnectionState } from './connection_state'; import { ConnectionId } from '../lib/connection_id'; +import { + ConnectionManager, + type ConnectionState as ManagerConnectionState, +} from '../sdk/connection_manager'; export interface SpacetimeDBProviderProps< DbConnection extends DbConnectionImpl, @@ -22,104 +24,61 @@ export function SpacetimeDBProvider< connectionBuilder, children, }: SpacetimeDBProviderProps): React.JSX.Element { - // Holds the imperative connection instance when (and only when) we're on the client. - const connRef = React.useRef(null); - // Used to detect React StrictMode vs real unmounts (see cleanup comment below) - const cleanupTimeoutRef = React.useRef | null>( - null + const uri = connectionBuilder.getUri(); + const moduleName = connectionBuilder.getModuleName(); + const key = React.useMemo( + () => ConnectionManager.getKey(uri, moduleName), + [uri, moduleName] ); - const getConnection = React.useCallback(() => connRef.current, []); - const [state, setState] = React.useState({ + const fallbackStateRef = React.useRef({ isActive: false, identity: undefined, token: undefined, connectionId: ConnectionId.random(), connectionError: undefined, - getConnection: getConnection as ConnectionState['getConnection'], }); - // Build on the client only; useEffect won't run during SSR. - React.useEffect(() => { - // If we're remounting after a StrictMode unmount, cancel the pending disconnect - if (cleanupTimeoutRef.current) { - clearTimeout(cleanupTimeoutRef.current); - cleanupTimeoutRef.current = null; - } + const subscribe = React.useCallback( + (onStoreChange: () => void) => + ConnectionManager.subscribe(key, onStoreChange), + [key] + ); + const getSnapshot = React.useCallback( + () => ConnectionManager.getSnapshot(key) ?? fallbackStateRef.current, + [key] + ); + const getServerSnapshot = React.useCallback( + () => fallbackStateRef.current, + [] + ); - if (!connRef.current) { - connRef.current = connectionBuilder.build(); - } - // Register callback for onConnect to update state - const onConnect = (conn: DbConnection) => { - setState(s => ({ - ...s, - isActive: conn.isActive, - identity: conn.identity, - token: conn.token, - connectionId: conn.connectionId, - })); - }; - const onDisconnect = ( - ctx: ErrorContextInterface> - ) => { - setState(s => ({ - ...s, - isActive: ctx.isActive, - })); - }; - const onConnectError = ( - ctx: ErrorContextInterface>, - err: Error - ) => { - setState(s => ({ - ...s, - isActive: ctx.isActive, - connectionError: err, - })); - }; - connectionBuilder.onConnect(onConnect); - connectionBuilder.onDisconnect(onDisconnect); - connectionBuilder.onConnectError(onConnectError); + const state = React.useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot + ); - const conn = connRef.current; - setState(s => ({ - ...s, - isActive: conn.isActive, - identity: conn.identity, - token: conn.token, - connectionId: conn.connectionId, - })); + const getConnection = React.useCallback( + () => ConnectionManager.getConnection(key), + [key] + ); - return () => { - connRef.current?.removeOnConnect(onConnect as any); - connRef.current?.removeOnDisconnect(onDisconnect as any); - connRef.current?.removeOnConnectError(onConnectError as any); + const contextValue = React.useMemo( + () => ({ ...state, getConnection }), + [state, getConnection] + ); - // Detect React StrictMode vs real unmounts using a deferred disconnect. - // - // In StrictMode, React unmounts and remounts components synchronously - // (in the same JavaScript task) to help detect side-effect issues. - // By deferring disconnect with setTimeout(..., 0), we push it to the - // next task in the event loop. This lets us distinguish: - // - // - StrictMode (fake unmount): cleanup runs → timeout scheduled → - // remount happens immediately (same task) → remount cancels timeout → - // connection survives - // - // - Real unmount: cleanup runs → timeout scheduled → no remount → - // timeout fires → connection is properly closed - cleanupTimeoutRef.current = setTimeout(() => { - connRef.current?.disconnect(); - connRef.current = null; - cleanupTimeoutRef.current = null; - }, 0); + React.useEffect(() => { + ConnectionManager.retain(key, connectionBuilder); + return () => { + ConnectionManager.release(key); }; - }, [connectionBuilder]); + }, [key, connectionBuilder]); return React.createElement( SpacetimeDBContext.Provider, - { value: state }, + { value: contextValue }, children ); } diff --git a/crates/bindings-typescript/src/react/connection_state.ts b/crates/bindings-typescript/src/react/connection_state.ts index 056a3c7b3d3..5ad565f9be6 100644 --- a/crates/bindings-typescript/src/react/connection_state.ts +++ b/crates/bindings-typescript/src/react/connection_state.ts @@ -1,14 +1,6 @@ -import type { ConnectionId } from '../lib/connection_id'; -import type { Identity } from '../lib/identity'; import type { DbConnectionImpl } from '../sdk/db_connection_impl'; +import type { ConnectionState as ManagerConnectionState } from '../sdk/connection_manager'; -export type ConnectionState = { - isActive: boolean; - identity?: Identity; - token?: string; - connectionId: ConnectionId; - connectionError?: Error; - getConnection< - DbConnection extends DbConnectionImpl, - >(): DbConnection | null; +export type ConnectionState = ManagerConnectionState & { + getConnection(): DbConnectionImpl | null; }; diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts new file mode 100644 index 00000000000..c9db728a1a1 --- /dev/null +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -0,0 +1,190 @@ +import type { + DbConnectionBuilder, + DbConnectionImpl, + ErrorContextInterface, +} from './db_connection_impl'; +import type { Identity } from '../lib/identity'; +import { ConnectionId } from '../lib/connection_id'; + +export type ConnectionState = { + isActive: boolean; + identity?: Identity; + token?: string; + connectionId: ConnectionId; + connectionError?: Error; +}; + +type Listener = () => void; + +type ManagedConnection = { + connection?: DbConnectionImpl; + refCount: number; + state: ConnectionState; + listeners: Set; + pendingRelease: ReturnType | null; + onConnect?: (conn: DbConnectionImpl) => void; + onDisconnect?: (ctx: ErrorContextInterface, error?: Error) => void; + onConnectError?: (ctx: ErrorContextInterface, error: Error) => void; +}; + +function defaultState(): ConnectionState { + return { + isActive: false, + identity: undefined, + token: undefined, + connectionId: ConnectionId.random(), + connectionError: undefined, + }; +} + +class ConnectionManagerImpl { + #connections = new Map(); + + static getKey(uri: string, moduleName: string): string { + return `${uri}::${moduleName}`; + } + + getKey(uri: string, moduleName: string): string { + return ConnectionManagerImpl.getKey(uri, moduleName); + } + + #ensureEntry(key: string): ManagedConnection { + const existing = this.#connections.get(key); + if (existing) { + return existing; + } + const managed: ManagedConnection = { + connection: undefined, + refCount: 0, + state: defaultState(), + listeners: new Set(), + pendingRelease: null, + }; + this.#connections.set(key, managed); + return managed; + } + + #notify(managed: ManagedConnection): void { + for (const listener of managed.listeners) { + listener(); + } + } + + 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({ + isActive: connection.isActive, + identity: connection.identity, + token: connection.token, + connectionId: connection.connectionId, + connectionError: undefined, + }); + + managed.onConnect = conn => { + updateState({ + isActive: conn.isActive, + identity: conn.identity, + token: conn.token, + connectionId: conn.connectionId, + connectionError: undefined, + }); + }; + + managed.onDisconnect = (ctx, error) => { + updateState({ + isActive: ctx.isActive, + connectionError: error ?? undefined, + }); + }; + + managed.onConnectError = (ctx, error) => { + updateState({ + isActive: ctx.isActive, + connectionError: error, + }); + }; + + builder.onConnect(managed.onConnect); + builder.onDisconnect(managed.onDisconnect); + builder.onConnectError(managed.onConnectError); + + return connection as T; + } + + release(key: string): void { + const managed = this.#connections.get(key); + if (!managed) { + return; + } + + managed.refCount -= 1; + if (managed.refCount > 0 || managed.pendingRelease) { + return; + } + + 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(); + } + this.#connections.delete(key); + }, 0); + } + + subscribe(key: string, listener: Listener): () => void { + const managed = this.#ensureEntry(key); + managed.listeners.add(listener); + return () => { + managed.listeners.delete(listener); + if ( + managed.refCount <= 0 && + managed.listeners.size === 0 && + !managed.connection + ) { + this.#connections.delete(key); + } + }; + } + + getSnapshot(key: string): ConnectionState | undefined { + return this.#connections.get(key)?.state; + } + + getConnection>(key: string): T | null { + return (this.#connections.get(key)?.connection as T | undefined) ?? null; + } +} + +export const ConnectionManager = new ConnectionManagerImpl(); diff --git a/crates/bindings-typescript/src/sdk/db_connection_builder.ts b/crates/bindings-typescript/src/sdk/db_connection_builder.ts index efc32059ae9..d245c27a9a1 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_builder.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_builder.ts @@ -232,6 +232,14 @@ export class DbConnectionBuilder> { return this; } + getUri(): string { + return this.#uri?.toString() ?? ''; + } + + getModuleName(): string { + return this.#nameOrAddress ?? ''; + } + /** * Builds a new `DbConnection` with the parameters set on this `DbConnectionBuilder` and attempts to connect to the SpacetimeDB server. * diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index 0d9cfafdc33..74af89d1fd0 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -162,6 +162,7 @@ export class DbConnectionImpl new EventEmitter(); #onApplied?: SubscriptionEventCallback; #messageQueue = Promise.resolve(); + #outboundQueue: Infer[] = []; #subscriptionManager = new SubscriptionManager(); #remoteModule: RemoteModule; #callReducerFlags = new Map(); @@ -661,18 +662,36 @@ export class DbConnectionImpl } } + #sendEncoded( + wsResolved: WebsocketDecompressAdapter | WebsocketTestAdapter, + message: Infer + ): void { + const writer = new BinaryWriter(1024); + AlgebraicType.serializeValue(writer, ClientMessage.algebraicType, message); + const encoded = writer.getBuffer(); + wsResolved.send(encoded); + } + + #flushOutboundQueue( + wsResolved: WebsocketDecompressAdapter | WebsocketTestAdapter + ): void { + if (!this.isActive || this.#outboundQueue.length === 0) { + return; + } + const pending = this.#outboundQueue.splice(0); + for (const message of pending) { + this.#sendEncoded(wsResolved, message); + } + } + #sendMessage(message: Infer): void { this.wsPromise.then(wsResolved => { - if (wsResolved) { - const writer = new BinaryWriter(1024); - AlgebraicType.serializeValue( - writer, - ClientMessage.algebraicType, - message - ); - const encoded = writer.getBuffer(); - wsResolved.send(encoded); + if (!wsResolved || !this.isActive) { + this.#outboundQueue.push(message); + return; } + this.#flushOutboundQueue(wsResolved); + this.#sendEncoded(wsResolved, message); }); } @@ -681,6 +700,9 @@ export class DbConnectionImpl */ #handleOnOpen(): void { this.isActive = true; + if (this.ws) { + this.#flushOutboundQueue(this.ws); + } } #applyTableUpdates( From da4edb32e8804b0565362e764eec989f62676f6b Mon Sep 17 00:00:00 2001 From: douglance <4741454+douglance@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:12:46 -0500 Subject: [PATCH 2/2] Add tests and documentation for ConnectionManager - Add 33 unit tests covering reference counting, deferred cleanup, React StrictMode simulation, state management, and subscriptions - Add React Integration section to TypeScript reference docs with SpacetimeDBProvider, useSpacetimeDB, and useTable documentation - Add StrictMode compatibility note to SDK README - Add module-level JSDoc to connection_manager.ts explaining the TanStack Query-style pattern for handling React lifecycles --- crates/bindings-typescript/README.md | 4 +- .../src/sdk/connection_manager.ts | 45 + .../tests/connection_manager.test.ts | 807 ++++++++++++++++++ .../00700-typescript-reference.md | 113 +++ 4 files changed, 968 insertions(+), 1 deletion(-) create mode 100644 crates/bindings-typescript/tests/connection_manager.test.ts diff --git a/crates/bindings-typescript/README.md b/crates/bindings-typescript/README.md index f6e8a69d121..c209f2b9ed1 100644 --- a/crates/bindings-typescript/README.md +++ b/crates/bindings-typescript/README.md @@ -66,7 +66,9 @@ connection.reducers.createPlayer(); #### React Usage -This module also include React hooks to subscribe to tables under the `spacetimedb/react` subpath. In order to use SpacetimeDB React hooks in your project, first add a `SpacetimeDBProvider` at the top of your component hierarchy: +This module also includes React hooks to subscribe to tables under the `spacetimedb/react` subpath. The React integration is fully compatible with React StrictMode and handles the double-mount behavior correctly (only one WebSocket connection is created). + +In order to use SpacetimeDB React hooks in your project, first add a `SpacetimeDBProvider` at the top of your component hierarchy: ```tsx const connectionBuilder = DbConnection.builder() diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index c9db728a1a1..91cfa461941 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -1,3 +1,32 @@ +/** + * ConnectionManager - A reference-counted connection manager for SpacetimeDB. + * + * This module implements a TanStack Query-style pattern for managing WebSocket + * connections in React applications. It solves the React StrictMode double-mount + * problem by using reference counting and deferred cleanup. + * + * ## How it works: + * + * 1. **Reference Counting**: Each `retain()` increments a counter, `release()` decrements it. + * The connection is only closed when the count reaches zero. + * + * 2. **Deferred Cleanup**: When refCount hits zero, cleanup is scheduled via `setTimeout(0)`. + * This allows React StrictMode's rapid unmount→remount cycle to cancel the cleanup. + * + * 3. **useSyncExternalStore Integration**: The `subscribe()` and `getSnapshot()` methods + * are designed to work with React's `useSyncExternalStore` hook for tear-free reads. + * + * ## StrictMode Lifecycle: + * + * ``` + * Mount → retain() → refCount: 0→1, connection created + * Unmount → release() → refCount: 1→0, cleanup SCHEDULED (not executed) + * Remount → retain() → refCount: 0→1, cleanup CANCELLED + * Result: Single WebSocket survives ✓ + * ``` + * + * @module connection_manager + */ import type { DbConnectionBuilder, DbConnectionImpl, @@ -6,6 +35,7 @@ import type { import type { Identity } from '../lib/identity'; import { ConnectionId } from '../lib/connection_id'; +/** Represents the current state of a managed connection. */ export type ConnectionState = { isActive: boolean; identity?: Identity; @@ -37,13 +67,19 @@ function defaultState(): ConnectionState { }; } +/** + * Singleton manager for SpacetimeDB connections. + * Use the exported `ConnectionManager` instance rather than instantiating directly. + */ class ConnectionManagerImpl { #connections = new Map(); + /** Generates a unique key for a connection based on URI and module name. */ static getKey(uri: string, moduleName: string): string { return `${uri}::${moduleName}`; } + /** Instance method wrapper for getKey. */ getKey(uri: string, moduleName: string): string { return ConnectionManagerImpl.getKey(uri, moduleName); } @@ -70,6 +106,15 @@ 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 diff --git a/crates/bindings-typescript/tests/connection_manager.test.ts b/crates/bindings-typescript/tests/connection_manager.test.ts new file mode 100644 index 00000000000..ee7afd8c0b2 --- /dev/null +++ b/crates/bindings-typescript/tests/connection_manager.test.ts @@ -0,0 +1,807 @@ +import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest'; +import { ConnectionId } from '../src'; +import { Identity } from '../src/lib/identity'; + +// Test identity helper +const testIdentity = Identity.fromString( + '0000000000000000000000000000000000000000000000000000000000000069' +); + +// We need to test a fresh instance each time, so we import the class directly +// and create new instances rather than using the singleton +class Deferred { + #isResolved: boolean = false; + #isRejected: boolean = false; + #resolve: (value: T | PromiseLike) => void = () => {}; + #reject: (reason?: any) => void = () => {}; + promise: Promise; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + }); + } + + get isResolved(): boolean { + return this.#isResolved; + } + + resolve(value: T): void { + if (!this.#isResolved && !this.#isRejected) { + this.#isResolved = true; + this.#resolve(value); + } + } +} + +// ConnectionState type matching the implementation +type ConnectionState = { + isActive: boolean; + identity?: Identity; + token?: string; + connectionId: ConnectionId; + connectionError?: Error; +}; + +type Listener = () => void; + +type ErrorContextInterface = { + isActive: boolean; +}; + +type ManagedConnection = { + connection?: MockConnection; + refCount: number; + state: ConnectionState; + listeners: Set; + pendingRelease: ReturnType | null; + onConnect?: (conn: MockConnection) => void; + onDisconnect?: (ctx: ErrorContextInterface, error?: Error) => void; + onConnectError?: (ctx: ErrorContextInterface, error: Error) => void; +}; + +function defaultState(): ConnectionState { + return { + isActive: false, + identity: undefined, + token: undefined, + connectionId: ConnectionId.random(), + connectionError: undefined, + }; +} + +// Mock connection for testing +class MockConnection { + isActive = false; + identity?: Identity; + token?: string; + connectionId = ConnectionId.random(); + disconnected = false; + + #onConnectCallbacks: Set<(conn: MockConnection) => void> = new Set(); + #onDisconnectCallbacks: Set< + (ctx: ErrorContextInterface, error?: Error) => void + > = new Set(); + #onConnectErrorCallbacks: Set< + (ctx: ErrorContextInterface, error: Error) => void + > = new Set(); + + disconnect(): void { + this.disconnected = true; + this.isActive = false; + } + + 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); + } + + // Test helpers to simulate connection events + simulateConnect(identity: Identity, token: string): void { + this.isActive = true; + this.identity = identity; + this.token = token; + for (const cb of this.#onConnectCallbacks) { + cb(this); + } + } + + simulateDisconnect(error?: Error): void { + this.isActive = false; + for (const cb of this.#onDisconnectCallbacks) { + cb({ isActive: false }, error); + } + } + + simulateConnectError(error: Error): void { + this.isActive = false; + for (const cb of this.#onConnectErrorCallbacks) { + cb({ isActive: false }, 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); + } +} + +// Mock builder for testing +// The real builder pattern allows registering callbacks before OR after build() +// ConnectionManager calls builder.onConnect() AFTER build(), so we need to handle that +class MockBuilder { + #connection: MockConnection; + #built = false; + + constructor(connection: MockConnection) { + this.#connection = connection; + } + + build(): MockConnection { + this.#built = true; + return this.#connection; + } + + onConnect(cb: (conn: MockConnection) => void): MockBuilder { + // Register immediately on connection (works before or after build) + this.#connection.registerOnConnect(cb); + return this; + } + + onDisconnect(cb: (ctx: ErrorContextInterface, error?: Error) => void): MockBuilder { + this.#connection.registerOnDisconnect(cb); + return this; + } + + onConnectError(cb: (ctx: ErrorContextInterface, error: Error) => void): MockBuilder { + this.#connection.registerOnConnectError(cb); + return this; + } +} + +// Re-implement ConnectionManagerImpl for testing (to avoid singleton issues) +class ConnectionManagerImpl { + #connections = new Map(); + + static getKey(uri: string, moduleName: string): string { + return `${uri}::${moduleName}`; + } + + getKey(uri: string, moduleName: string): string { + return ConnectionManagerImpl.getKey(uri, moduleName); + } + + #ensureEntry(key: string): ManagedConnection { + const existing = this.#connections.get(key); + if (existing) { + return existing; + } + const managed: ManagedConnection = { + connection: undefined, + refCount: 0, + state: defaultState(), + listeners: new Set(), + pendingRelease: null, + }; + this.#connections.set(key, managed); + return managed; + } + + #notify(managed: ManagedConnection): void { + for (const listener of managed.listeners) { + listener(); + } + } + + retain(key: string, builder: MockBuilder): MockConnection { + const managed = this.#ensureEntry(key); + if (managed.pendingRelease) { + clearTimeout(managed.pendingRelease); + managed.pendingRelease = null; + } + managed.refCount += 1; + if (managed.connection) { + return managed.connection; + } + + const connection = builder.build(); + managed.connection = connection; + + const updateState = (updates: Partial) => { + managed.state = { ...managed.state, ...updates }; + this.#notify(managed); + }; + + updateState({ + isActive: connection.isActive, + identity: connection.identity, + token: connection.token, + connectionId: connection.connectionId, + connectionError: undefined, + }); + + managed.onConnect = conn => { + updateState({ + isActive: conn.isActive, + identity: conn.identity, + token: conn.token, + connectionId: conn.connectionId, + connectionError: undefined, + }); + }; + + managed.onDisconnect = (ctx, error) => { + updateState({ + isActive: ctx.isActive, + connectionError: error ?? undefined, + }); + }; + + managed.onConnectError = (ctx, error) => { + updateState({ + isActive: ctx.isActive, + connectionError: error, + }); + }; + + builder.onConnect(managed.onConnect); + builder.onDisconnect(managed.onDisconnect); + builder.onConnectError(managed.onConnectError); + + return connection; + } + + release(key: string): void { + const managed = this.#connections.get(key); + if (!managed) { + return; + } + + managed.refCount -= 1; + if (managed.refCount > 0 || managed.pendingRelease) { + return; + } + + managed.pendingRelease = setTimeout(() => { + managed.pendingRelease = null; + if (managed.refCount > 0) { + return; + } + if (managed.connection) { + if (managed.onConnect) { + managed.connection.removeOnConnect(managed.onConnect); + } + if (managed.onDisconnect) { + managed.connection.removeOnDisconnect(managed.onDisconnect); + } + if (managed.onConnectError) { + managed.connection.removeOnConnectError(managed.onConnectError); + } + managed.connection.disconnect(); + } + this.#connections.delete(key); + }, 0); + } + + subscribe(key: string, listener: Listener): () => void { + const managed = this.#ensureEntry(key); + managed.listeners.add(listener); + return () => { + managed.listeners.delete(listener); + if ( + managed.refCount <= 0 && + managed.listeners.size === 0 && + !managed.connection + ) { + this.#connections.delete(key); + } + }; + } + + getSnapshot(key: string): ConnectionState | undefined { + return this.#connections.get(key)?.state; + } + + getConnection(key: string): MockConnection | null { + return this.#connections.get(key)?.connection ?? null; + } + + // Test helper to check internal state + _getRefCount(key: string): number { + return this.#connections.get(key)?.refCount ?? 0; + } + + _hasConnection(key: string): boolean { + return this.#connections.get(key)?.connection !== undefined; + } + + _hasEntry(key: string): boolean { + return this.#connections.has(key); + } + + _hasPendingRelease(key: string): boolean { + return this.#connections.get(key)?.pendingRelease !== null; + } +} + +describe('ConnectionManager', () => { + let manager: ConnectionManagerImpl; + + beforeEach(() => { + vi.useFakeTimers(); + manager = new ConnectionManagerImpl(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('getKey', () => { + test('generates consistent keys from uri and moduleName', () => { + const key1 = manager.getKey('ws://localhost:3000', 'myModule'); + const key2 = manager.getKey('ws://localhost:3000', 'myModule'); + expect(key1).toBe(key2); + expect(key1).toBe('ws://localhost:3000::myModule'); + }); + + test('generates different keys for different uris', () => { + const key1 = manager.getKey('ws://localhost:3000', 'myModule'); + const key2 = manager.getKey('ws://localhost:4000', 'myModule'); + expect(key1).not.toBe(key2); + }); + + test('generates different keys for different modules', () => { + const key1 = manager.getKey('ws://localhost:3000', 'moduleA'); + const key2 = manager.getKey('ws://localhost:3000', 'moduleB'); + expect(key1).not.toBe(key2); + }); + + test('static getKey matches instance method', () => { + const uri = 'ws://localhost:3000'; + const moduleName = 'myModule'; + expect(ConnectionManagerImpl.getKey(uri, moduleName)).toBe( + manager.getKey(uri, moduleName) + ); + }); + }); + + describe('retain', () => { + test('creates connection on first retain', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + const connection = manager.retain(key, builder); + + expect(connection).toBe(mockConnection); + expect(manager._getRefCount(key)).toBe(1); + }); + + test('returns same connection on subsequent retains', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + const connection1 = manager.retain(key, builder); + const connection2 = manager.retain(key, builder); + + expect(connection1).toBe(connection2); + expect(manager._getRefCount(key)).toBe(2); + }); + + test('increments refCount on each retain', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + expect(manager._getRefCount(key)).toBe(1); + + manager.retain(key, builder); + expect(manager._getRefCount(key)).toBe(2); + + manager.retain(key, builder); + expect(manager._getRefCount(key)).toBe(3); + }); + + test('cancels pending release when retaining again', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + manager.release(key); + + expect(manager._hasPendingRelease(key)).toBe(true); + + manager.retain(key, builder); + + expect(manager._hasPendingRelease(key)).toBe(false); + expect(manager._getRefCount(key)).toBe(1); + expect(mockConnection.disconnected).toBe(false); + }); + }); + + describe('release', () => { + test('decrements refCount on release', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + manager.retain(key, builder); + expect(manager._getRefCount(key)).toBe(2); + + manager.release(key); + expect(manager._getRefCount(key)).toBe(1); + }); + + test('schedules cleanup when refCount reaches zero', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + manager.release(key); + + expect(manager._hasPendingRelease(key)).toBe(true); + expect(mockConnection.disconnected).toBe(false); + }); + + test('disconnects after timeout when refCount is zero', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + manager.release(key); + + vi.runAllTimers(); + + expect(mockConnection.disconnected).toBe(true); + expect(manager._hasConnection(key)).toBe(false); + }); + + test('does not disconnect if re-retained before timeout', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + manager.release(key); + manager.retain(key, builder); + + vi.runAllTimers(); + + expect(mockConnection.disconnected).toBe(false); + expect(manager._hasConnection(key)).toBe(true); + }); + + test('does nothing when releasing unknown key', () => { + expect(() => manager.release('unknown-key')).not.toThrow(); + }); + + test('does not schedule multiple cleanups', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + manager.retain(key, builder); + manager.release(key); + manager.release(key); + + // Should only have one pending release + expect(manager._hasPendingRelease(key)).toBe(true); + + vi.runAllTimers(); + + expect(mockConnection.disconnected).toBe(true); + }); + }); + + describe('React StrictMode simulation', () => { + test('survives mount → unmount → remount cycle', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + // Mount: retain + manager.retain(key, builder); + expect(manager._getRefCount(key)).toBe(1); + + // Unmount: release (schedules cleanup) + manager.release(key); + expect(manager._getRefCount(key)).toBe(0); + expect(manager._hasPendingRelease(key)).toBe(true); + + // Remount: retain again (cancels cleanup) + manager.retain(key, builder); + expect(manager._getRefCount(key)).toBe(1); + expect(manager._hasPendingRelease(key)).toBe(false); + + // Let any timers run + vi.runAllTimers(); + + // Connection should still be active + expect(mockConnection.disconnected).toBe(false); + expect(manager.getConnection(key)).toBe(mockConnection); + }); + + test('maintains single connection across multiple StrictMode cycles', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + // First cycle + manager.retain(key, builder); + manager.release(key); + manager.retain(key, builder); + + // Second cycle (nested component) + manager.retain(key, builder); + manager.release(key); + manager.retain(key, builder); + + vi.runAllTimers(); + + expect(mockConnection.disconnected).toBe(false); + expect(manager._getRefCount(key)).toBe(2); + }); + }); + + describe('subscribe', () => { + test('adds listener and returns unsubscribe function', () => { + const key = 'test-key'; + const listener = vi.fn(); + + const unsubscribe = manager.subscribe(key, listener); + + expect(typeof unsubscribe).toBe('function'); + }); + + test('notifies listeners on state change', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + const listener = vi.fn(); + + manager.subscribe(key, listener); + manager.retain(key, builder); + + // Initial state update during retain + expect(listener).toHaveBeenCalled(); + }); + + test('notifies listeners when connection state changes', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + const listener = vi.fn(); + + manager.subscribe(key, listener); + manager.retain(key, builder); + listener.mockClear(); + + const identity = testIdentity; + mockConnection.simulateConnect(identity, 'test-token'); + + expect(listener).toHaveBeenCalled(); + }); + + test('stops notifying after unsubscribe', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + const listener = vi.fn(); + + const unsubscribe = manager.subscribe(key, listener); + manager.retain(key, builder); + listener.mockClear(); + + unsubscribe(); + + const identity = testIdentity; + mockConnection.simulateConnect(identity, 'test-token'); + + expect(listener).not.toHaveBeenCalled(); + }); + + test('cleans up entry when no listeners and no connection', () => { + const key = 'test-key'; + const listener = vi.fn(); + + const unsubscribe = manager.subscribe(key, listener); + expect(manager._hasConnection(key)).toBe(false); + + unsubscribe(); + + // Entry should be cleaned up since there's no connection and no listeners + expect(manager.getSnapshot(key)).toBeUndefined(); + }); + + test('does not clean up entry when connection exists', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + const listener = vi.fn(); + + const unsubscribe = manager.subscribe(key, listener); + manager.retain(key, builder); + unsubscribe(); + + // Entry should still exist because connection is active + expect(manager.getSnapshot(key)).toBeDefined(); + }); + }); + + describe('getSnapshot', () => { + test('returns undefined for unknown key', () => { + expect(manager.getSnapshot('unknown-key')).toBeUndefined(); + }); + + test('returns state after retain', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + const snapshot = manager.getSnapshot(key); + + expect(snapshot).toBeDefined(); + expect(snapshot?.isActive).toBe(false); + }); + + test('reflects connection state changes', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + + const identity = testIdentity; + mockConnection.simulateConnect(identity, 'test-token'); + + const snapshot = manager.getSnapshot(key); + expect(snapshot?.isActive).toBe(true); + expect(snapshot?.identity).toBe(identity); + expect(snapshot?.token).toBe('test-token'); + }); + + test('reflects disconnect state', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + const identity = testIdentity; + mockConnection.simulateConnect(identity, 'test-token'); + mockConnection.simulateDisconnect(); + + const snapshot = manager.getSnapshot(key); + expect(snapshot?.isActive).toBe(false); + }); + + test('reflects connection error', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + const error = new Error('Connection failed'); + mockConnection.simulateConnectError(error); + + const snapshot = manager.getSnapshot(key); + expect(snapshot?.isActive).toBe(false); + expect(snapshot?.connectionError).toBe(error); + }); + }); + + describe('getConnection', () => { + test('returns null for unknown key', () => { + expect(manager.getConnection('unknown-key')).toBeNull(); + }); + + test('returns connection after retain', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + + expect(manager.getConnection(key)).toBe(mockConnection); + }); + + test('returns null after cleanup', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + manager.release(key); + vi.runAllTimers(); + + expect(manager.getConnection(key)).toBeNull(); + }); + }); + + describe('multiple connections', () => { + test('manages multiple independent connections', () => { + const connection1 = new MockConnection(); + const connection2 = new MockConnection(); + const builder1 = new MockBuilder(connection1); + const builder2 = new MockBuilder(connection2); + const key1 = 'connection-1'; + const key2 = 'connection-2'; + + manager.retain(key1, builder1); + manager.retain(key2, builder2); + + expect(manager.getConnection(key1)).toBe(connection1); + expect(manager.getConnection(key2)).toBe(connection2); + expect(manager._getRefCount(key1)).toBe(1); + expect(manager._getRefCount(key2)).toBe(1); + }); + + test('releases connections independently', () => { + const connection1 = new MockConnection(); + const connection2 = new MockConnection(); + const builder1 = new MockBuilder(connection1); + const builder2 = new MockBuilder(connection2); + const key1 = 'connection-1'; + const key2 = 'connection-2'; + + manager.retain(key1, builder1); + manager.retain(key2, builder2); + manager.release(key1); + vi.runAllTimers(); + + expect(connection1.disconnected).toBe(true); + expect(connection2.disconnected).toBe(false); + expect(manager.getConnection(key1)).toBeNull(); + expect(manager.getConnection(key2)).toBe(connection2); + }); + }); + + describe('callback cleanup', () => { + test('removes callbacks on disconnect', () => { + const mockConnection = new MockConnection(); + const builder = new MockBuilder(mockConnection); + const key = 'test-key'; + + manager.retain(key, builder); + manager.release(key); + vi.runAllTimers(); + + // After cleanup, simulating events should not cause issues + // (callbacks were removed) + const identity = testIdentity; + expect(() => { + mockConnection.simulateConnect(identity, 'test-token'); + }).not.toThrow(); + }); + }); +}); diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md index d2f0b254bf6..4f7b0178250 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md +++ b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md @@ -24,6 +24,7 @@ Before diving into the reference, you may want to review: | [`ErrorContext` type](#type-errorcontext) | [`DbContext`](#interface-dbcontext) available in error-related callbacks. | | [Access the client cache](#access-the-client-cache) | Make local queries against subscribed rows, and register [row callbacks](#callback-oninsert) to run when subscribed rows change. | | [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | +| [React Integration](#react-integration) | React hooks and components for SpacetimeDB (`spacetimedb/react`). | | [Identify a client](#identify-a-client) | Types for identifying users and client connections. | ## Project setup @@ -877,6 +878,118 @@ Each reducer defined by the module has three methods on the `.reducers`: - A callback registation method, whose name is prefixed with `on`, like `onSetName`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. - A callback remove method, whose name is prefixed with `removeOn`, like `removeOnSetName`. This cancels a callback previously registered via the callback registration method. +## React Integration + +The SpacetimeDB TypeScript SDK includes React bindings under the `spacetimedb/react` subpath. These bindings provide a `SpacetimeDBProvider` component and hooks for easily integrating SpacetimeDB into React applications. + +The React integration is fully compatible with React StrictMode and correctly handles the double-mount behavior (only one WebSocket connection is created). + +| Name | Description | +| ----------------------------------------------------------- | --------------------------------------------------------- | +| [`SpacetimeDBProvider` component](#component-spacetimedbprovider) | Context provider that manages the database connection. | +| [`useSpacetimeDB` hook](#hook-usespacetimedb) | Access the connection and connection state. | +| [`useTable` hook](#hook-usetable) | Subscribe to table data with automatic re-renders. | + +### Component `SpacetimeDBProvider` + +```tsx +import { SpacetimeDBProvider } from 'spacetimedb/react'; +``` + +Wrap your application with `SpacetimeDBProvider` to provide connection context to child components. Pass a configured `DbConnectionBuilder` (without calling `.build()`). + +```tsx +import { DbConnection } from './module_bindings'; +import { SpacetimeDBProvider } from 'spacetimedb/react'; + +const connectionBuilder = DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('my-module') + .onConnect((conn, identity, token) => { + console.log('Connected:', identity.toHexString()); + conn.subscriptionBuilder().subscribe('SELECT * FROM player'); + }) + .onDisconnect(() => console.log('Disconnected')); + +function App() { + return ( + + + + ); +} +``` + +### Hook `useSpacetimeDB` + +```tsx +import { useSpacetimeDB } from 'spacetimedb/react'; + +function useSpacetimeDB(): { + isActive: boolean; + identity?: Identity; + token?: string; + connectionId: ConnectionId; + connectionError?: Error; + getConnection(): DbConnection | null; +}; +``` + +Returns the current connection state and a function to access the connection. The hook re-renders the component when the connection state changes. + +```tsx +function MyComponent() { + const { isActive, identity, getConnection } = useSpacetimeDB(); + const conn = getConnection(); + + if (!isActive) { + return
Connecting...
; + } + + return ( +
+

Connected as: {identity?.toHexString()}

+ +
+ ); +} +``` + +### Hook `useTable` + +```tsx +import { useTable } from 'spacetimedb/react'; + +function useTable(tableName: string): { + rows: Row[]; + loading: boolean; +}; +``` + +Subscribe to a table and receive automatic re-renders when rows change. Returns the current rows and a loading state. + +```tsx +import { Player } from './module_bindings'; + +function PlayerList() { + const { rows: players, loading } = useTable('player'); + + if (loading) { + return
Loading players...
; + } + + return ( +
    + {players.map(player => ( +
  • {player.name}
  • + ))} +
+ ); +} +``` + ## Identify a client ### Type `Identity`