fix(web): extract cross-store resolvers into neutral utility to break TDZ

instanceStore registers three resolver functions at module load —
setApiForOriginResolver, setUserIdForOriginResolver,
setOriginFromHostnameResolver — whose backing `let` bindings used to
live in spaceStore. When the module graph was entered from
instanceStore (e.g. JoinSpaceModal importing useInstanceStore) the
order became spaceStore → chatStore → useWebSocket → socialStore →
instanceStore (top-level setter call) while spaceStore was still
paused on its line-8 chatStore import, so the backing `let` had not
been reached yet and the setter crashed with
`Cannot access '_getApiForOrigin' before initialization`. This left
InviteModal.test.tsx and JoinSpace.test.tsx unable to even load their
suites once AudioManager was mocked away.

Move the three `let` bindings, their setters, their pure getters, plus
the WS-populated user-ID cache (`_myUserIdByOrigin`, setMyUserIdForOrigin,
getCachedUserIdForOrigin, clearMyUserIdCache) into
`packages/web/src/utils/crossStoreResolvers.ts`. The utility imports
nothing from `./stores/*`, so no back-edge exists. spaceStore re-exports
the public surface for backward compatibility with the many existing
import sites; instanceStore imports the setters directly from the
utility (the in-cycle re-export path does not resolve at module-init
time under vite-ssr, so a direct import is required for the top-level
setter calls).

spaceStore's remaining wrappers (resolveUserOrigin, getLayoutHomeOrigin,
getMyUserIdForOrigin) stay where they are — they combine the utility's
pure lookups with authStore state — but now delegate to the utility.

Also adds the AudioManager mock to InviteModal.test.tsx and
JoinSpace.test.tsx so their suites actually load (same pattern already
used in 5 other test files). Net test-suite result: 127/131 pass (up
from 121/121 — +6 newly unlockable). The 4 remaining JoinSpace
failures are pre-existing stale UI-text assertions (the placeholder was
expanded and the submit button was made disable-when-empty) made
visible by the suite now loading; they're orthogonal to this change
and handed back for a separate triage.

Closes backlog #27.
This commit is contained in:
Jannis Braun
2026-04-23 02:46:19 +02:00
parent 15d2a3f163
commit 4d2e50b55b
6 changed files with 164 additions and 57 deletions
+6 -1
View File
@@ -2,7 +2,12 @@ import { create } from 'zustand';
import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse, FederationRegistryEntry } from '@backspace/shared';
import { BackspaceApiClient, createApiClient, api } from '../api/client';
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';
// Circular dependency: federationOps imports useInstanceStore, instanceStore imports this.
// 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 { isSelf } from '../utils/identity';
import { sortDmChannels } from '../utils/dmSorting';
import {
getApiForOrigin,
resolveOriginFromHostname,
resolveUserIdFromInstances,
getCachedUserIdForOrigin,
clearMyUserIdCache,
} from '../utils/crossStoreResolvers';
import { useAuthStore } from './authStore';
import { useChatStore } from './chatStore';
@@ -139,7 +146,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
_layoutUpdatedAt: 0,
reset: () => {
_myUserIdByOrigin.clear();
clearMyUserIdCache();
set({
spaces: [],
currentSpaceId: null,
@@ -951,36 +958,20 @@ export function resolveDmChannelId(rawId: string): string | null {
return null;
}
// ─── API client resolution ────────────────────────────────────────────────────
// The actual resolver is registered by instanceStore on import, avoiding a
// circular dependency (instanceStore → useWebSocket → chatStore → spaceStore).
let _getApiForOrigin: ((origin: string) => BackspaceApiClient) | null = null;
export function setApiForOriginResolver(resolver: (origin: string) => BackspaceApiClient): void {
_getApiForOrigin = resolver;
}
/**
* Returns the correct API client for a given instance origin.
* '' or falsy = home instance, 'https://...' = remote instance.
* The resolver is registered by instanceStore on import.
*/
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;
}
// ─── Cross-store resolvers (federation) ───────────────────────────────────────
// The resolver/setter pairs and the WS-populated user-ID cache live in
// `utils/crossStoreResolvers.ts` — a neutral module with no store imports —
// to break a TDZ cycle: instanceStore registers these at top-level load, but
// a spaceStore-rooted import chain leaves spaceStore mid-load when that code
// runs. Re-exported here for backward compatibility with existing import
// sites. See the header comment in crossStoreResolvers.ts for details.
export {
setApiForOriginResolver,
getApiForOrigin,
setOriginFromHostnameResolver,
setUserIdForOriginResolver,
setMyUserIdForOrigin,
} from '../utils/crossStoreResolvers';
/**
* 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 {
const host = user.homeInstance;
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 {
const user = useAuthStore.getState().user;
if (!user?.homeInstance) return '';
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);
return resolveOriginFromHostname(user.homeInstance);
}
/**
@@ -1029,8 +1002,8 @@ export function setMyUserIdForOrigin(origin: string, userId: string): void {
export function getMyUserIdForOrigin(origin: string): string | undefined {
if (!origin) return useAuthStore.getState().user?.id;
// Direct cache (populated from WS ready) takes priority — always correct
const cached = _myUserIdByOrigin.get(origin);
const cached = getCachedUserIdForOrigin(origin);
if (cached) return cached;
// Fallback to instanceStore resolver (may have placeholder user during connection)
return _getUserIdForOrigin?.(origin);
return resolveUserIdFromInstances(origin);
}