From 5079fc5c0752d8785bcca8e95dcd2f219d0ab866 Mon Sep 17 00:00:00 2001 From: Ludv1g Date: Thu, 23 Apr 2026 23:35:49 +0200 Subject: [PATCH 1/3] feat(bindings-typescript): multi-module React provider + ConnectionManager.rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a first-class multi-module story for React clients. Real-world apps that connect to more than one SpacetimeDB module per session (game + chat, core + telemetry, org + multiplayer, etc.) previously had to stack nested s and fight useSpacetimeDB()'s closest-provider lookup, or re-implement ConnectionManager locally because it wasn't exported. Changes: * New : mounts N connections under a single provider. Connections are ref-counted by the shared ConnectionManager pool keyed on (uri, moduleName), so providers that refer to the same module share a single WebSocket even across sibling trees. * useSpacetimeDB, useTable, and useReducer accept an optional leading key argument. useSpacetimeDB('launcher') / useTable('admin', tables.user) / useReducer('mp', reducers.sendMessage) read from the nearest SpacetimeDBMultiProvider entry labelled `key`. Zero-arg usage is unchanged — it still reads from the nearest . * useSpacetimeDBStatus() returns a live Map aggregating every connection in the nearest SpacetimeDBMultiProvider. Useful for connection-health panels. * ConnectionManager.rebuild(key, builder) tears down the existing connection and installs a fresh one under the same refcount + listener set. Closes the gap that retain() ignores the builder on subsequent calls — which blocks token-refresh reconnects. Symmetric with retain()/release(); cancels any pending release. Returns null for unretained keys. * ConnectionManager is now re-exported from spacetimedb/sdk (and thus the root), alongside `ManagedConnectionState`. Previously internal. Behavior: * Backwards compatible. Existing single- apps continue to work unchanged — the hooks' no-arg forms hit the original context path. * Rules-of-Hooks compliant: both contexts are always read via useContext, branching happens after. * StrictMode-safe: MultiProvider retain/release cycles use the pool's setTimeout(0) deferred cleanup. Tests: 8 new rebuild() cases covering tear-down, refcount preservation, listener preservation, stale-callback cleanup, pending-release cancellation, and fresh-connection event routing. All 186 SDK tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/react/SpacetimeDBMultiProvider.ts | 162 ++++++++++++++++++ crates/bindings-typescript/src/react/index.ts | 7 + .../src/react/useReducer.ts | 37 +++- .../src/react/useSpacetimeDB.ts | 42 ++++- .../src/react/useSpacetimeDBStatus.ts | 22 +++ .../bindings-typescript/src/react/useTable.ts | 69 +++++--- .../src/sdk/connection_manager.ts | 74 ++++++-- crates/bindings-typescript/src/sdk/index.ts | 4 + .../tests/connection_manager.test.ts | 158 +++++++++++++++-- 9 files changed, 516 insertions(+), 59 deletions(-) create mode 100644 crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts create mode 100644 crates/bindings-typescript/src/react/useSpacetimeDBStatus.ts diff --git a/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts b/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts new file mode 100644 index 00000000000..d0fd0383a8b --- /dev/null +++ b/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts @@ -0,0 +1,162 @@ +import { + useEffect, + useMemo, + useSyncExternalStore, + createContext, + useRef, + useCallback, +} from 'react'; +import * as React from 'react'; +import { + DbConnectionBuilder, + type DbConnectionImpl, +} from '../sdk/db_connection_impl'; +import { + ConnectionManager, + type ConnectionState as ManagedConnectionState, +} from '../sdk/connection_manager'; +import { ConnectionId } from '../lib/connection_id'; +import type { ConnectionState } from './connection_state'; + +/** + * Per-module connection map keyed by the application-chosen label. Each entry + * has the same shape as `useSpacetimeDB()`'s return value so hooks can accept + * a key argument transparently. + */ +export type ManagedConnectionStateMap = Map; + +export const SpacetimeDBMultiContext = createContext< + ManagedConnectionStateMap | undefined +>(undefined); + +export interface SpacetimeDBMultiProviderProps { + /** + * Map of application-chosen label → connection builder. One retained + * connection per label. Labels drive the optional `key` argument on + * `useSpacetimeDB(key)`, `useTable(key, ...)`, `useReducer(key, ...)`. + * + * The same underlying pool keys by `(uri, moduleName)` regardless of + * label, so two `SpacetimeDBMultiProvider`s that refer to the same + * `(uri, moduleName)` share a single WebSocket. + */ + connections: Record>>; + children?: React.ReactNode; +} + +type Entry = { + label: string; + builder: DbConnectionBuilder>; + poolKey: string; +}; + +const FALLBACK_STATE: ManagedConnectionState = { + isActive: false, + identity: undefined, + token: undefined, + connectionId: ConnectionId.random(), + connectionError: undefined, +}; + +/** + * Mounts multiple SpacetimeDB connections under a single provider, one per + * application-chosen label. Components inside the tree can read any module by + * label via the `key` argument on `useSpacetimeDB`, `useTable`, and + * `useReducer`. + * + * Connections are ref-counted by the shared ConnectionManager pool. Two + * providers that reference the same `(uri, moduleName)` share a single + * WebSocket; unmounting one provider releases its retain without tearing the + * socket while the other is alive. + * + * StrictMode-safe: cleanup defers through the pool's setTimeout(0). + */ +export function SpacetimeDBMultiProvider({ + connections, + children, +}: SpacetimeDBMultiProviderProps): React.JSX.Element { + // Stable entry list — label + builder + pool key. Rebuilt only when the + // `connections` record identity changes. Order is stable. + const entries = useMemo(() => { + return Object.entries(connections).map(([label, builder]) => ({ + label, + builder, + poolKey: ConnectionManager.getKey( + builder.getUri(), + builder.getModuleName() + ), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connections]); + + // Retain every entry for the lifetime of this provider. + useEffect(() => { + for (const { poolKey, builder } of entries) { + ConnectionManager.retain(poolKey, builder); + } + return () => { + for (const { poolKey } of entries) { + ConnectionManager.release(poolKey); + } + }; + }, [entries]); + + // Subscribe to the union of entries' state so we re-render on any change. + const subscribe = useCallback( + (onChange: () => void) => { + const unsubs = entries.map(({ poolKey }) => + ConnectionManager.subscribe(poolKey, onChange) + ); + return () => { + for (const u of unsubs) u(); + }; + }, + [entries] + ); + + // Snapshot: per-entry state object. We cache by a synthesized version number + // so useSyncExternalStore doesn't tear — the Map reference changes only when + // at least one underlying state ref changes. + const snapshotRef = useRef<{ + states: ManagedConnectionState[]; + map: ManagedConnectionStateMap; + } | null>(null); + + const getSnapshot = useCallback((): ManagedConnectionStateMap => { + const states = entries.map( + ({ poolKey }) => ConnectionManager.getSnapshot(poolKey) ?? FALLBACK_STATE + ); + + // Return the cached map if every state is reference-equal to the last + // read. This is what keeps `useSyncExternalStore` stable across renders + // that don't actually change pool state. + if ( + snapshotRef.current && + snapshotRef.current.states.length === states.length && + snapshotRef.current.states.every((s, i) => s === states[i]) + ) { + return snapshotRef.current.map; + } + + const map: ManagedConnectionStateMap = new Map(); + for (let i = 0; i < entries.length; i++) { + const { label, poolKey } = entries[i]; + const state = states[i]; + map.set(label, { + ...state, + getConnection: () => + ConnectionManager.getConnection>(poolKey), + }); + } + + snapshotRef.current = { states, map }; + return map; + }, [entries]); + + const statusMap = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + return React.createElement( + SpacetimeDBMultiContext.Provider, + { value: statusMap }, + children + ); +} diff --git a/crates/bindings-typescript/src/react/index.ts b/crates/bindings-typescript/src/react/index.ts index 3b3e64aba48..508da8f5d72 100644 --- a/crates/bindings-typescript/src/react/index.ts +++ b/crates/bindings-typescript/src/react/index.ts @@ -1,5 +1,12 @@ export * from './SpacetimeDBProvider.ts'; +export { + SpacetimeDBMultiProvider, + SpacetimeDBMultiContext, + type SpacetimeDBMultiProviderProps, + type ManagedConnectionStateMap, +} from './SpacetimeDBMultiProvider.ts'; export { useSpacetimeDB } from './useSpacetimeDB.ts'; +export { useSpacetimeDBStatus } from './useSpacetimeDBStatus.ts'; export { useTable } from './useTable.ts'; export { useReducer } from './useReducer.ts'; export { useProcedure } from './useProcedure.ts'; diff --git a/crates/bindings-typescript/src/react/useReducer.ts b/crates/bindings-typescript/src/react/useReducer.ts index 8e0ab3177c2..53eae53ffee 100644 --- a/crates/bindings-typescript/src/react/useReducer.ts +++ b/crates/bindings-typescript/src/react/useReducer.ts @@ -2,14 +2,46 @@ import { useCallback, useEffect, useRef } from 'react'; import type { UntypedReducerDef } from '../sdk/reducers'; import { useSpacetimeDB } from './useSpacetimeDB'; import type { ParamsType } from '../sdk'; +import type { ConnectionState } from './connection_state'; +/** + * React hook returning a reducer-call function. + * + * - `useReducer(reducerDef)` — reads from the nearest ``. + * - `useReducer(key, reducerDef)` — reads the connection labelled `key` from + * the nearest ``. + * + * Returns `(...args) => Promise`. Calls made before the connection is + * live are queued and flushed on `onApplied`. + */ export function useReducer( reducerDef: ReducerDef +): (...params: ParamsType) => Promise; +export function useReducer( + key: string, + reducerDef: ReducerDef +): (...params: ParamsType) => Promise; +export function useReducer( + keyOrDef: string | ReducerDef, + maybeDef?: ReducerDef +): (...params: ParamsType) => Promise { + const keyed = typeof keyOrDef === 'string'; + const key: string | undefined = keyed ? (keyOrDef as string) : undefined; + const reducerDef: ReducerDef = keyed + ? (maybeDef as ReducerDef) + : (keyOrDef as ReducerDef); + + const connectionState: ConnectionState = useSpacetimeDB(key); + return useReducerInternal(connectionState, reducerDef); +} + +function useReducerInternal( + connectionState: ConnectionState, + reducerDef: ReducerDef ): (...params: ParamsType) => Promise { - const { getConnection, isActive } = useSpacetimeDB(); + const { getConnection, isActive } = connectionState; const reducerName = reducerDef.accessorName; - // Holds calls made before the connection exists const queueRef = useRef< { params: ParamsType; @@ -18,7 +50,6 @@ export function useReducer( }[] >([]); - // Flush when we finally have a connection useEffect(() => { const conn = getConnection(); if (!conn) { diff --git a/crates/bindings-typescript/src/react/useSpacetimeDB.ts b/crates/bindings-typescript/src/react/useSpacetimeDB.ts index 2a7cbfc828a..9e8cb59d0c5 100644 --- a/crates/bindings-typescript/src/react/useSpacetimeDB.ts +++ b/crates/bindings-typescript/src/react/useSpacetimeDB.ts @@ -1,18 +1,48 @@ import { createContext, useContext } from 'react'; import type { ConnectionState } from './connection_state'; +import { SpacetimeDBMultiContext } from './SpacetimeDBMultiProvider'; export const SpacetimeDBContext = createContext( undefined ); -// Throws an error if used outside of a SpacetimeDBProvider -// Error is caught by other hooks like useTable so they can provide better error messages -export function useSpacetimeDB(): ConnectionState { - const context = useContext(SpacetimeDBContext) as ConnectionState | undefined; - if (!context) { +/** + * Read the live SpacetimeDB connection state. + * + * - `useSpacetimeDB()` — reads the nearest ``. Throws if + * there is none. + * - `useSpacetimeDB(key)` — reads the entry labelled `key` from the nearest + * ``. Throws if there is no multi-provider, or + * if `key` is not registered. + */ +export function useSpacetimeDB(key?: string): ConnectionState { + // Call both context hooks unconditionally so hook order is stable across + // keyed / un-keyed usage. + const singleContext = useContext(SpacetimeDBContext) as + | ConnectionState + | undefined; + const multiContext = useContext(SpacetimeDBMultiContext); + + if (key !== undefined) { + if (!multiContext) { + throw new Error( + `useSpacetimeDB('${key}') must be used within a SpacetimeDBMultiProvider component. Did you forget to add a \`SpacetimeDBMultiProvider\` to your component tree?` + ); + } + const state = multiContext.get(key); + if (!state) { + const known = Array.from(multiContext.keys()).join(', ') || '(none)'; + throw new Error( + `useSpacetimeDB('${key}'): no connection registered under that key. Known keys: ${known}.` + ); + } + return state; + } + + if (!singleContext) { throw new Error( 'useSpacetimeDB must be used within a SpacetimeDBProvider component. Did you forget to add a `SpacetimeDBProvider` to your component tree?' ); } - return context; + return singleContext; } diff --git a/crates/bindings-typescript/src/react/useSpacetimeDBStatus.ts b/crates/bindings-typescript/src/react/useSpacetimeDBStatus.ts new file mode 100644 index 00000000000..dd2d42e0b7a --- /dev/null +++ b/crates/bindings-typescript/src/react/useSpacetimeDBStatus.ts @@ -0,0 +1,22 @@ +import { useContext } from 'react'; +import { + SpacetimeDBMultiContext, + type ManagedConnectionStateMap, +} from './SpacetimeDBMultiProvider'; + +/** + * Read the live state of every connection registered in the nearest + * ``. Useful for connection-health UI. + * + * Returns a live `Map`. Throws if there is no + * multi-provider in the component tree. + */ +export function useSpacetimeDBStatus(): ManagedConnectionStateMap { + const map = useContext(SpacetimeDBMultiContext); + if (!map) { + throw new Error( + 'useSpacetimeDBStatus must be used within a SpacetimeDBMultiProvider component. Did you forget to add a `SpacetimeDBMultiProvider` to your component tree?' + ); + } + return map; +} diff --git a/crates/bindings-typescript/src/react/useTable.ts b/crates/bindings-typescript/src/react/useTable.ts index 6eb5e0cf209..80fc80755df 100644 --- a/crates/bindings-typescript/src/react/useTable.ts +++ b/crates/bindings-typescript/src/react/useTable.ts @@ -45,42 +45,67 @@ function classifyMembership( /** * React hook to subscribe to a table in SpacetimeDB and receive live updates. * - * Accepts a query builder expression as the first argument: - * - `tables.user` — subscribe to all rows - * - `tables.user.where(r => r.online.eq(true))` — subscribe with a filter + * - `useTable(query, callbacks?)` — reads from the nearest ``. + * - `useTable(key, query, callbacks?)` — reads the connection labelled `key` + * from the nearest ``. + * + * The query argument accepts either a table reference (`tables.user`) or a + * filtered query (`tables.user.where(r => r.online.eq(true))`). * - * @param query - A query builder expression (table reference or filtered query). - * @param callbacks - Optional callbacks for row insert, delete, and update events. * @returns A tuple of [rows, isReady]. * * @example * ```tsx + * // Single-module app * const [rows, isReady] = useTable(tables.user); - * const [onlineUsers, isReady] = useTable( - * tables.user.where(r => r.online.eq(true)), - * { onInsert: (row) => console.log('New user:', row) } - * ); + * + * // Multi-module app + * const [apps] = useTable('launcher', tables.app); + * const [users] = useTable('admin', tables.user); * ``` */ export function useTable( query: Query, callbacks?: UseTableCallbacks>> +): [readonly Prettify>[], boolean]; +export function useTable( + key: string, + query: Query, + callbacks?: UseTableCallbacks>> +): [readonly Prettify>[], boolean]; +export function useTable( + queryOrKey: Query | string, + queryOrCallbacks?: + | Query + | UseTableCallbacks>>, + maybeCallbacks?: UseTableCallbacks>> +): [readonly Prettify>[], boolean] { + const keyed = typeof queryOrKey === 'string'; + const key: string | undefined = keyed ? (queryOrKey as string) : undefined; + const query: Query = keyed + ? (queryOrCallbacks as Query) + : (queryOrKey as Query); + const callbacks: UseTableCallbacks>> | undefined = + keyed + ? maybeCallbacks + : (queryOrCallbacks as + | UseTableCallbacks>> + | undefined); + + const connectionState: ConnectionState = useSpacetimeDB(key); + return useTableInternal(connectionState, query, callbacks); +} + +function useTableInternal( + connectionState: ConnectionState, + query: Query, + callbacks?: UseTableCallbacks>> ): [readonly Prettify>[], boolean] { type UseTableRowType = RowType; const accessorName = getQueryAccessorName(query); const whereExpr = getQueryWhereClause(query); const [subscribeApplied, setSubscribeApplied] = useState(false); - let connectionState: ConnectionState | undefined; - try { - connectionState = useSpacetimeDB(); - } catch { - throw new Error( - 'Could not find SpacetimeDB client! Did you forget to add a ' + - '`SpacetimeDBProvider`? `useTable` must be used in the React component tree ' + - 'under a `SpacetimeDBProvider` component.' - ); - } const querySql = toSql(query); @@ -109,9 +134,6 @@ export function useTable( // eslint-disable-next-line react-hooks/exhaustive-deps }, [connectionState, accessorName, querySql, subscribeApplied]); - // Invalidate the cached snapshot when computeSnapshot changes (e.g. when - // subscribeApplied flips to true) so getSnapshot() recomputes on the next - // render instead of returning a stale [rows, false] tuple. useEffect(() => { lastSnapshotRef.current = null; }, [computeSnapshot]); @@ -181,7 +203,7 @@ export function useTable( callbacks?.onUpdate?.(oldRow, newRow); break; case 'stayOut': - return; // no-op + return; } if (ctx.event.id !== latestTransactionEventId.current) { @@ -231,6 +253,5 @@ export function useTable( return lastSnapshotRef.current; }, [computeSnapshot]); - // SSR fallback can be the same getter return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 7f43c274104..34762d64671 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -128,7 +128,44 @@ class ConnectionManagerImpl { if (managed.connection) { return managed.connection as T; } + return this.#install(managed, builder); + } + + /** + * Tears down the current connection and re-installs one using a fresh + * builder, preserving the ref count and listener set. + * + * This exists because `retain()` ignores the builder once a connection is + * live — which is usually what you want for React ref-counting, but blocks + * "reconnect with a fresh token" flows. Call `rebuild()` from application + * reconnect logic (e.g. after a token refresh or auth change). + * + * Returns `null` if the key has no retained entry; otherwise returns the + * newly-installed connection. + * + * @param key - Unique identifier for the connection (use getKey to generate) + * @param builder - Fresh connection builder; its handlers will be rewired + */ + rebuild>( + key: string, + builder: DbConnectionBuilder + ): T | null { + const managed = this.#connections.get(key); + if (!managed || managed.refCount <= 0) { + return null; + } + if (managed.pendingRelease) { + clearTimeout(managed.pendingRelease); + managed.pendingRelease = null; + } + this.#teardown(managed); + return this.#install(managed, builder); + } + #install>( + managed: ManagedConnection, + builder: DbConnectionBuilder + ): T { const connection = builder.build(); managed.connection = connection; @@ -176,6 +213,28 @@ class ConnectionManagerImpl { return connection as T; } + #teardown(managed: ManagedConnection): void { + if (!managed.connection) return; + 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); + } + try { + managed.connection.disconnect(); + } catch { + // disconnect on a dead socket is a no-op we can swallow + } + managed.connection = undefined; + managed.onConnect = undefined; + managed.onDisconnect = undefined; + managed.onConnectError = undefined; + } + release(key: string): void { const managed = this.#connections.get(key); if (!managed) { @@ -192,20 +251,7 @@ class ConnectionManagerImpl { 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.#teardown(managed); this.#connections.delete(key); }, 0); } diff --git a/crates/bindings-typescript/src/sdk/index.ts b/crates/bindings-typescript/src/sdk/index.ts index 3afeba97a17..e4bda857bfd 100644 --- a/crates/bindings-typescript/src/sdk/index.ts +++ b/crates/bindings-typescript/src/sdk/index.ts @@ -12,3 +12,7 @@ export { table } from '../lib/table.ts'; export { reducerSchema, reducers } from './reducers.ts'; export { procedureSchema, procedures } from './procedures.ts'; export * from './type_utils.ts'; +export { + ConnectionManager, + type ConnectionState as ManagedConnectionState, +} from './connection_manager.ts'; diff --git a/crates/bindings-typescript/tests/connection_manager.test.ts b/crates/bindings-typescript/tests/connection_manager.test.ts index ebd49a8e2fa..57d71fe291c 100644 --- a/crates/bindings-typescript/tests/connection_manager.test.ts +++ b/crates/bindings-typescript/tests/connection_manager.test.ts @@ -202,7 +202,23 @@ class ConnectionManagerImpl { if (managed.connection) { return managed.connection; } + return this.#install(managed, builder); + } + + rebuild(key: string, builder: MockBuilder): MockConnection | null { + const managed = this.#connections.get(key); + if (!managed || managed.refCount <= 0) { + return null; + } + if (managed.pendingRelease) { + clearTimeout(managed.pendingRelease); + managed.pendingRelease = null; + } + this.#teardown(managed); + return this.#install(managed, builder); + } + #install(managed: ManagedConnection, builder: MockBuilder): MockConnection { const connection = builder.build(); managed.connection = connection; @@ -250,6 +266,24 @@ class ConnectionManagerImpl { return connection; } + #teardown(managed: ManagedConnection): void { + if (!managed.connection) return; + 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(); + managed.connection = undefined; + managed.onConnect = undefined; + managed.onDisconnect = undefined; + managed.onConnectError = undefined; + } + release(key: string): void { const managed = this.#connections.get(key); if (!managed) { @@ -266,18 +300,7 @@ class ConnectionManagerImpl { 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.#teardown(managed); this.#connections.delete(key); }, 0); } @@ -780,4 +803,115 @@ describe('ConnectionManager', () => { }).not.toThrow(); }); }); + + describe('rebuild', () => { + test('returns null for unknown key', () => { + const builder = new MockBuilder(new MockConnection()); + expect(manager.rebuild('unknown-key', builder)).toBeNull(); + }); + + test('returns null when key has no retain', () => { + const builder = new MockBuilder(new MockConnection()); + // Subscribe but don't retain — rebuild should refuse. + manager.subscribe('some-key', () => {}); + expect(manager.rebuild('some-key', builder)).toBeNull(); + }); + + test('tears down the old connection and installs a new one', () => { + const firstConn = new MockConnection(); + const secondConn = new MockConnection(); + const key = 'test-key'; + + manager.retain(key, new MockBuilder(firstConn)); + expect(manager.getConnection(key)).toBe(firstConn); + expect(firstConn.disconnected).toBe(false); + + const rebuilt = manager.rebuild(key, new MockBuilder(secondConn)); + expect(rebuilt).toBe(secondConn); + expect(manager.getConnection(key)).toBe(secondConn); + expect(firstConn.disconnected).toBe(true); + expect(secondConn.disconnected).toBe(false); + }); + + test('preserves refCount across rebuild', () => { + const firstConn = new MockConnection(); + const secondConn = new MockConnection(); + const key = 'test-key'; + + manager.retain(key, new MockBuilder(firstConn)); + manager.retain(key, new MockBuilder(firstConn)); + expect(manager._getRefCount(key)).toBe(2); + + manager.rebuild(key, new MockBuilder(secondConn)); + expect(manager._getRefCount(key)).toBe(2); + }); + + test('preserves listeners across rebuild and notifies them', () => { + const firstConn = new MockConnection(); + const secondConn = new MockConnection(); + const key = 'test-key'; + const listener = vi.fn(); + + manager.subscribe(key, listener); + manager.retain(key, new MockBuilder(firstConn)); + listener.mockClear(); + + manager.rebuild(key, new MockBuilder(secondConn)); + // Initial install re-emits state → at least one listener call. + expect(listener).toHaveBeenCalled(); + }); + + test('cancels pending release', () => { + const firstConn = new MockConnection(); + const secondConn = new MockConnection(); + const key = 'test-key'; + + manager.retain(key, new MockBuilder(firstConn)); + manager.release(key); + expect(manager._hasPendingRelease(key)).toBe(true); + + // Rebuild should cancel the release so the new connection stays alive. + manager.retain(key, new MockBuilder(firstConn)); + manager.rebuild(key, new MockBuilder(secondConn)); + expect(manager._hasPendingRelease(key)).toBe(false); + + vi.runAllTimers(); + expect(manager.getConnection(key)).toBe(secondConn); + expect(secondConn.disconnected).toBe(false); + }); + + test('removes old callbacks so stale events do not leak state', () => { + const firstConn = new MockConnection(); + const secondConn = new MockConnection(); + const key = 'test-key'; + const listener = vi.fn(); + + manager.subscribe(key, listener); + manager.retain(key, new MockBuilder(firstConn)); + manager.rebuild(key, new MockBuilder(secondConn)); + listener.mockClear(); + + // Firing an event on the OLD connection must not notify listeners. + firstConn.simulateConnect(testIdentity, 'stale-token'); + expect(listener).not.toHaveBeenCalled(); + expect(manager.getSnapshot(key)?.token).not.toBe('stale-token'); + }); + + test('new connection events reach listeners', () => { + const firstConn = new MockConnection(); + const secondConn = new MockConnection(); + const key = 'test-key'; + const listener = vi.fn(); + + manager.subscribe(key, listener); + manager.retain(key, new MockBuilder(firstConn)); + manager.rebuild(key, new MockBuilder(secondConn)); + listener.mockClear(); + + secondConn.simulateConnect(testIdentity, 'fresh-token'); + expect(listener).toHaveBeenCalled(); + expect(manager.getSnapshot(key)?.token).toBe('fresh-token'); + expect(manager.getSnapshot(key)?.isActive).toBe(true); + }); + }); }); From 597c3c9eb562338a8afe98d394a26104ac35edc8 Mon Sep 17 00:00:00 2001 From: Ludv1g Date: Thu, 23 Apr 2026 23:49:34 +0200 Subject: [PATCH 2/3] test(bindings-typescript): React layer tests + MultiProvider polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the multi-module provider commit. * Add @testing-library/react + happy-dom as dev deps; write 9 React-layer tests covering SpacetimeDBMultiProvider + the keyed `useSpacetimeDB` / `useSpacetimeDBStatus` hooks. Tests now back the prior claims about Rules-of-Hooks discipline, StrictMode safety, and keyed dispatch. Tests use unique URIs per test so the ConnectionManager singleton's pending- release state cannot bleed across cases. * Harden SpacetimeDBMultiProvider against inline `connections={{...}}` props. Every render previously produced a new object → useMemo recomputed → retain/release thrashed via the deferred cleanup. Now the provider compares entries by a (label, uri, moduleName) content signature and re-uses the last `entries` array identity when nothing changed. Also documents the "wrap in useMemo" guidance in JSDoc. * Fix FALLBACK_STATE's shared connectionId: previously all un-ready labels returned the same module-level ConnectionId.random(). Now each label holds its own cached fallback state so consumers comparing IDs across labels see distinct values. * Tighten the rebuild() listener-preservation test: assert exact call counts so a double-registration regression would fail (was only `toHaveBeenCalled()` before). All 195 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/bindings-typescript/package.json | 5 + .../src/react/SpacetimeDBMultiProvider.ts | 59 +++- .../tests/connection_manager.test.ts | 11 +- .../tests/multi_provider.test.tsx | 262 ++++++++++++++++++ crates/bindings-typescript/vitest.config.ts | 4 +- pnpm-lock.yaml | 166 +++++++---- 6 files changed, 441 insertions(+), 66 deletions(-) create mode 100644 crates/bindings-typescript/tests/multi_provider.test.tsx diff --git a/crates/bindings-typescript/package.json b/crates/bindings-typescript/package.json index 19ae19e3168..07ef022bd81 100644 --- a/crates/bindings-typescript/package.json +++ b/crates/bindings-typescript/package.json @@ -219,9 +219,11 @@ "@eslint/js": "^9.17.0", "@size-limit/file": "^11.2.0", "@tanstack/react-query": "^5.90.19", + "@testing-library/react": "^16.2.0", "@types/fast-text-encoding": "^1.0.3", "@types/object-inspect": "^1.13.0", "@types/react": "^19.1.13", + "@types/react-dom": "^19.2.3", "@types/statuses": "^2.0.6", "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", @@ -230,6 +232,9 @@ "eslint": "^9.33.0", "eslint-plugin-jsdoc": "^61.5.0", "globals": "^15.14.0", + "happy-dom": "^20.9.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", "size-limit": "^11.2.0", "svelte": "^5.0.0", "ts-node": "^10.9.2", diff --git a/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts b/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts index d0fd0383a8b..e9babcce6c7 100644 --- a/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts +++ b/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts @@ -38,6 +38,12 @@ export interface SpacetimeDBMultiProviderProps { * The same underlying pool keys by `(uri, moduleName)` regardless of * label, so two `SpacetimeDBMultiProvider`s that refer to the same * `(uri, moduleName)` share a single WebSocket. + * + * **Do not inline new builders on every render.** Each builder identity + * should be stable — build them outside the render or inside a `useMemo`. + * The provider compares entries by `(label, uri, moduleName)` to absorb + * accidental object-identity churn, but fresh builder objects every render + * still do wasted work. */ connections: Record>>; children?: React.ReactNode; @@ -49,13 +55,16 @@ type Entry = { poolKey: string; }; -const FALLBACK_STATE: ManagedConnectionState = { - isActive: false, - identity: undefined, - token: undefined, - connectionId: ConnectionId.random(), - connectionError: undefined, -}; +/** Fresh per-entry fallback so unrelated labels never collide on connectionId. */ +function freshFallbackState(): ManagedConnectionState { + return { + isActive: false, + identity: undefined, + token: undefined, + connectionId: ConnectionId.random(), + connectionError: undefined, + }; +} /** * Mounts multiple SpacetimeDB connections under a single provider, one per @@ -74,10 +83,15 @@ export function SpacetimeDBMultiProvider({ connections, children, }: SpacetimeDBMultiProviderProps): React.JSX.Element { - // Stable entry list — label + builder + pool key. Rebuilt only when the - // `connections` record identity changes. Order is stable. + // Resolve entries with a content-based signature so inline `connections={{...}}` + // props don't churn retain/release on every render. Two consecutive renders + // whose (label, uri, moduleName) tuples match reuse the same `entries` array + // identity, which keeps the effect + snapshot deps stable. + const entriesRef = useRef<{ signature: string; entries: Entry[] } | null>( + null + ); const entries = useMemo(() => { - return Object.entries(connections).map(([label, builder]) => ({ + const raw = Object.entries(connections).map(([label, builder]) => ({ label, builder, poolKey: ConnectionManager.getKey( @@ -85,7 +99,12 @@ export function SpacetimeDBMultiProvider({ builder.getModuleName() ), })); - // eslint-disable-next-line react-hooks/exhaustive-deps + const signature = raw.map(e => `${e.label}\0${e.poolKey}`).join('\n'); + if (entriesRef.current && entriesRef.current.signature === signature) { + return entriesRef.current.entries; + } + entriesRef.current = { signature, entries: raw }; + return raw; }, [connections]); // Retain every entry for the lifetime of this provider. @@ -120,11 +139,23 @@ export function SpacetimeDBMultiProvider({ states: ManagedConnectionState[]; map: ManagedConnectionStateMap; } | null>(null); + const fallbackStatesRef = useRef>( + new Map() + ); const getSnapshot = useCallback((): ManagedConnectionStateMap => { - const states = entries.map( - ({ poolKey }) => ConnectionManager.getSnapshot(poolKey) ?? FALLBACK_STATE - ); + const states = entries.map(({ label, poolKey }) => { + const pooled = ConnectionManager.getSnapshot(poolKey); + if (pooled) return pooled; + // Stable per-label fallback so consumers don't see churning connectionIds + // before the pool has a real state. + let fb = fallbackStatesRef.current.get(label); + if (!fb) { + fb = freshFallbackState(); + fallbackStatesRef.current.set(label, fb); + } + return fb; + }); // Return the cached map if every state is reference-equal to the last // read. This is what keeps `useSyncExternalStore` stable across renders diff --git a/crates/bindings-typescript/tests/connection_manager.test.ts b/crates/bindings-typescript/tests/connection_manager.test.ts index 57d71fe291c..bcf8a858732 100644 --- a/crates/bindings-typescript/tests/connection_manager.test.ts +++ b/crates/bindings-typescript/tests/connection_manager.test.ts @@ -846,7 +846,7 @@ describe('ConnectionManager', () => { expect(manager._getRefCount(key)).toBe(2); }); - test('preserves listeners across rebuild and notifies them', () => { + test('preserves listeners across rebuild and notifies them exactly once per state change', () => { const firstConn = new MockConnection(); const secondConn = new MockConnection(); const key = 'test-key'; @@ -856,9 +856,14 @@ describe('ConnectionManager', () => { manager.retain(key, new MockBuilder(firstConn)); listener.mockClear(); + // Rebuild does exactly one initial state emission (the install's setState). manager.rebuild(key, new MockBuilder(secondConn)); - // Initial install re-emits state → at least one listener call. - expect(listener).toHaveBeenCalled(); + expect(listener).toHaveBeenCalledTimes(1); + + // And the listener is still wired to the NEW connection's events, not + // a double-registered pair from old + new. + secondConn.simulateConnect(testIdentity, 'fresh'); + expect(listener).toHaveBeenCalledTimes(2); }); test('cancels pending release', () => { diff --git a/crates/bindings-typescript/tests/multi_provider.test.tsx b/crates/bindings-typescript/tests/multi_provider.test.tsx new file mode 100644 index 00000000000..f5473a91e96 --- /dev/null +++ b/crates/bindings-typescript/tests/multi_provider.test.tsx @@ -0,0 +1,262 @@ +// @vitest-environment happy-dom +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, renderHook, act, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import { StrictMode } from 'react'; +import { ConnectionId } from '../src'; +import { + SpacetimeDBMultiProvider, + useSpacetimeDB, + useSpacetimeDBStatus, +} from '../src/react'; + +/** + * Minimal mock DbConnection + Builder — enough surface for the pool + React + * layer to drive. Deliberately NOT using the real SDK builder (which would + * require a WebSocket to SpacetimeDB). + */ + +type ErrorContextInterface = { isActive: boolean }; + +class MockConnection { + isActive = false; + identity: any = undefined; + token: string | undefined = undefined; + connectionId = ConnectionId.random(); + disconnected = false; + + #onConnectCbs = new Set<(conn: MockConnection) => void>(); + #onDisconnectCbs = new Set< + (ctx: ErrorContextInterface, err?: Error) => void + >(); + #onConnectErrorCbs = new Set< + (ctx: ErrorContextInterface, err: Error) => void + >(); + + disconnect(): void { + this.disconnected = true; + this.isActive = false; + } + removeOnConnect(cb: (conn: MockConnection) => void): void { + this.#onConnectCbs.delete(cb); + } + removeOnDisconnect( + cb: (ctx: ErrorContextInterface, err?: Error) => void + ): void { + this.#onDisconnectCbs.delete(cb); + } + removeOnConnectError( + cb: (ctx: ErrorContextInterface, err: Error) => void + ): void { + this.#onConnectErrorCbs.delete(cb); + } + _registerOnConnect(cb: (conn: MockConnection) => void): void { + this.#onConnectCbs.add(cb); + } + _registerOnDisconnect( + cb: (ctx: ErrorContextInterface, err?: Error) => void + ): void { + this.#onDisconnectCbs.add(cb); + } + _registerOnConnectError( + cb: (ctx: ErrorContextInterface, err: Error) => void + ): void { + this.#onConnectErrorCbs.add(cb); + } + + simulateConnect(): void { + this.isActive = true; + for (const cb of this.#onConnectCbs) cb(this); + } +} + +class MockBuilder { + constructor( + private conn: MockConnection, + private uri: string, + private moduleName: string + ) {} + getUri(): string { + return this.uri; + } + getModuleName(): string { + return this.moduleName; + } + build(): MockConnection { + return this.conn; + } + onConnect(cb: (conn: MockConnection) => void): MockBuilder { + this.conn._registerOnConnect(cb); + return this; + } + onDisconnect( + cb: (ctx: ErrorContextInterface, err?: Error) => void + ): MockBuilder { + this.conn._registerOnDisconnect(cb); + return this; + } + onConnectError( + cb: (ctx: ErrorContextInterface, err: Error) => void + ): MockBuilder { + this.conn._registerOnConnectError(cb); + return this; + } +} + +// Unique per-test URI so each test lives in its own ConnectionManager +// namespace — the manager is a singleton and pending releases from previous +// tests can otherwise mask fresh retains. +let uriCounter = 0; +function makeBuilder(moduleName: string): { + conn: MockConnection; + builder: MockBuilder; + uri: string; +} { + const uri = `ws://test-${++uriCounter}`; + const conn = new MockConnection(); + const builder = new MockBuilder(conn, uri, moduleName); + return { conn, builder, uri }; +} + +// Silence expected error messages during hook-throws tests. +let consoleErrorSpy: ReturnType | null = null; +beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + consoleErrorSpy?.mockRestore(); + cleanup(); +}); + +describe('SpacetimeDBMultiProvider', () => { + test('renders children', () => { + const { builder } = makeBuilder('mod-a'); + const { getByText } = render( + +
hello
+
+ ); + expect(getByText('hello')).toBeTruthy(); + }); + + test('useSpacetimeDB(key) returns the state for the matching label', () => { + const { conn: connA, builder: builderA } = makeBuilder('mod-a'); + const { conn: connB, builder: builderB } = makeBuilder('mod-b'); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook( + () => ({ a: useSpacetimeDB('a'), b: useSpacetimeDB('b') }), + { wrapper } + ); + + expect(result.current.a.getConnection()).toBe(connA); + expect(result.current.b.getConnection()).toBe(connB); + }); + + test("useSpacetimeDB('missing') throws with known-keys list", () => { + const { builder } = makeBuilder('mod-a'); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + expect(() => + renderHook(() => useSpacetimeDB('missing'), { wrapper }) + ).toThrow(/no connection registered.*Known keys: a/); + }); + + test('useSpacetimeDB(key) outside a MultiProvider throws', () => { + expect(() => renderHook(() => useSpacetimeDB('a'))).toThrow( + /SpacetimeDBMultiProvider/ + ); + }); + + test('useSpacetimeDBStatus returns every labelled connection state', () => { + const { builder: builderA } = makeBuilder('mod-a'); + const { builder: builderB } = makeBuilder('mod-b'); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useSpacetimeDBStatus(), { wrapper }); + expect(Array.from(result.current.keys()).sort()).toEqual(['alpha', 'beta']); + }); + + test('useSpacetimeDBStatus outside a MultiProvider throws', () => { + expect(() => renderHook(() => useSpacetimeDBStatus())).toThrow( + /SpacetimeDBMultiProvider/ + ); + }); + + test('reacts to onConnect state changes', () => { + const { conn, builder } = makeBuilder('mod-live'); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useSpacetimeDB('m'), { wrapper }); + expect(result.current.isActive).toBe(false); + + act(() => { + conn.simulateConnect(); + }); + + expect(result.current.isActive).toBe(true); + }); + + test('StrictMode mount → unmount → remount keeps the connection alive', () => { + const { conn, builder } = makeBuilder('mod-strict'); + + const { unmount } = render( + + +
child
+
+
+ ); + + // StrictMode mounts, unmounts, remounts — retain/release/retain — + // but the pool's deferred cleanup should keep the connection alive. + expect(conn.disconnected).toBe(false); + + unmount(); + }); + + test('inline connections prop does not churn retain/release each render', () => { + const { conn, builder } = makeBuilder('mod-inline'); + // We'll pass a fresh object literal on every render below. If the provider + // trusted object identity alone, each re-render would retain→release→retain + // in the same tick; the deferred cleanup would catch it but ConnectionId + // inside the snapshot would churn. The content-based signature guard makes + // the entries array reference stable across renders with the same shape. + + function Harness({ tick }: { tick: number }): React.JSX.Element { + return ( + + {tick} + + ); + } + + const { rerender } = render(); + for (let i = 1; i < 5; i++) rerender(); + + // Connection should still be the same instance and not disconnected. + expect(conn.disconnected).toBe(false); + }); +}); diff --git a/crates/bindings-typescript/vitest.config.ts b/crates/bindings-typescript/vitest.config.ts index 56dd6752bb9..ad2f029f7e0 100644 --- a/crates/bindings-typescript/vitest.config.ts +++ b/crates/bindings-typescript/vitest.config.ts @@ -3,11 +3,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], globals: true, environment: 'node', typecheck: { - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], tsconfig: './tsconfig.typecheck.json', }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 384072b8616..c0c60c82dae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: version: 5.6.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) crates/bindings-typescript: dependencies: @@ -65,9 +65,6 @@ importers: pure-rand: specifier: ^7.0.1 version: 7.0.1 - react: - specifier: ^18.0.0 || ^19.0.0-0 || ^19.0.0 - version: 18.3.1 safe-stable-stringify: specifier: ^2.5.0 version: 2.5.0 @@ -98,7 +95,10 @@ importers: version: 11.2.0(size-limit@11.2.0) '@tanstack/react-query': specifier: ^5.90.19 - version: 5.90.19(react@18.3.1) + version: 5.90.19(react@19.2.5) + '@testing-library/react': + specifier: ^16.2.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@types/fast-text-encoding': specifier: ^1.0.3 version: 1.0.3 @@ -108,6 +108,9 @@ importers: '@types/react': specifier: ^19.1.13 version: 19.1.13 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.1.13) '@types/statuses': specifier: ^2.0.6 version: 2.0.6 @@ -119,7 +122,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) brotli-size-cli: specifier: ^1.0.0 version: 1.0.0 @@ -132,6 +135,15 @@ importers: globals: specifier: ^15.14.0 version: 15.15.0 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) size-limit: specifier: ^11.2.0 version: 11.2.0 @@ -155,7 +167,7 @@ importers: version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) crates/bindings-typescript/test-app: dependencies: @@ -314,7 +326,7 @@ importers: devDependencies: '@angular/build': specifier: ^21.1.1 - version: 21.1.4(@angular/compiler-cli@21.1.4(@angular/compiler@21.1.4)(typescript@5.9.3))(@angular/compiler@21.1.4)(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2))(@angular/platform-browser@21.1.4(@angular/common@21.1.4(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2)))(@types/node@24.3.0)(chokidar@5.0.0)(jiti@2.6.1)(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + version: 21.1.4(@angular/compiler-cli@21.1.4(@angular/compiler@21.1.4)(typescript@5.9.3))(@angular/compiler@21.1.4)(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2))(@angular/platform-browser@21.1.4(@angular/common@21.1.4(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2)))(@types/node@24.3.0)(chokidar@5.0.0)(jiti@2.6.1)(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) '@angular/cli': specifier: ^21.1.1 version: 21.1.4(@types/node@24.3.0)(chokidar@5.0.0) @@ -362,7 +374,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.11 + version: 1.3.13 bun: specifier: ^1.3.2 version: 1.3.9 @@ -432,7 +444,7 @@ importers: version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: 3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) templates/chat-react-ts/spacetimedb: dependencies: @@ -448,7 +460,7 @@ importers: dependencies: '@convex-dev/sharded-counter': specifier: ^0.2.0 - version: 0.2.0(convex@1.32.0(react@19.2.4)) + version: 0.2.0(convex@1.32.0(react@19.2.5)) '@supabase/supabase-js': specifier: ^2.80.0 version: 2.97.0 @@ -500,7 +512,7 @@ importers: version: 1.3.9 convex: specifier: ^1.29.0 - version: 1.32.0(react@19.2.4) + version: 1.32.0(react@19.2.5) dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -524,7 +536,7 @@ importers: dependencies: nuxt: specifier: ~3.16.0 - version: 3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2) + version: 3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2) spacetimedb: specifier: workspace:* version: link:../../crates/bindings-typescript @@ -5917,8 +5929,8 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} - '@types/bun@1.3.11': - resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==} + '@types/bun@1.3.13': + resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==} '@types/bun@1.3.9': resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} @@ -6134,6 +6146,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -7281,8 +7296,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.3.11: - resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==} + bun-types@1.3.13: + resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==} bun-types@1.3.9: resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==} @@ -8364,6 +8379,10 @@ packages: resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -9106,6 +9125,10 @@ packages: handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -12129,6 +12152,11 @@ packages: peerDependencies: react: ^19.2.4 + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + react-error-boundary@4.1.2: resolution: {integrity: sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==} peerDependencies: @@ -12283,6 +12311,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -14311,6 +14343,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -14805,7 +14841,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular/build@21.1.4(@angular/compiler-cli@21.1.4(@angular/compiler@21.1.4)(typescript@5.9.3))(@angular/compiler@21.1.4)(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2))(@angular/platform-browser@21.1.4(@angular/common@21.1.4(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2)))(@types/node@24.3.0)(chokidar@5.0.0)(jiti@2.6.1)(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': + '@angular/build@21.1.4(@angular/compiler-cli@21.1.4(@angular/compiler@21.1.4)(typescript@5.9.3))(@angular/compiler@21.1.4)(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2))(@angular/platform-browser@21.1.4(@angular/common@21.1.4(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2)))(@types/node@24.3.0)(chokidar@5.0.0)(jiti@2.6.1)(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2101.4(chokidar@5.0.0) @@ -14843,7 +14879,7 @@ snapshots: '@angular/platform-browser': 21.1.4(@angular/common@21.1.4(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2)) lmdb: 3.4.4 postcss: 8.5.6 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - chokidar @@ -16586,9 +16622,9 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@convex-dev/sharded-counter@0.2.0(convex@1.32.0(react@19.2.4))': + '@convex-dev/sharded-counter@0.2.0(convex@1.32.0(react@19.2.5))': dependencies: - convex: 1.32.0(react@19.2.4) + convex: 1.32.0(react@19.2.5) '@cspotcode/source-map-support@0.8.1': dependencies: @@ -21293,15 +21329,15 @@ snapshots: '@tanstack/react-query': 5.90.19(react@19.2.4) react: 19.2.4 - '@tanstack/react-query@5.90.19(react@18.3.1)': + '@tanstack/react-query@5.90.19(react@19.2.4)': dependencies: '@tanstack/query-core': 5.90.19 - react: 18.3.1 + react: 19.2.4 - '@tanstack/react-query@5.90.19(react@19.2.4)': + '@tanstack/react-query@5.90.19(react@19.2.5)': dependencies: '@tanstack/query-core': 5.90.19 - react: 19.2.4 + react: 19.2.5 '@tanstack/react-router-devtools@1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.162.6)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: @@ -21551,6 +21587,16 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.2.3(@types/react@19.1.13) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -21624,9 +21670,9 @@ snapshots: dependencies: '@types/node': 22.18.0 - '@types/bun@1.3.11': + '@types/bun@1.3.13': dependencies: - bun-types: 1.3.11 + bun-types: 1.3.13 '@types/bun@1.3.9': dependencies: @@ -21878,6 +21924,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 22.18.0 @@ -22201,7 +22249,7 @@ snapshots: vite: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.26(typescript@5.6.3) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -22216,7 +22264,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -23946,7 +23994,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.3.11: + bun-types@1.3.13: dependencies: '@types/node': 22.18.0 @@ -24386,13 +24434,13 @@ snapshots: convert-source-map@2.0.0: {} - convex@1.32.0(react@19.2.4): + convex@1.32.0(react@19.2.5): dependencies: esbuild: 0.27.0 prettier: 3.6.2 ws: 8.18.0 optionalDependencies: - react: 19.2.4 + react: 19.2.5 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -24695,10 +24743,10 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)): + db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0)): optionalDependencies: better-sqlite3: 12.6.2 - drizzle-orm: 0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0) + drizzle-orm: 0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0) de-indent@1.0.2: {} @@ -24871,14 +24919,14 @@ snapshots: dotenv@17.2.3: {} - drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0): + drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0): optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.16.0 '@types/sql.js': 1.4.9 better-sqlite3: 12.6.2 - bun-types: 1.3.11 + bun-types: 1.3.13 pg: 8.18.0 sql.js: 1.14.0 optional: true @@ -24962,6 +25010,8 @@ snapshots: entities@7.0.0: {} + entities@7.0.1: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -25968,6 +26018,18 @@ snapshots: handle-thing@2.0.1: {} + happy-dom@20.9.0: + dependencies: + '@types/node': 22.18.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -28128,7 +28190,7 @@ snapshots: neo-async@2.6.2: {} - nitropack@2.13.1(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13): + nitropack@2.13.1(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.56.0) @@ -28149,7 +28211,7 @@ snapshots: cookie-es: 2.0.0 croner: 9.1.0 crossws: 0.3.5 - db0: 0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)) + db0: 0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0)) defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 @@ -28195,7 +28257,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.6.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2) + unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0-beta.13 @@ -28403,7 +28465,7 @@ snapshots: schema-utils: 3.3.0 webpack: 5.102.0 - nuxt@3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2): + nuxt@3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2): dependencies: '@nuxt/cli': 3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.3.5) '@nuxt/devalue': 2.0.2 @@ -28440,7 +28502,7 @@ snapshots: mlly: 1.8.0 mocked-exports: 0.1.1 nanotar: 0.2.1 - nitropack: 2.13.1(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13) + nitropack: 2.13.1(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13) nypm: 0.6.4 ofetch: 1.5.1 ohash: 2.0.11 @@ -28462,7 +28524,7 @@ snapshots: unimport: 4.2.0 unplugin: 2.3.11 unplugin-vue-router: 0.12.0(vue-router@4.6.4(vue@3.5.26(typescript@5.6.3)))(vue@3.5.26(typescript@5.6.3)) - unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2) + unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2) untyped: 2.0.0 vue: 3.5.26(typescript@5.6.3) vue-bundle-renderer: 2.2.0 @@ -29833,6 +29895,11 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + react-error-boundary@4.1.2(react@18.3.1): dependencies: '@babel/runtime': 7.28.4 @@ -30012,6 +30079,8 @@ snapshots: react@19.2.4: {} + react@19.2.5: {} + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -31710,7 +31779,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2): + unstorage@1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -31721,7 +31790,7 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.3 optionalDependencies: - db0: 0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)) + db0: 0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.13)(pg@8.18.0)(sql.js@1.14.0)) ioredis: 5.9.2 untun@0.1.3: @@ -32145,7 +32214,7 @@ snapshots: optionalDependencies: vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -32173,6 +32242,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.18.0 + happy-dom: 20.9.0 jsdom: 26.1.0 transitivePeerDependencies: - jiti @@ -32188,7 +32258,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -32216,6 +32286,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.3.0 + happy-dom: 20.9.0 jsdom: 26.1.0 transitivePeerDependencies: - jiti @@ -32452,6 +32523,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: @@ -32553,7 +32626,6 @@ snapshots: ws@8.18.3: {} - wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 From c9eee826771dea4c738bead6ec5cb0939fe13b05 Mon Sep 17 00:00:00 2001 From: Ludv1g Date: Fri, 24 Apr 2026 00:00:58 +0200 Subject: [PATCH 3/3] feat(react): nest-to-merge composition for SpacetimeDBMultiProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nested s now compose by merging their label maps instead of the inner shadowing the outer wholesale. Outer labels remain visible from the inner subtree; inner entries shadow outer entries only on label collision. Unmounting the inner provider only releases the inner's pool retains — the outer's connections are untouched. This is the clean pattern for persistent-plus-swappable module sets (e.g. long-lived org / launcher modules as the outer provider, and project-scoped modules in an inner that remounts on project switch). Implementation: the provider reads the parent's context via `useContext` and merges it into its own `statusMap` behind a `useMemo`. The merged map is reference-stable when neither parent nor own statusMap changed. Also adds four tests covering: - Outer labels visible from an inner subtree. - Inner provider shadowing an outer label on collision. - Inner unmount releasing only inner entries. - Subset-swap of a single label within a single provider (launcher's release→retain cancels the deferred teardown; orphaned old entry tears down after setTimeout(0)). All 199 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/react/SpacetimeDBMultiProvider.ts | 26 +++- .../tests/multi_provider.test.tsx | 120 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts b/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts index e9babcce6c7..1b6095b5f34 100644 --- a/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts +++ b/crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts @@ -3,6 +3,7 @@ import { useMemo, useSyncExternalStore, createContext, + useContext, useRef, useCallback, } from 'react'; @@ -77,6 +78,15 @@ function freshFallbackState(): ManagedConnectionState { * WebSocket; unmounting one provider releases its retain without tearing the * socket while the other is alive. * + * **Nesting / composition.** Providers compose by merging. An inner provider's + * label map is layered on top of the nearest outer provider's — labels unique + * to the outer remain visible from the inner subtree, labels present in both + * are shadowed by the inner entry. This is the idiomatic pattern for + * persistent-plus-swappable module sets: wrap long-lived modules in an outer + * provider, and swap shorter-lived modules with an inner + * ``. Unmounting the inner provider + * only releases the inner's entries. + * * StrictMode-safe: cleanup defers through the pool's setTimeout(0). */ export function SpacetimeDBMultiProvider({ @@ -185,9 +195,23 @@ export function SpacetimeDBMultiProvider({ const statusMap = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + // Compose with any parent MultiProvider: parent labels remain visible, own + // labels shadow on collision. Keeps the merged map reference-stable when + // neither the parent map nor our own statusMap changed. + const parentMap = useContext(SpacetimeDBMultiContext); + const mergedMap = useMemo(() => { + if (!parentMap || parentMap.size === 0) return statusMap; + if (statusMap.size === 0) return parentMap; + const merged: ManagedConnectionStateMap = new Map(parentMap); + for (const [label, state] of statusMap) { + merged.set(label, state); + } + return merged; + }, [parentMap, statusMap]); + return React.createElement( SpacetimeDBMultiContext.Provider, - { value: statusMap }, + { value: mergedMap }, children ); } diff --git a/crates/bindings-typescript/tests/multi_provider.test.tsx b/crates/bindings-typescript/tests/multi_provider.test.tsx index f5473a91e96..afc02bf462f 100644 --- a/crates/bindings-typescript/tests/multi_provider.test.tsx +++ b/crates/bindings-typescript/tests/multi_provider.test.tsx @@ -237,6 +237,126 @@ describe('SpacetimeDBMultiProvider', () => { unmount(); }); + test('nested provider: outer labels remain visible from inner subtree', () => { + const { conn: launcherConn, builder: launcherBuilder } = + makeBuilder('mod-launcher'); + const { conn: coreConn, builder: coreBuilder } = makeBuilder('mod-core'); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + const { result } = renderHook( + () => ({ + launcher: useSpacetimeDB('launcher'), + core: useSpacetimeDB('core'), + }), + { wrapper } + ); + + expect(result.current.launcher.getConnection()).toBe(launcherConn); + expect(result.current.core.getConnection()).toBe(coreConn); + }); + + test('nested provider: inner provider shadows outer label on collision', () => { + const { conn: outerConn, builder: outerBuilder } = + makeBuilder('mod-shared'); + const { conn: innerConn, builder: innerBuilder } = + makeBuilder('mod-shared'); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useSpacetimeDB('shared'), { wrapper }); + expect(result.current.getConnection()).toBe(innerConn); + // Outer is still retained — the outer and inner map to different pool + // entries because they have different URIs. + expect(outerConn.disconnected).toBe(false); + }); + + test('nested provider: inner unmount only releases inner entries', async () => { + const { conn: launcherConn, builder: launcherBuilder } = makeBuilder( + 'mod-launcher-scoped' + ); + const { conn: coreConn, builder: coreBuilder } = + makeBuilder('mod-core-scoped'); + + function Harness({ showInner }: { showInner: boolean }): React.JSX.Element { + return ( + + {showInner ? ( + +
inner
+
+ ) : ( +
no inner
+ )} +
+ ); + } + + const { rerender } = render(); + expect(launcherConn.disconnected).toBe(false); + expect(coreConn.disconnected).toBe(false); + + rerender(); + // Flush the pool's setTimeout(0) deferred teardown. + await new Promise(r => setTimeout(r, 10)); + expect(launcherConn.disconnected).toBe(false); + expect(coreConn.disconnected).toBe(true); + }); + + test('subset swap: changing one label releases that entry, keeps siblings alive', async () => { + const { conn: launcherConn, builder: launcherBuilder } = + makeBuilder('mod-launcher-keep'); + const { conn: oldCoreConn, builder: oldCoreBuilder } = + makeBuilder('mod-core-v1'); + const { conn: newCoreConn, builder: newCoreBuilder } = + makeBuilder('mod-core-v2'); + + function Harness({ coreV }: { coreV: 1 | 2 }): React.JSX.Element { + const connections = React.useMemo( + () => ({ + launcher: launcherBuilder as any, + core: (coreV === 1 ? oldCoreBuilder : newCoreBuilder) as any, + }), + [coreV] + ); + return ( + +
project {coreV}
+
+ ); + } + + const { rerender } = render(); + expect(launcherConn.disconnected).toBe(false); + expect(oldCoreConn.disconnected).toBe(false); + + rerender(); + // Launcher's release→retain on the same pool key cancels the scheduled + // cleanup; old-core's entry is orphaned and tears down after setTimeout(0). + await new Promise(r => setTimeout(r, 10)); + expect(launcherConn.disconnected).toBe(false); + expect(oldCoreConn.disconnected).toBe(true); + expect(newCoreConn.disconnected).toBe(false); + }); + test('inline connections prop does not churn retain/release each render', () => { const { conn, builder } = makeBuilder('mod-inline'); // We'll pass a fresh object literal on every render below. If the provider