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:
@@ -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';
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user