Skip to content

Conversation

@douglance
Copy link

@douglance douglance commented Jan 13, 2026

Summary

  • Add ConnectionManager singleton that uses reference counting and deferred cleanup to handle React StrictMode's double-mount behavior
  • Implement TanStack Query-style pattern: retain() / release() instead of direct connect/disconnect
  • Use useSyncExternalStore for tear-free state reads in React

Changes

  • New: src/sdk/connection_manager.ts - Core ConnectionManager implementation
  • Modified: src/react/SpacetimeDBProvider.ts - Now uses ConnectionManager
  • Modified: src/react/connection_state.ts - Simplified type, imports from ConnectionManager
  • Modified: src/sdk/db_connection_builder.ts - Added getUri() and getModuleName() methods
  • Modified: src/sdk/db_connection_impl.ts - Minor type updates for ConnectionManager integration
  • New: tests/connection_manager.test.ts - 33 unit tests
  • Docs: Added React Integration section to TypeScript reference
  • Docs: Added StrictMode compatibility note to README

Test plan

  • pnpm test passes (104 tests)
  • pnpm build passes
  • Manual browser test with React StrictMode - single WebSocket connection

Motivation

The Problem

React StrictMode double-mounts components in development to help catch bugs:

  1. Mount → creates WebSocket connection
  2. Unmount → destroys WebSocket connection
  3. Remount → creates another WebSocket connection

Without ConnectionManager, you get either:

  • Two connections (if you don't clean up on unmount)
  • Broken connection (if you clean up on unmount, the remount races with cleanup)

Why ConnectionManager? (vs alternatives)

Alternative Problem
Don't clean up on unmount Leaks connections, memory leaks, stale subscriptions
Disable StrictMode Hides real bugs, production still has edge cases
Connection in module scope React can't react to state changes, no useSyncExternalStore

ConnectionManager approach (TanStack Query pattern):

  • Reference counting: multiple components share one connection
  • Deferred cleanup: setTimeout(0) lets StrictMode remount cancel cleanup
  • External store: connection state lives outside React but syncs properly

Why Two useSyncExternalStore Usages?

The SDK now has two places using useSyncExternalStore. They solve different problems:

useTable ConnectionManager
Subscribes to Table row changes Connection state changes
External store DbConnection.db.tableName callbacks ConnectionManager singleton
Re-renders when Row inserted/deleted/updated isActive, identity, error change
Manages lifecycle? ❌ No - assumes connection exists ✅ Yes - owns connect/disconnect

useTable assumes a connection exists

// useTable.ts - subscribes to TABLE DATA
useEffect(() => {
  const connection = connectionState.getConnection()!;  // assumes this exists
  if (connectionState.isActive && connection) {
    const cancel = connection.subscriptionBuilder().subscribe(query);
    return () => cancel.unsubscribe();
  }
}, [...]);

ConnectionManager manages WHEN the connection exists

// OLD SpacetimeDBProvider - broken with StrictMode
useEffect(() => {
  const conn = builder.build();    // Creates WebSocket
  return () => conn.disconnect();  // Destroys WebSocket
}, [builder]);
// StrictMode: build → disconnect → build = TWO CONNECTIONS RACING

// NEW SpacetimeDBProvider - works with StrictMode  
useEffect(() => {
  ConnectionManager.retain(key, builder);   // refCount++, maybe create
  return () => ConnectionManager.release(key); // refCount--, deferred cleanup
}, [key, builder]);
// StrictMode: retain(1) → release(0, schedule) → retain(1, cancel) = ONE CONNECTION

The Layered Architecture

ConnectionManager (lifecycle: retain/release, deferred cleanup)
    ↓
SpacetimeDBProvider (React context + useSyncExternalStore for connection state)
    ↓
useTable (useSyncExternalStore for table data)
  • useTable syncs data from an existing connection
  • ConnectionManager syncs connection state AND manages when the connection exists

@CLAassistant
Copy link

CLAassistant commented Jan 13, 2026

CLA assistant check
All committers have signed the CLA.

Implements TanStack Query-style connection management:
- ConnectionManager singleton with retain/release reference counting
- useSyncExternalStore for React state subscriptions
- Deferred cleanup handles StrictMode mount/unmount/remount cycle
- Connection sharing across providers with same uri/moduleName key

This replaces the setTimeout workaround in SpacetimeDBProvider with
a proper architectural solution at the SDK layer.
- Add 33 unit tests covering reference counting, deferred cleanup,
  React StrictMode simulation, state management, and subscriptions
- Add React Integration section to TypeScript reference docs with
  SpacetimeDBProvider, useSpacetimeDB, and useTable documentation
- Add StrictMode compatibility note to SDK README
- Add module-level JSDoc to connection_manager.ts explaining the
  TanStack Query-style pattern for handling React lifecycles
@douglance douglance force-pushed the doug/connection-manager branch from b797a77 to da4edb3 Compare January 13, 2026 21:08
@bfops
Copy link
Collaborator

bfops commented Jan 14, 2026

Thank you for opening this and the detailed description! @cloutiertyler also tried to fix the StrictMode problem but did so by briefly delaying disconnection (#4017).

What do you think of that approach?

@douglance
Copy link
Author

@bfops I was watching him work on that PR on stream. That's why I wanted to open this one. There are scenarios where that solution doesn't work. The timing is not guaranteed.

This solution (basically just using a singleton outside of React), guarantees the system timing works as expected in all scenarios. It's a robust pattern used by the biggest libraries solving this problem.

I would consider it the best solution for this problem.

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.

3 participants