Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/bindings-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
217 changes: 217 additions & 0 deletions crates/bindings-typescript/src/react/SpacetimeDBMultiProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import {
useEffect,
useMemo,
useSyncExternalStore,
createContext,
useContext,
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<string, ConnectionState>;

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.
*
* **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<string, DbConnectionBuilder<DbConnectionImpl<any>>>;
children?: React.ReactNode;
}

type Entry = {
label: string;
builder: DbConnectionBuilder<DbConnectionImpl<any>>;
poolKey: string;
};

/** 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
* 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.
*
* **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
* `<SpacetimeDBMultiProvider key={scopeId}>`. Unmounting the inner provider
* only releases the inner's entries.
*
* StrictMode-safe: cleanup defers through the pool's setTimeout(0).
*/
export function SpacetimeDBMultiProvider({
connections,
children,
}: SpacetimeDBMultiProviderProps): React.JSX.Element {
// 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<Entry[]>(() => {
const raw = Object.entries(connections).map(([label, builder]) => ({
label,
builder,
poolKey: ConnectionManager.getKey(
builder.getUri(),
builder.getModuleName()
),
}));
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.
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 fallbackStatesRef = useRef<Map<string, ManagedConnectionState>>(
new Map()
);

const getSnapshot = useCallback((): ManagedConnectionStateMap => {
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
// 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<DbConnectionImpl<any>>(poolKey),
});
}

snapshotRef.current = { states, map };
return map;
}, [entries]);

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<ManagedConnectionStateMap>(() => {
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: mergedMap },
children
);
}
7 changes: 7 additions & 0 deletions crates/bindings-typescript/src/react/index.ts
Original file line number Diff line number Diff line change
@@ -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';
37 changes: 34 additions & 3 deletions crates/bindings-typescript/src/react/useReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<SpacetimeDBProvider>`.
* - `useReducer(key, reducerDef)` — reads the connection labelled `key` from
* the nearest `<SpacetimeDBMultiProvider>`.
*
* Returns `(...args) => Promise<void>`. Calls made before the connection is
* live are queued and flushed on `onApplied`.
*/
export function useReducer<ReducerDef extends UntypedReducerDef>(
reducerDef: ReducerDef
): (...params: ParamsType<ReducerDef>) => Promise<void>;
export function useReducer<ReducerDef extends UntypedReducerDef>(
key: string,
reducerDef: ReducerDef
): (...params: ParamsType<ReducerDef>) => Promise<void>;
export function useReducer<ReducerDef extends UntypedReducerDef>(
keyOrDef: string | ReducerDef,
maybeDef?: ReducerDef
): (...params: ParamsType<ReducerDef>) => Promise<void> {
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<ReducerDef>(connectionState, reducerDef);
}

function useReducerInternal<ReducerDef extends UntypedReducerDef>(
connectionState: ConnectionState,
reducerDef: ReducerDef
): (...params: ParamsType<ReducerDef>) => Promise<void> {
const { getConnection, isActive } = useSpacetimeDB();
const { getConnection, isActive } = connectionState;
const reducerName = reducerDef.accessorName;

// Holds calls made before the connection exists
const queueRef = useRef<
{
params: ParamsType<ReducerDef>;
Expand All @@ -18,7 +50,6 @@ export function useReducer<ReducerDef extends UntypedReducerDef>(
}[]
>([]);

// Flush when we finally have a connection
useEffect(() => {
const conn = getConnection();
if (!conn) {
Expand Down
42 changes: 36 additions & 6 deletions crates/bindings-typescript/src/react/useSpacetimeDB.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,48 @@
import { createContext, useContext } from 'react';
import type { ConnectionState } from './connection_state';
import { SpacetimeDBMultiContext } from './SpacetimeDBMultiProvider';

export const SpacetimeDBContext = createContext<ConnectionState | undefined>(
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 `<SpacetimeDBProvider>`. Throws if
* there is none.
* - `useSpacetimeDB(key)` — reads the entry labelled `key` from the nearest
* `<SpacetimeDBMultiProvider>`. 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;
}
22 changes: 22 additions & 0 deletions crates/bindings-typescript/src/react/useSpacetimeDBStatus.ts
Original file line number Diff line number Diff line change
@@ -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
* `<SpacetimeDBMultiProvider>`. Useful for connection-health UI.
*
* Returns a live `Map<label, ConnectionState>`. 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;
}
Loading