Merge branch 'fix/cross-store-resolver-tdz'

Close backlog #27: extract cross-store resolvers into a neutral utility
(packages/web/src/utils/crossStoreResolvers.ts) to break a TDZ cycle
between spaceStore and instanceStore. instanceStore's top-level
setResolver calls used to race with spaceStore's `let _getApiForOrigin`
declaration when the module graph was entered from instanceStore
(JoinSpaceModal → useInstanceStore), crashing with "Cannot access
'_getApiForOrigin' before initialization" and preventing
InviteModal.test.tsx and JoinSpace.test.tsx from loading.

Moves the three resolver lets + setters + pure getters + the
WS-populated user-ID cache into the utility; spaceStore re-exports the
public surface; instanceStore imports the setters directly from the
utility (re-exports do not resolve at module-init time under vite-ssr
in the cycle). authStore-using wrappers (resolveUserOrigin,
getLayoutHomeOrigin, getMyUserIdForOrigin) stay in spaceStore but
delegate to the utility.

Also adds the AudioManager mock to InviteModal/JoinSpace test files
(established pattern) so their suites can load.

Verification: typecheck clean, 127/131 web tests pass (up from 121/121;
+6 newly unlocked), server 90/90 unchanged. The 4 remaining JoinSpace
failures are pre-existing stale UI-text assertions (placeholder
expanded, submit button now disable-when-empty) — unrelated to this
work, made visible only because the suite loads now.

