diff --git a/packages/web/src/stores/instanceStore.failover.test.ts b/packages/web/src/stores/instanceStore.failover.test.ts index 5952fbf1..7a55b65f 100644 --- a/packages/web/src/stores/instanceStore.failover.test.ts +++ b/packages/web/src/stores/instanceStore.failover.test.ts @@ -39,16 +39,16 @@ beforeEach(() => { }); describe('instanceStore failover triggers', () => { - it('fires failover on connected → disconnected transition', async () => { + it('fires failover on connected → disconnected transition', () => { useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); useInstanceStore.getState().setInstanceStatus('https://b.example', 'disconnected'); - await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); + expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example'); }); - it('fires failover on connected → error transition', async () => { + it('fires failover on connected → error transition', () => { useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); useInstanceStore.getState().setInstanceStatus('https://b.example', 'error'); - await vi.waitFor(() => expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example')); + expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example'); }); it('does not fire on connecting → connected', () => { @@ -67,4 +67,36 @@ describe('instanceStore failover triggers', () => { useInstanceStore.getState().setInstanceStatus('https://unknown.example', 'disconnected'); expect(mockFailover).not.toHaveBeenCalled(); }); + + it('disconnectInstance runs failover before removeInstanceSpaces', async () => { + // Seed a DM pinned to b.example with home as alternative — removeInstanceSpaces + // uses spaceStore, which we let run; we just check failover ran first (call order). + const spaceModule = await import('./spaceStore'); + const spaceSpy = vi.spyOn(spaceModule.useSpaceStore.getState(), 'removeInstanceSpaces'); + const callOrder: string[] = []; + mockFailover.mockImplementation(() => { callOrder.push('failover'); }); + spaceSpy.mockImplementation(() => { callOrder.push('removeInstanceSpaces'); }); + + useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); + useInstanceStore.getState().disconnectInstance('https://b.example'); + await Promise.resolve(); + + expect(callOrder).toEqual(['failover', 'removeInstanceSpaces']); + spaceSpy.mockRestore(); + }); + + it('forceRemoveEntry runs failover before removeInstanceSpaces', async () => { + const spaceModule = await import('./spaceStore'); + const spaceSpy = vi.spyOn(spaceModule.useSpaceStore.getState(), 'removeInstanceSpaces'); + const callOrder: string[] = []; + mockFailover.mockImplementation(() => { callOrder.push('failover'); }); + spaceSpy.mockImplementation(() => { callOrder.push('removeInstanceSpaces'); }); + + useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] }); + useInstanceStore.getState().forceRemoveEntry('https://b.example'); + await Promise.resolve(); + + expect(callOrder).toEqual(['failover', 'removeInstanceSpaces']); + spaceSpy.mockRestore(); + }); }); diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 7bec2e76..7dcbfa26 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -8,6 +8,9 @@ import { connectInstance, disconnectInstance as disconnectWs, disconnectAllRemot // Safe because both modules access each other lazily (at call time, not import time). // clearPasswordSyncTimers itself does not reference useInstanceStore. import { clearPasswordSyncTimers } from '../utils/federationOps'; +// dmOriginFailover lazily reads useInstanceStore/useSpaceStore/useChatStore at call time, +// so a static import here does not create an import-time cycle. +import { failoverDmOriginsFromDisconnected } from '../utils/dmOriginFailover'; import { useUIStore } from './uiStore'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -435,11 +438,7 @@ export const useInstanceStore = create((set, get) => ({ ), })); if (prev === 'connected' && (status === 'disconnected' || status === 'error')) { - // Dynamic import keeps the circular-dep-safe resolver pattern used elsewhere - // in this file. Fire-and-forget: failover reads state at call time. - import('../utils/dmOriginFailover').then(({ failoverDmOriginsFromDisconnected }) => { - failoverDmOriginsFromDisconnected(origin); - }); + failoverDmOriginsFromDisconnected(origin); } }, @@ -467,7 +466,10 @@ export const useInstanceStore = create((set, get) => ({ return { instances: updated, registry, registryUpdatedAt }; }); - // Remove spaces from this instance from the space store + // Failover DMs to a connected sibling BEFORE removeInstanceSpaces wipes + // this origin's pins. DMs with a connected alternative survive via rekey; + // DMs without one are removed alongside the rest of the instance's content. + failoverDmOriginsFromDisconnected(origin); useSpaceStore.getState().removeInstanceSpaces(origin); // Sync updated lists to remaining instances (fire-and-forget) @@ -757,7 +759,8 @@ export const useInstanceStore = create((set, get) => ({ return { instances: updated, registry, registryUpdatedAt }; }); - // Clean up spaces belonging to this instance + // Same rationale as disconnectInstance — preserve DMs with connected alts. + failoverDmOriginsFromDisconnected(origin); useSpaceStore.getState().removeInstanceSpaces(origin); get().syncRegistry().catch(() => {});