Skip to content

feat: Multi-module React provider and ConnectionManager.rebuild()#4887

Open
Ludv1gL wants to merge 3 commits intoclockworklabs:masterfrom
Ludv1gL:feat/multi-module-provider
Open

feat: Multi-module React provider and ConnectionManager.rebuild()#4887
Ludv1gL wants to merge 3 commits intoclockworklabs:masterfrom
Ludv1gL:feat/multi-module-provider

Conversation

@Ludv1gL
Copy link
Copy Markdown
Contributor

@Ludv1gL Ludv1gL commented Apr 23, 2026

Summary

The React layer ships a first-class single-module story — one
<SpacetimeDBProvider> per app, useSpacetimeDB() reads the closest
one — but no supported pattern for apps that need to connect to more
than one module concurrently (game + chat, core + telemetry, org +
multiplayer, etc.). Stacking nested <SpacetimeDBProvider>s breaks
because useTable(tables.X) can only reach the nearest provider, and
the internal ConnectionManager that would otherwise let applications
build their own adapter layer is not re-exported.

This PR adds a keyed multi-module provider, re-exports
ConnectionManager, and adds a rebuild(key, builder) method that
closes a real gap in the pool API. Existing single-<SpacetimeDBProvider>
apps are unchanged — the new hook forms take an optional leading key
argument; zero-arg calls hit the original context path exactly as before.

Changes

  • crates/bindings-typescript/src/sdk/connection_manager.ts:
    New rebuild(key, builder) method that tears down the existing
    connection and installs a fresh one under the same refcount + listener
    set. Extracted shared #install / #teardown helpers so retain,
    release, and rebuild go through a single code path. rebuild
    cancels a pending release if one is scheduled, returns null when the
    key has no retain, and removes the old connection's callbacks before
    swapping so stale events never leak into the pool state.
  • crates/bindings-typescript/src/sdk/index.ts: Re-export
    ConnectionManager and alias the internal state type as
    ManagedConnectionState so application code can inter-op.
  • crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts
    (new, 160 LOC): <SpacetimeDBMultiProvider connections={{ key: builder, ... }}>
    retains one pool entry per label on mount, subscribes to every entry's
    state via useSyncExternalStore, and exposes a Map<label, ConnectionState>
    to the subtree. Absorbs inline-object-prop churn with a content-based
    signature over (label, uri, moduleName) — two renders whose tuples
    match reuse the same entries-array identity, avoiding spurious
    retain/release cycles. Per-label fallback state so consumers comparing
    connectionId across labels don't see a single shared
    ConnectionId.random() before the pool resolves.
  • crates/bindings-typescript/src/react/useSpacetimeDB.ts:
    useSpacetimeDB(key?) now reads both the single-provider and
    multi-provider contexts unconditionally (Rules of Hooks), then
    dispatches on key. Zero-arg form is exactly the pre-PR behavior.
    Thrown errors for useSpacetimeDB('missing') include the list of
    registered keys.
  • crates/bindings-typescript/src/react/useTable.ts /
    useReducer.ts
    : Keyed overloads —
    useTable(key, tables.user) / useReducer(key, def) — that dispatch
    through useSpacetimeDB(key). Internal logic extracted into
    useTableInternal / useReducerInternal so the overloads are a thin
    dispatch wrapper.
  • crates/bindings-typescript/src/react/useSpacetimeDBStatus.ts
    (new): useSpacetimeDBStatus() returns the live
    Map<label, ConnectionState> from the nearest SpacetimeDBMultiProvider.
    Useful for per-module connection-health UI.
  • crates/bindings-typescript/src/react/index.ts: Exports for
    SpacetimeDBMultiProvider, SpacetimeDBMultiContext,
    useSpacetimeDBStatus, and the supporting types.
  • crates/bindings-typescript/tests/connection_manager.test.ts:
    Eight new rebuild() cases covering teardown, refcount preservation,
    pending-release cancellation, stale-callback removal, listener
    re-wiring, and fresh-connection event routing. Updated the test file's
    mirror implementation to match the new #install / #teardown
    factoring.
  • crates/bindings-typescript/tests/multi_provider.test.tsx (new):
    Nine React-layer tests (Testing Library + happy-dom) covering render,
    keyed-dispatch correctness, missing-key and missing-provider errors,
    useSpacetimeDBStatus aggregation, reactivity to onConnect,
    StrictMode mount→unmount→remount survival, and absorption of inline
    connections={{...}} prop churn.
  • crates/bindings-typescript/vitest.config.ts: Include
    tests/**/*.test.tsx in the glob and typecheck set.
  • crates/bindings-typescript/package.json + pnpm-lock.yaml:
    Dev-deps for the React tests — @testing-library/react, happy-dom,
    react, react-dom, @types/react, @types/react-dom.

