feat: Multi-module React provider and ConnectionManager.rebuild()#4887
Open
Ludv1gL wants to merge 3 commits intoclockworklabs:masterfrom
Open
feat: Multi-module React provider and ConnectionManager.rebuild()#4887Ludv1gL wants to merge 3 commits intoclockworklabs:masterfrom
Ludv1gL wants to merge 3 commits intoclockworklabs:masterfrom
Conversation
…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>
Contributor
Author
|
Added a follow-up commit (c9eee82) that makes Behavior:
Covered by four new tests:
Total: 199 tests pass. Lint + build clean. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The React layer ships a first-class single-module story — one
<SpacetimeDBProvider>per app,useSpacetimeDB()reads the closestone — 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 breaksbecause
useTable(tables.X)can only reach the nearest provider, andthe internal
ConnectionManagerthat would otherwise let applicationsbuild their own adapter layer is not re-exported.
This PR adds a keyed multi-module provider, re-exports
ConnectionManager, and adds arebuild(key, builder)method thatcloses a real gap in the pool API. Existing single-
<SpacetimeDBProvider>apps are unchanged — the new hook forms take an optional leading
keyargument; 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 existingconnection and installs a fresh one under the same refcount + listener
set. Extracted shared
#install/#teardownhelpers soretain,release, andrebuildgo through a single code path.rebuildcancels a pending release if one is scheduled, returns
nullwhen thekey 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-exportConnectionManagerand alias the internal state type asManagedConnectionStateso 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 aMap<label, ConnectionState>to the subtree. Absorbs inline-object-prop churn with a content-based
signature over
(label, uri, moduleName)— two renders whose tuplesmatch reuse the same entries-array identity, avoiding spurious
retain/release cycles. Per-label fallback state so consumers comparing
connectionIdacross labels don't see a single sharedConnectionId.random()before the pool resolves.crates/bindings-typescript/src/react/useSpacetimeDB.ts:useSpacetimeDB(key?)now reads both the single-provider andmulti-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 ofregistered keys.
crates/bindings-typescript/src/react/useTable.ts/useReducer.ts: Keyed overloads —useTable(key, tables.user)/useReducer(key, def)— that dispatchthrough
useSpacetimeDB(key). Internal logic extracted intouseTableInternal/useReducerInternalso the overloads are a thindispatch wrapper.
crates/bindings-typescript/src/react/useSpacetimeDBStatus.ts(new):
useSpacetimeDBStatus()returns the liveMap<label, ConnectionState>from the nearestSpacetimeDBMultiProvider.Useful for per-module connection-health UI.
crates/bindings-typescript/src/react/index.ts: Exports forSpacetimeDBMultiProvider,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/#teardownfactoring.
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,
useSpacetimeDBStatusaggregation, reactivity toonConnect,StrictMode mount→unmount→remount survival, and absorption of inline
connections={{...}}prop churn.crates/bindings-typescript/vitest.config.ts: Includetests/**/*.test.tsxin 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
useSpacetimeDB(),useTable(query),useReducer(def), and existing<SpacetimeDBProvider>usage are untouched. The overload signaturescollapse into a single implementation that runs the same
useContextcalls whether or not a key was passed, so hook order stays stable
across call forms.
useSpacetimeDBcalls bothuseContext(SpacetimeDBContext)anduseContext(SpacetimeDBMultiContext)unconditionally on every render, then branches on the argument. All
keyed hooks delegate through
useSpacetimeDB(key)with no ternary atthe call site, so ESLint
react-hooks/rules-of-hookspasses withoutexemptions.
SpacetimeDBMultiProvideruses the existingpool semantics —
retainon mount,releaseon cleanup — and thepool's
setTimeout(0)deferred teardown absorbs React 18'sdouble-mount. Covered by
multi_provider.test.tsx:'StrictMode mount → unmount → remount keeps the connection alive'.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'sonConnect/onDisconnect/onConnectErrorcallbacks beforecalling
disconnect(), then installs the new connection. Listenersregistered via
subscribe()are unaffected, souseSyncExternalStoreconsumers see the new state on the nextnotification rather than a brief empty gap.
a per-provider
useRef, so multiple providers don't share a randomConnectionIdfallback.Example
Test plan
pnpm --filter spacetimedb test— 195/195 pass, including 8 newrebuild()cases and 9 new React-layer cases covering the keyeddispatch, error surfaces, StrictMode, and inline-prop churn
pnpm --filter spacetimedb lint— clean (ESLint + Prettier)pnpm --filter spacetimedb build— clean (tsupall variants +tsctypes build)<SpacetimeDBProvider>+useSpacetimeDB()+useTable(query)unchanged — the single-moduledb_connection.test.tssuite and all 19 pre-existing test files still pass
useSpacetimeDB()in asubtree wrapped by both providers reads the single-provider context,
not the multi-provider map
🤖 Generated with Claude Code