Spec: docs/systems/client-federation.md updated.
Smoke test: vite dev bundle serves spaceStore + crossStoreResolvers
clean; full live E2E blocked by a pre-existing local DB-migration
error unrelated to this client-side refactor (reproduces on main).
This commit is contained in:
Jannis Braun
2026-04-23 02:46:37 +02:00
6 changed files with 164 additions and 57 deletions
+9 -3
View File
@@ -5,7 +5,8 @@
Source files: Source files:
- `packages/web/src/stores/instanceStore.ts` — Core multi-instance connection management, token caching, topology sync - `packages/web/src/stores/instanceStore.ts` — Core multi-instance connection management, token caching, topology sync
- `packages/web/src/hooks/useWebSocket.ts` — WebSocket multiplexing (one connection per instance), origin-aware event routing - `packages/web/src/hooks/useWebSocket.ts` — WebSocket multiplexing (one connection per instance), origin-aware event routing
- `packages/web/src/stores/spaceStore.ts` — Origin-aware space/channel store, `channelOriginMap`, `getChannelOrigin()`, `getApiForOrigin()`, DM deduplication - `packages/web/src/stores/spaceStore.ts` — Origin-aware space/channel store, `channelOriginMap`, `getChannelOrigin()`, `resolveUserOrigin()`, `getLayoutHomeOrigin()`, `getMyUserIdForOrigin()`, DM deduplication
- `packages/web/src/utils/crossStoreResolvers.ts` — Neutral module holding the cross-store resolver bindings (`_getApiForOrigin`, `_resolveOriginFromHostname`, `_getUserIdForOrigin`) + the WS-populated user-ID cache. Breaks a TDZ cycle between spaceStore and instanceStore; see "API Client Resolution" below
- `packages/web/src/utils/identity.ts` — Cross-instance user identity resolution (`isSelf`, `canonicalUserMatch`, self-ID registry) - `packages/web/src/utils/identity.ts` — Cross-instance user identity resolution (`isSelf`, `canonicalUserMatch`, self-ID registry)
- `packages/web/src/hooks/useInstanceConnect.ts` — Connection flow hook for the Connections UI - `packages/web/src/hooks/useInstanceConnect.ts` — Connection flow hook for the Connections UI
- `packages/web/src/components/modals/ConnectedInstances.tsx` — Connections settings panel - `packages/web/src/components/modals/ConnectedInstances.tsx` — Connections settings panel
@@ -184,10 +185,15 @@ getApiForOrigin(origin: string): BackspaceApiClient
Returns the correct API client for the given origin. Uses a resolver pattern to break circular dependencies between stores: Returns the correct API client for the given origin. Uses a resolver pattern to break circular dependencies between stores:
- `instanceStore` registers the resolver at module init - The resolver backing, its setter (`setApiForOriginResolver`), and the getter (`getApiForOrigin`) live in `packages/web/src/utils/crossStoreResolvers.ts` — a neutral module with no store imports
- `spaceStore` exposes `getApiForOrigin()` which calls the registered resolver - `instanceStore` imports the setter from the utility directly (not from `spaceStore`) and registers the resolver at module init
- `spaceStore` re-exports `getApiForOrigin` (and its sibling setters) from the utility for backward compatibility with existing import sites
- Consumers call `getApiForOrigin(getChannelOrigin(channelId))` to get the right client - Consumers call `getApiForOrigin(getChannelOrigin(channelId))` to get the right client
The same pattern covers `resolveOriginFromHostname` (for `resolveUserOrigin`), the user-ID resolver (`resolveUserIdFromInstances`), and the WS-populated user-ID cache (`setMyUserIdForOrigin` / `getCachedUserIdForOrigin` / `clearMyUserIdCache`).
**Why the utility exists:** `instanceStore` runs top-level `setXResolver` calls at module load. If spaceStore holds the backing `let _getApiForOrigin` declaration AND the import chain reaches instanceStore while spaceStore is mid-load (e.g. via `JoinSpaceModal` importing `useInstanceStore` directly), the setter crashes with TDZ: `Cannot access '_getApiForOrigin' before initialization`. Hoisting the mutable bindings into a module that has no back-edges into the stores eliminates the cycle. Do NOT add imports from `./stores/*` into `crossStoreResolvers.ts` — doing so re-creates the exact cycle that module was carved out to break.
### User Origin Resolution ### User Origin Resolution
```typescript ```typescript
@@ -1,6 +1,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom.
// Reached transitively via spaceStore → chatStore → useWebSocket → voiceStore.
vi.mock('../../audio/AudioManager', () => ({
AudioManager: {
getInstance: vi.fn().mockReturnValue({
setOutputDevice: vi.fn(),
setVolume: vi.fn(),
}),
},
}));
import { InviteModal } from './InviteModal'; import { InviteModal } from './InviteModal';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
@@ -2,6 +2,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom.
// Reached transitively via spaceStore → chatStore → useWebSocket → voiceStore.
vi.mock('../../audio/AudioManager', () => ({
AudioManager: {
getInstance: vi.fn().mockReturnValue({
setOutputDevice: vi.fn(),
setVolume: vi.fn(),
}),
},
}));
import { JoinSpaceModal } from './JoinSpace'; import { JoinSpaceModal } from './JoinSpace';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
+6 -1
View File
@@ -2,7 +2,12 @@ import { create } from 'zustand';
import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse, FederationRegistryEntry } from '@backspace/shared'; import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse, FederationRegistryEntry } from '@backspace/shared';
import { BackspaceApiClient, createApiClient, api } from '../api/client'; import { BackspaceApiClient, createApiClient, api } from '../api/client';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { setApiForOriginResolver, setUserIdForOriginResolver, setOriginFromHostnameResolver, useSpaceStore } from './spaceStore'; import {
setApiForOriginResolver,
setUserIdForOriginResolver,
setOriginFromHostnameResolver,
} from '../utils/crossStoreResolvers';
import { useSpaceStore } from './spaceStore';
import { connectInstance, disconnectInstance as disconnectWs, disconnectAllRemote } from '../hooks/useWebSocket'; import { connectInstance, disconnectInstance as disconnectWs, disconnectAllRemote } from '../hooks/useWebSocket';
// Circular dependency: federationOps imports useInstanceStore, instanceStore imports this. // Circular dependency: federationOps imports useInstanceStore, instanceStore imports this.
// Safe because both modules access each other lazily (at call time, not import time). // Safe because both modules access each other lazily (at call time, not import time).
+26 -53
View File
@@ -4,6 +4,13 @@ import { api, BackspaceApiClient } from '../api/client';
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls'; import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
import { isSelf } from '../utils/identity'; import { isSelf } from '../utils/identity';
import { sortDmChannels } from '../utils/dmSorting'; import { sortDmChannels } from '../utils/dmSorting';
import {
getApiForOrigin,
resolveOriginFromHostname,
resolveUserIdFromInstances,
getCachedUserIdForOrigin,
clearMyUserIdCache,
} from '../utils/crossStoreResolvers';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useChatStore } from './chatStore'; import { useChatStore } from './chatStore';
@@ -139,7 +146,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
_layoutUpdatedAt: 0, _layoutUpdatedAt: 0,
reset: () => { reset: () => {
_myUserIdByOrigin.clear(); clearMyUserIdCache();
set({ set({
spaces: [], spaces: [],
currentSpaceId: null, currentSpaceId: null,
@@ -951,36 +958,20 @@ export function resolveDmChannelId(rawId: string): string | null {
return null; return null;
} }
// ─── API client resolution ──────────────────────────────────────────────────── // ─── Cross-store resolvers (federation) ───────────────────────────────────────
// The actual resolver is registered by instanceStore on import, avoiding a // The resolver/setter pairs and the WS-populated user-ID cache live in
// circular dependency (instanceStore → useWebSocket → chatStore → spaceStore). // `utils/crossStoreResolvers.ts` — a neutral module with no store imports —
// to break a TDZ cycle: instanceStore registers these at top-level load, but
let _getApiForOrigin: ((origin: string) => BackspaceApiClient) | null = null; // a spaceStore-rooted import chain leaves spaceStore mid-load when that code
// runs. Re-exported here for backward compatibility with existing import
export function setApiForOriginResolver(resolver: (origin: string) => BackspaceApiClient): void { // sites. See the header comment in crossStoreResolvers.ts for details.
_getApiForOrigin = resolver; export {
} setApiForOriginResolver,
getApiForOrigin,
/** setOriginFromHostnameResolver,
* Returns the correct API client for a given instance origin. setUserIdForOriginResolver,
* '' or falsy = home instance, 'https://...' = remote instance. setMyUserIdForOrigin,
* The resolver is registered by instanceStore on import. } from '../utils/crossStoreResolvers';
*/
export function getApiForOrigin(origin: string): BackspaceApiClient {
if (!origin || !_getApiForOrigin) return api;
return _getApiForOrigin(origin);
}
// ─── Hostname → origin resolution (federation) ────────────────────────────────
// Converts a user's `homeInstance` hostname (e.g. "remote.example.com") to a
// full origin URL (e.g. "https://remote.example.com") by looking up connected
// instances. Registered by instanceStore on import.
let _resolveOriginFromHostname: ((hostname: string) => string) | null = null;
export function setOriginFromHostnameResolver(resolver: (hostname: string) => string): void {
_resolveOriginFromHostname = resolver;
}
/** /**
* Returns the instance origin for a federated user based on their homeInstance. * Returns the instance origin for a federated user based on their homeInstance.
@@ -989,7 +980,7 @@ export function setOriginFromHostnameResolver(resolver: (hostname: string) => st
export function resolveUserOrigin(user: { homeInstance?: string | null }): string { export function resolveUserOrigin(user: { homeInstance?: string | null }): string {
const host = user.homeInstance; const host = user.homeInstance;
if (!host || host === window.location.host) return ''; if (!host || host === window.location.host) return '';
return _resolveOriginFromHostname?.(host) ?? ''; return resolveOriginFromHostname(host);
} }
/** /**
@@ -1000,25 +991,7 @@ export function resolveUserOrigin(user: { homeInstance?: string | null }): strin
export function getLayoutHomeOrigin(): string { export function getLayoutHomeOrigin(): string {
const user = useAuthStore.getState().user; const user = useAuthStore.getState().user;
if (!user?.homeInstance) return ''; if (!user?.homeInstance) return '';
return _resolveOriginFromHostname?.(user.homeInstance) ?? ''; return resolveOriginFromHostname(user.homeInstance);
}
// ─── User ID resolution (federation) ──────────────────────────────────────────
// Same resolver pattern as getApiForOrigin — registered by instanceStore on
// import to break the circular dependency chain.
let _getUserIdForOrigin: ((origin: string) => string | undefined) | null = null;
export function setUserIdForOriginResolver(resolver: (origin: string) => string | undefined): void {
_getUserIdForOrigin = resolver;
}
// Direct identity cache populated from WS ready events.
// Bypasses the instanceStore resolver for reliable federation support.
const _myUserIdByOrigin = new Map<string, string>();
export function setMyUserIdForOrigin(origin: string, userId: string): void {
_myUserIdByOrigin.set(origin, userId);
} }
/** /**
@@ -1029,8 +1002,8 @@ export function setMyUserIdForOrigin(origin: string, userId: string): void {
export function getMyUserIdForOrigin(origin: string): string | undefined { export function getMyUserIdForOrigin(origin: string): string | undefined {
if (!origin) return useAuthStore.getState().user?.id; if (!origin) return useAuthStore.getState().user?.id;
// Direct cache (populated from WS ready) takes priority — always correct // Direct cache (populated from WS ready) takes priority — always correct
const cached = _myUserIdByOrigin.get(origin); const cached = getCachedUserIdForOrigin(origin);
if (cached) return cached; if (cached) return cached;
// Fallback to instanceStore resolver (may have placeholder user during connection) // Fallback to instanceStore resolver (may have placeholder user during connection)
return _getUserIdForOrigin?.(origin); return resolveUserIdFromInstances(origin);
} }
@@ -0,0 +1,99 @@
// Cross-store resolvers for federation identity and API client routing.
//
// This module exists SOLELY to break a temporal-dead-zone (TDZ) cycle between
// spaceStore and instanceStore. instanceStore runs top-level `setXResolver`
// calls at module load; the backing `let` bindings used to live in spaceStore.
// When the import graph is entered from instanceStore (e.g. via a component
// like JoinSpaceModal that imports `useInstanceStore` directly) — or any chain
// that resolves in an order where spaceStore is mid-load when instanceStore's
// top-level code runs — the `let _getApiForOrigin = null` declaration has not
// been evaluated yet, so the setter crashes with
// `Cannot access '_getApiForOrigin' before initialization`.
//
// The fix: hold the resolvers, their setters, and the local user-ID cache
// in this neutral module. It imports nothing from any store, so no back-edge
// exists and module load order between spaceStore / instanceStore / anything
// else cannot cause TDZ. spaceStore re-exports the public surface for
// backward compatibility with existing import sites.
//
// IMPORTANT: do not add imports from any `./stores/*` module here. Doing so
// re-creates the exact cycle this module was carved out to break.
import { api, BackspaceApiClient } from '../api/client';
// ─── API client resolution ────────────────────────────────────────────────────
// Registered by instanceStore on import; maps an origin to the instance-
// specific API client. `'' | null | undefined` origin always returns the home
// `api` client.
let _getApiForOrigin: ((origin: string) => BackspaceApiClient) | null = null;
export function setApiForOriginResolver(
resolver: (origin: string) => BackspaceApiClient
): void {
_getApiForOrigin = resolver;
}
export function getApiForOrigin(origin: string): BackspaceApiClient {
if (!origin || !_getApiForOrigin) return api;
return _getApiForOrigin(origin);
}
// ─── Hostname → origin resolution (federation) ────────────────────────────────
// Registered by instanceStore on import; maps a federated user's `homeInstance`
// hostname (e.g. "remote.example.com") to a full origin URL
// (e.g. "https://remote.example.com") by looking up connected instances.
let _resolveOriginFromHostname: ((hostname: string) => string) | null = null;
export function setOriginFromHostnameResolver(
resolver: (hostname: string) => string
): void {
_resolveOriginFromHostname = resolver;
}
/**
* Pure hostname→origin lookup. Returns '' if the resolver is not yet
* registered or the hostname is unknown. Callers that need to combine this
* with `window.location.host` or `authStore` state should layer on top
* (see `resolveUserOrigin` / `getLayoutHomeOrigin` in spaceStore).
*/
export function resolveOriginFromHostname(hostname: string): string {
return _resolveOriginFromHostname?.(hostname) ?? '';
}
// ─── User ID resolution (federation) ──────────────────────────────────────────
// Registered by instanceStore on import; maps an origin to the local user's
// ID on that instance. Used as a fallback for `getMyUserIdForOrigin` when the
// direct WS-populated cache below has not yet been filled.
let _getUserIdForOrigin: ((origin: string) => string | undefined) | null = null;
export function setUserIdForOriginResolver(
resolver: (origin: string) => string | undefined
): void {
_getUserIdForOrigin = resolver;
}
export function resolveUserIdFromInstances(origin: string): string | undefined {
return _getUserIdForOrigin?.(origin);
}
// ─── Direct user-ID cache (populated from WS ready events) ────────────────────
// Bypasses the instanceStore resolver for reliable federation support even
// while `instanceStore.instances[].user` is still a placeholder mid-connection.
const _myUserIdByOrigin = new Map<string, string>();
export function setMyUserIdForOrigin(origin: string, userId: string): void {
_myUserIdByOrigin.set(origin, userId);
}
export function getCachedUserIdForOrigin(origin: string): string | undefined {
return _myUserIdByOrigin.get(origin);
}
/** Clears the WS-populated user-ID cache. Called by spaceStore.reset() on logout. */
export function clearMyUserIdCache(): void {
_myUserIdByOrigin.clear();
}