Safety

  • Backward compatibility: Zero-arg useSpacetimeDB(),
    useTable(query), useReducer(def), and existing
    <SpacetimeDBProvider> usage are untouched. The overload signatures
    collapse into a single implementation that runs the same useContext
    calls whether or not a key was passed, so hook order stays stable
    across call forms.
  • Rules of Hooks: useSpacetimeDB calls both
    useContext(SpacetimeDBContext) and useContext(SpacetimeDBMultiContext)
    unconditionally on every render, then branches on the argument. All
    keyed hooks delegate through useSpacetimeDB(key) with no ternary at
    the call site, so ESLint react-hooks/rules-of-hooks passes without
    exemptions.
  • StrictMode safety: SpacetimeDBMultiProvider uses the existing
    pool semantics — retain on mount, release on cleanup — and the
    pool's setTimeout(0) deferred teardown absorbs React 18's
    double-mount. Covered by
    multi_provider.test.tsx:'StrictMode mount → unmount → remount keeps the connection alive'.
  • Pool sharing: Two SpacetimeDBMultiProviders registering the same
    (uri, moduleName) under different labels share a single WebSocket
    — the pool key is derived from builder identity, not from the React
    label — so unmounting one provider releases its retain without tearing
    the socket while sibling providers are still alive.
  • rebuild() semantics: Synchronously removes the old connection's
    onConnect / onDisconnect / onConnectError callbacks before
    calling disconnect(), then installs the new connection. Listeners
    registered via subscribe() are unaffected, so
    useSyncExternalStore consumers see the new state on the next
    notification rather than a brief empty gap.
  • No module-level mutable state: The per-label fallback map lives in
    a per-provider useRef, so multiple providers don't share a random
    ConnectionId fallback.

Example

import {
  SpacetimeDBMultiProvider,
  useSpacetimeDB,
  useTable,
  useSpacetimeDBStatus,
} from 'spacetimedb/react';

function App() {
  const connections = useMemo(() => ({
    game: GameDbConnection.builder().withUri(GAME_URI).withDatabaseName('game'),
    chat: ChatDbConnection.builder().withUri(GAME_URI).withDatabaseName('chat'),
  }), []);

  return (
    <SpacetimeDBMultiProvider connections={connections}>
      <PlayerList />
      <ChatPanel />
      <ConnectionStatusBar />
    </SpacetimeDBMultiProvider>
  );
}

function PlayerList() {
  const [players] = useTable('game', gameTables.player);
  return <ul>{players.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

function ChatPanel() {
  const [messages] = useTable('chat', chatTables.message);
  return <ul>{messages.map(m => <li key={m.id}>{m.text}</li>)}</ul>;
}

function ConnectionStatusBar() {
  const status = useSpacetimeDBStatus();
  return (
    <div>
      {Array.from(status.entries()).map(([label, state]) => (
        <span key={label}>{label}: {state.isActive ? 'ok' : 'connecting'}</span>
      ))}
    </div>
  );
}

Test plan

  • pnpm --filter spacetimedb test — 195/195 pass, including 8 new
    rebuild() cases and 9 new React-layer cases covering the keyed
    dispatch, error surfaces, StrictMode, and inline-prop churn
  • pnpm --filter spacetimedb lint — clean (ESLint + Prettier)
  • pnpm --filter spacetimedb build — clean (tsup all variants +
    tsc types build)
  • Verified <SpacetimeDBProvider> + useSpacetimeDB() +
    useTable(query) unchanged — the single-module db_connection.test.ts
    suite and all 19 pre-existing test files still pass
  • Manual React DevTools check: zero-arg useSpacetimeDB() in a
    subtree wrapped by both providers reads the single-provider context,
    not the multi-provider map

🤖 Generated with Claude Code

Ludv1gL and others added 3 commits April 23, 2026 23:44
…nager.rebuild

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
<SpacetimeDBProvider>s and fight useSpacetimeDB()'s closest-provider lookup,
or re-implement ConnectionManager locally because it wasn't exported.

Changes:

* New <SpacetimeDBMultiProvider connections={{ key: builder, ... }}>: 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 <SpacetimeDBProvider>.

* useSpacetimeDBStatus() returns a live Map<label, ConnectionState> 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-<SpacetimeDBProvider> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Nested <SpacetimeDBMultiProvider>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 <SpacetimeDBMultiProvider key={projectId}>
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) <noreply@anthropic.com>
@Ludv1gL
Copy link
Copy Markdown
Contributor Author

Ludv1gL commented Apr 23, 2026

Added a follow-up commit (c9eee82) that makes <SpacetimeDBMultiProvider>s compose by merging when nested. Motivating use case: apps with a mix of long-lived and short-lived modules (e.g. org-scoped connections that outlive a single project, plus project-scoped connections that swap on project change).

Behavior:

  • Outer labels remain visible from an inner subtree (useSpacetimeDB('label') resolves through the parent provider even when called inside a child <SpacetimeDBMultiProvider>).
  • Inner entries shadow outer entries only on label collision.
  • Unmounting the inner provider only releases the inner's pool retains; outer retains are untouched — no reliance on the release-then-retain deferred-cleanup trick for the persistent half of the tree.

Covered by four new tests:

  • nested provider: outer labels remain visible from inner subtree
  • nested provider: inner provider shadows outer label on collision
  • nested provider: inner unmount only releases inner entries
  • subset swap: changing one label releases that entry, keeps siblings alive

Total: 199 tests pass. Lint + build clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant