diff --git a/agent-notes/fix-reconnect-history.md b/agent-notes/fix-reconnect-history.md new file mode 100644 index 00000000..bbf15264 --- /dev/null +++ b/agent-notes/fix-reconnect-history.md @@ -0,0 +1,45 @@ +# Reconnect history + +## Goal +Investigate and fix history clearing on reconnect; open a draft PR only. No live MUD login, game commands, production changes or deploy. + +## State +- Verified local origin and GitHub identity: MongooseMoo/react-client, default master; fetched origin, base faf66a5e. +- Isolated worktree at C:/Users/Q/Documents/Codex/2026-10-20/task-7/react-client, branch fix/reconnect-history. Original checkout's untracked notes/reports preserved. +- Read global AGENTS.md, project CLAUDE.md, output-loss notes and relevant session/output/input source. No project .agents or nested instructions found. Task explicitly authorizes implementation and full validation over older standing restrictions. +- clearForConnect resets output entries and drops owner. Output owner subscription saves previous lines then replaces its buffer from the next owner's storage. Same-owner reconfirmation should restore persisted history; testing queued output and unauthenticated gaps. + +## Decisions and why +Smallest expected change: correct output's ownership transition (~10-30 production lines) with regression coverage in the existing personalDataOwnership tests. Keep owner partitioning and logout/visitor clears. + +## Surprises +- Git safe.directory required a command-scoped exception for the original checkout (sandbox account differs); no global configuration changed. +- Initial dependencies reused through a local node_modules junction; original sources untouched. Existing dependency installation lacked axe-core, so the junction was removed (only the junction, no recursive delete) and npm ci --ignore-scripts --no-audit --no-fund installed 638 exact dependencies in this worktree. Normal Husky hooks initialized with npm run prepare. + +## Root-cause checkpoint +- Regression run on unchanged production source: 3 failed / 3 passed, personalDataOwnership.test.tsx. The missing lines were Last line 0, Visitor history, and Memory-only history. +- clearForConnect discards pending output before it is delivered. Output's owner subscription replaces allLines with loadOutput on each ownership change, making a temporary reconnect depend on successful localStorage writes. Visitor histories are intentionally never persisted, so this deterministically deletes the entire visitor log; unavailable storage does the same for owners. Ordinary successfully persisted owner history does restore after confirmation; no claim that every owner reconnect loses its saved log. +- Regression introduced with ownership partitioning (401bf753, Oct 14): connect clears owner and Output reloads storage. Later reset/launch changes and MIDI revert retain that behavior. +- Minimal fix underway: one bounded owner-keyed memory snapshot during reconnect, restored only for identical confirmed runtime/UUID/reference; explicit logout and visitor transitions invalidate it. Flush output microtask queue before taking the reconnect snapshot. No protocol or persistence changes. + +## Validation checkpoint +- Before fix: 3 new regressions failed, 3 existing tests passed (no-cache run). +- After fix: focused 6-file run passed 162 tests; full suite passed 2,748 tests in 191 files (134s). One subsequently added explicit-clear regression passed with the ownership file's full 13 tests. +- npm run typecheck and npm run build passed. Biome lint on all four changed TS/TSX files: exit 0, 17 existing output.tsx warnings, no errors. +- Production browser check-client-recovery.mjs passed repeated forced-quota reconnect, persisted reload, visitor reconnect/privacy, session recovery, and existing cases; all external requests blocked and WebSockets/API intercepted. +- Initial new browser locator incorrectly matched both visible log and live-announcement text; scoped it to .output-line-serverMessage, then browser QA passed. +- Accessibility browser script initially could not load axe-core/axe.min.js from reused dependencies; after clean lockfile install, passed: native keyboard, stable selection, modal focus, history actions, startup and reflow. +- Latest addition: clearLog drops the memory snapshot so an explicit clear during visitor reconnect cannot resurrect deleted text. +- Normal commit hook passed typecheck and staged lint (17 existing warnings, no errors). Draft PR #201: https://github.com/MongooseMoo/react-client/pull/201. Initial head 9362578016635cbe321b6ee52304fe3e26cbd592 passed exact-head CI run 39504951824, including 2,749 tests / 191 files and all production/browser checks; deploy skipped. +- No live game login/commands or production changes. No platform denial. + +## Authenticated Clear Log follow-up +- Parent reported an uncovered persisted-owner case at 9362578. Verified with a failing regression: 1 failed / 13 passed; A explicitly cleared log reappeared after reconnect confirmation. +- Cause: during reconnect storageKey is undefined. clearLog discarded the memory snapshot but never removed A's saved archive, so confirmation fell back to that archive. My initial test covered visitors only and missed this persisted-owner path. +- Small fix: include the original storageKey in the one reconnect snapshot; Clear Log removes that specific key and retains an empty snapshot until confirmation. This prevents fallback resurrection while preserving invalidation on logout/different identity and never deriving a key from another owner. +- Regression also verifies B's archive is preserved and A's cleared log stays absent after reload. Focused output/ownership tests passed 50/50; full suite passed 2,750/2,750 in 191 files (129s). Production build passed. +- Production-browser recovery QA passed a held-resume Clear Log click, confirmation and reload, alongside the original reconnect/storage/visitor cases. All requests still use fake/local fixtures. +- Follow-up commit hook passed typecheck and staged lint (17 existing output.tsx warnings, no errors). + +## Next step +Commit/push the validated authenticated Clear Log follow-up to the same draft PR #201, then verify exact head CI. Implementation and local validation complete. Keep draft, never merge/deploy. diff --git a/scripts/check-client-recovery.mjs b/scripts/check-client-recovery.mjs index e55e8a95..e3bc7b5b 100644 --- a/scripts/check-client-recovery.mjs +++ b/scripts/check-client-recovery.mjs @@ -23,15 +23,18 @@ const origin = `http://127.0.0.1:${server.address().port}`; const browser = await chromium.launch(); try { const context = await browser.newContext(); + await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort()); await context.route('https://mongoose.world/api/**', route => route.fulfill({ json: {} })); const sockets = []; let dropNextPing = false; + let holdNextResume = false; + let heldResume; const packet = (name, data) => Buffer.concat([Buffer.from([255, 250, 201]), Buffer.from(data === undefined ? name : `${name} ${JSON.stringify(data)}`), Buffer.from([255, 240])]); const owner = { uuid: 'recovery-owner', name: '#101', fullname: 'Recovery owner' }; await context.routeWebSocket('**/*', socket => { const index = sockets.length; - const record = { commands: [], pings: 0 }; + const record = { commands: [], pings: 0, socket }; sockets.push(record); let identity = owner; socket.onMessage(message => { @@ -51,10 +54,23 @@ try { const request = text.match(/Auth\.Autologin\.(Session|Resume) (\{.*\})/s); if (request) { const data = JSON.parse(request[2]); - if (request[1] === 'Resume') socket.send(packet('Char.Name', identity)); - socket.send(packet('Auth.Autologin.SessionToken', { ...data, version: 2, - playerUuid: identity.uuid, playerRef: identity.name, token: `synthetic-recovery-${index}`, - expires_at: Date.now() / 1000 + 600 })); + const reply = () => { + if (request[1] === 'Resume') { + if (data.playerUuid === 'recovery-visitor') { + identity = { uuid: 'recovery-visitor', name: '#102', fullname: 'Temporary guest' }; + } + socket.send(packet('Char.Name', identity)); + } + socket.send(packet('Auth.Autologin.SessionToken', { ...data, version: 2, + playerUuid: identity.uuid, playerRef: identity.name, token: `synthetic-recovery-${index}`, + expires_at: Date.now() / 1000 + 600 })); + }; + if (request[1] === 'Resume' && holdNextResume) { + holdNextResume = false; + heldResume = reply; + } else { + reply(); + } } }); setTimeout(() => { @@ -66,6 +82,48 @@ try { const page = await context.newPage(); await page.goto(origin); await expect.poll(() => page.evaluate(() => window.mudClient?.gmcp.require('Auth.Autologin').currentGrant?.playerUuid)).toBe(owner.uuid); + // A reconnect must retain the in-memory log even when its debounced save fails. + await page.evaluate(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function(key, value) { + if (key.startsWith('mongoose:personal:v2:output:')) throw new DOMException('Fixture quota', 'QuotaExceededError'); + return setItem.call(this, key, value); + }; + window.restoreHistoryStorage = () => { Storage.prototype.setItem = setItem; }; + }); + sockets.at(-1).socket.send(Buffer.from('Owner history retained across reconnect\r\n')); + const ownerHistory = page.locator('.output-line-serverMessage').filter({ hasText: 'Owner history retained across reconnect' }); + await expect(ownerHistory).toBeVisible(); + for (let attempt = 0; attempt < 2; attempt++) { + const count = sockets.length; + sockets.at(-1).socket.close(); + await expect.poll(() => sockets.length).toBe(count + 1); + await expect.poll(() => page.evaluate(() => window.mudClient.gmcp.require('Auth.Autologin').currentGrant?.playerUuid)).toBe(owner.uuid); + await expect(ownerHistory).toHaveCount(1); + } + await page.evaluate(() => window.restoreHistoryStorage()); + sockets.at(-1).socket.send(Buffer.from('History storage available again\r\n')); + await expect.poll(() => page.evaluate(() => Object.keys(localStorage).some(key => + key.startsWith('mongoose:personal:v2:output:') && localStorage.getItem(key).includes('Owner history retained across reconnect')))).toBe(true); + await page.reload(); + await expect.poll(() => page.evaluate(() => window.mudClient?.gmcp.require('Auth.Autologin').currentGrant?.playerUuid)).toBe(owner.uuid); + await expect(ownerHistory).toHaveCount(1); + // Clear while the owner is temporarily unknown, before the resume reply. + holdNextResume = true; + const beforeClear = sockets.length; + sockets.at(-1).socket.close(); + await expect.poll(() => sockets.length).toBe(beforeClear + 1); + await expect.poll(() => typeof heldResume).toBe('function'); + await page.getByRole('button', { name: 'Clear Log', exact: true }).click(); + heldResume(); + heldResume = undefined; + await page.waitForFunction(() => window.mudClient.gmcp.sessionReady); + await expect.poll(() => page.evaluate(() => window.mudClient.gmcp.require('Auth.Autologin').currentGrant?.playerUuid)).toBe(owner.uuid); + await expect(ownerHistory).toHaveCount(0); + await page.reload(); + await page.waitForFunction(() => window.mudClient?.gmcp.sessionReady); + await expect.poll(() => page.evaluate(() => window.mudClient?.gmcp.require('Auth.Autologin').currentGrant?.playerUuid)).toBe(owner.uuid); + await expect(ownerHistory).toHaveCount(0); await page.evaluate(async () => { const grant = window.mudClient.gmcp.require('Auth.Autologin').currentGrant; const db = await new Promise(resolve => { const request = indexedDB.open('mongoose-browser', 1); request.onsuccess = () => resolve(request.result); }); @@ -81,17 +139,27 @@ try { }); }); await page.locator('details.browser-sessions summary').click(); + const beforeVisit = sockets.length; await page.getByRole('button', { name: 'Begin temporary visit', exact: true }).click(); // The click starts an async storage/notification transition. The old owner // socket remains ready until that transition signs it out; waiting only on // connected/ready can accidentally send the guest login to that old socket. await expect(page.getByRole('button', { name: 'End temporary visit', exact: true })).toBeVisible(); - await expect.poll(() => sockets.length).toBe(2); + await expect.poll(() => sockets.length).toBe(beforeVisit + 1); await page.waitForFunction(() => window.mudClient.connected && window.mudClient.gmcp.ready); const command = page.getByRole('textbox', { name: 'Command', exact: true }); await command.fill('guest'); await command.press('Enter'); await expect.poll(() => page.evaluate(() => window.mudClient.gmcp.require('Auth.Autologin').currentGrant?.purpose)).toBe('visitor'); + sockets.at(-1).socket.send(Buffer.from('Visitor history retained across reconnect\r\n')); + const visitorHistory = page.locator('.output-line-serverMessage').filter({ hasText: 'Visitor history retained across reconnect' }); + await expect(visitorHistory).toBeVisible(); + const beforeVisitorReconnect = sockets.length; + sockets.at(-1).socket.close(); + await expect.poll(() => sockets.length).toBe(beforeVisitorReconnect + 1); + await expect.poll(() => page.evaluate(() => window.mudClient.gmcp.require('Auth.Autologin').currentGrant?.purpose)).toBe('visitor'); + await expect(visitorHistory).toHaveCount(1); await page.getByRole('button', { name: 'End temporary visit', exact: true }).click(); + await expect(visitorHistory).toHaveCount(0); await page.waitForFunction(() => window.mudClient.connected && window.mudClient.gmcp.ready); await expect(page.locator('.output-line-serverMessage').filter({ hasText: 'Recovery welcome: choose a character' })).toBeVisible(); await page.reload(); @@ -123,7 +191,7 @@ try { await expect(legacy.getByRole('alert')).toContainText('older game tab'); assert.equal(await legacy.locator('.monaco-editor').count(), 0); await page.evaluate(() => window.mudClient.close()); - console.log('Client recovery passed: visit end/reload/login, rejected unrelated owner, retry selection, resumption without push permission, correlated reconnect recovery and explicit obsolete editor feedback.'); + console.log('Client recovery passed: repeated reconnect with failed history saves, persisted reload, authenticated Clear Log during reconnect and reload, visitor history retention and privacy, visit end/reload/login, rejected unrelated owner, retry selection, resumption without push permission, correlated reconnect recovery and explicit obsolete editor feedback.'); } finally { await browser.close(); await new Promise(done => server.close(done)); diff --git a/src/components/output.tsx b/src/components/output.tsx index 8db605e6..211f0f85 100644 --- a/src/components/output.tsx +++ b/src/components/output.tsx @@ -188,6 +188,7 @@ class Output extends React.Component { private unsubscribeOwner: (() => void) | undefined; private storageKey = personalStorageKey('output'); private ownerIdentity = usePersonalDataStore.getState().owner; + private reconnectingOutput?: { owner: string; lines: OutputLine[]; storageKey?: string }; private previousConnected = useConnectionStore.getState().connected; private previousSessionReady = useConnectionStore.getState().sessionReady; private lastOutputEntryId = 0; @@ -582,13 +583,23 @@ componentDidUpdate( componentDidMount() { this.unsubscribeOwner = usePersonalDataStore.subscribe(() => { const key = personalStorageKey('output'); - const owner = usePersonalDataStore.getState().owner; + const { owner, reconnectingOwner } = usePersonalDataStore.getState(); + // Keep a single in-memory history while identity is unconfirmed. Storage + // is best effort (and deliberately disabled for visitors), not a handoff. + if (!owner) { + if (!reconnectingOwner) this.reconnectingOutput = undefined; + else if (this.ownerIdentity && ownerKey(this.ownerIdentity) === ownerKey(reconnectingOwner)) { + this.reconnectingOutput = { owner: ownerKey(reconnectingOwner), lines: this.allLines, storageKey: this.storageKey }; + } + } if (key === this.storageKey && (owner ? ownerKey(owner) : '') === (this.ownerIdentity ? ownerKey(this.ownerIdentity) : '')) return; this.cancelScheduledSave(); this.saveOutput(); this.storageKey = key; this.ownerIdentity = owner; - this.allLines = this.loadOutput(); + this.allLines = owner && this.reconnectingOutput?.owner === ownerKey(owner) + ? this.reconnectingOutput.lines : this.loadOutput(); + if (owner) this.reconnectingOutput = undefined; this.lastOutputEntryId = 0; this.totalLinesAdded = this.prevTotalLinesAdded = this.allLines.length; this.frozenRef.current?.replaceChildren(); @@ -802,6 +813,8 @@ scrollToBottom = () => { const output = this.outputRef.current; if (output) { clearLog() { // Cancel any pending debounced save so it can't fire after the clear and re-persist stale data. this.cancelScheduledSave(); + const storageKey = this.storageKey ?? this.reconnectingOutput?.storageKey; + if (this.reconnectingOutput) this.reconnectingOutput.lines = []; this.allLines = []; this.frozenCount = 0; this.frozenHiddenCount = 0; @@ -810,7 +823,7 @@ scrollToBottom = () => { const output = this.outputRef.current; if (output) { const frozenDiv = this.frozenRef.current; if (frozenDiv) frozenDiv.innerHTML = ''; this.setState({ liveOutput: [] }); - if (this.storageKey) removeStoredValue(this.storageKey); + if (storageKey) removeStoredValue(storageKey); } async copyLog() { diff --git a/src/components/personalDataOwnership.test.tsx b/src/components/personalDataOwnership.test.tsx index 0d4cc418..b8c86268 100644 --- a/src/components/personalDataOwnership.test.tsx +++ b/src/components/personalDataOwnership.test.tsx @@ -1,10 +1,12 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { createRef } from 'react'; +import { announce } from '@react-aria/live-announcer'; import type MudClient from '../client'; import { usePersonalDataStore, personalStorageKey } from '../stores/personalDataStore'; import { useOutputStore } from '../stores/outputStore'; import { useInputStore } from '../stores/inputStore'; +import { useConnectionStore } from '../stores/connectionStore'; import Output from './output'; import CommandInput from './input'; @@ -21,11 +23,144 @@ beforeEach(() => { clear: () => values.clear(), }); localStorage.clear(); - usePersonalDataStore.setState({ policyReady: true, owner: undefined, visitor: false }); + usePersonalDataStore.setState({ policyReady: true, owner: undefined, reconnectingOwner: undefined, visitor: false, visitorWindow: false }); useOutputStore.getState().reset(); useInputStore.getState().clear(); + useConnectionStore.getState().reset(); +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals(); }); + +it('restores the same owner history after repeated reconnects, including queued output', async () => { + usePersonalDataStore.getState().confirm(a); + render(); + await act(async () => useOutputStore.getState().addMessage('Before the disconnect')); + for (let reconnect = 0; reconnect < 2; reconnect++) { + await act(async () => { + useOutputStore.getState().addMessage(`Last line ${reconnect}`); + usePersonalDataStore.getState().clearForConnect(); + }); + expect(screen.queryByText('Before the disconnect')).not.toBeInTheDocument(); + await act(async () => usePersonalDataStore.getState().confirm(a)); + expect(screen.getAllByText('Before the disconnect')).toHaveLength(1); + expect(screen.getAllByText(`Last line ${reconnect}`)).toHaveLength(1); + } +}); + +it('restores a visitor history after reconnect without persisting it', async () => { + usePersonalDataStore.setState({ visitor: true, visitorWindow: true }); + usePersonalDataStore.getState().confirm(a); + render(); + await act(async () => useOutputStore.getState().addMessage('Visitor history')); + await act(async () => usePersonalDataStore.getState().clearForConnect()); + await act(async () => usePersonalDataStore.getState().confirm(a)); + expect(screen.getAllByText('Visitor history')).toHaveLength(1); + expect(localStorage.getItem(`mongoose:personal:v2:output:${JSON.stringify([a.runtime, a.playerUuid, a.playerRef])}`)).toBeNull(); +}); + +it('restores the same owner history when browser storage is unavailable', async () => { + vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new DOMException('Storage disabled', 'SecurityError'); }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + usePersonalDataStore.getState().confirm(a); + render(); + await act(async () => useOutputStore.getState().addMessage('Memory-only history')); + await act(async () => usePersonalDataStore.getState().clearForConnect()); + await act(async () => usePersonalDataStore.getState().clearForConnect()); + await act(async () => usePersonalDataStore.getState().confirm(a)); + expect(screen.getAllByText('Memory-only history')).toHaveLength(1); +}); + +it.each([ + ['different player', () => usePersonalDataStore.getState().confirm(b)], + ['recycled player', () => usePersonalDataStore.getState().confirm({ ...a, playerUuid: 'replacement' })], + ['different server', () => usePersonalDataStore.getState().confirm({ ...a, runtime: 'other-server' })], + ['sign-out', () => { usePersonalDataStore.getState().clear(); usePersonalDataStore.getState().confirm(a); }], + ['visitor transition', () => { usePersonalDataStore.getState().setVisitor(false); usePersonalDataStore.getState().confirm(a); }], +])('discards the reconnect snapshot on %s', async (_label, transition) => { + usePersonalDataStore.setState({ visitor: true, visitorWindow: true }); + usePersonalDataStore.getState().confirm(a); + render(); + await act(async () => useOutputStore.getState().addMessage('Private visitor log')); + await act(async () => usePersonalDataStore.getState().clearForConnect()); + expect(screen.queryByText('Private visitor log')).not.toBeInTheDocument(); + await act(async () => transition()); + expect(screen.queryByText('Private visitor log')).not.toBeInTheDocument(); + // Even selecting A again cannot recover a snapshot discarded by a transition. + await act(async () => usePersonalDataStore.getState().confirm(a)); + expect(screen.queryByText('Private visitor log')).not.toBeInTheDocument(); +}); + +it('saves restored output for reload without replaying or reannouncing history', async () => { + usePersonalDataStore.getState().confirm(a); + const view = render(); + await act(async () => { + for (let line = 0; line < Output.LIVE_WINDOW_SIZE + 5; line++) { + useOutputStore.getState().addMessage(`Retained line ${line}`); + } + useOutputStore.getState().addMessage('Provisional away line', true); + }); + vi.mocked(announce).mockClear(); + await act(async () => usePersonalDataStore.getState().clearForConnect()); + await act(async () => usePersonalDataStore.getState().confirm(a)); + await act(async () => useConnectionStore.getState().setSessionReady(true)); + expect(screen.queryByText('Provisional away line')).not.toBeInTheDocument(); + expect(announce).not.toHaveBeenCalled(); + await act(async () => useOutputStore.getState().addMessage('Authoritative away line')); + expect(announce).toHaveBeenCalledTimes(1); + expect(screen.getAllByText('Retained line 0')).toHaveLength(1); + expect(screen.getAllByText('Authoritative away line')).toHaveLength(1); + view.unmount(); + useOutputStore.getState().reset(); + usePersonalDataStore.getState().clearForConnect(); + usePersonalDataStore.getState().confirm(a); + vi.mocked(announce).mockClear(); + render(); + expect(screen.getAllByText('Retained line 0')).toHaveLength(1); + expect(screen.getAllByText('Authoritative away line')).toHaveLength(1); + expect(screen.queryByText('Provisional away line')).not.toBeInTheDocument(); + expect(announce).not.toHaveBeenCalled(); +}); + +it('does not restore a visitor log explicitly cleared during reconnect', async () => { + usePersonalDataStore.setState({ visitor: true, visitorWindow: true }); + usePersonalDataStore.getState().confirm(a); + const output = createRef(); + render(); + await act(async () => useOutputStore.getState().addMessage('Explicitly cleared visitor log')); + await act(async () => usePersonalDataStore.getState().clearForConnect()); + await act(async () => output.current?.clearLog()); + await act(async () => usePersonalDataStore.getState().confirm(a)); + expect(screen.queryByText('Explicitly cleared visitor log')).not.toBeInTheDocument(); +}); + +it('clears persisted owner history during reconnect without touching another owner', async () => { + vi.useFakeTimers(); + usePersonalDataStore.getState().confirm(b); + const output = createRef(); + const view = render(); + await act(async () => useOutputStore.getState().addMessage('B saved log')); + await act(async () => usePersonalDataStore.getState().confirm(a)); + const bKey = personalStorageKey('output', b) ?? ''; + const aKey = personalStorageKey('output', a) ?? ''; + await act(async () => useOutputStore.getState().addMessage('A explicitly cleared log')); + await act(async () => usePersonalDataStore.getState().clearForConnect()); + expect(localStorage.getItem(aKey)).toContain('A explicitly cleared log'); + await act(async () => output.current?.clearLog()); + await act(async () => vi.advanceTimersByTime(600)); + await act(async () => usePersonalDataStore.getState().confirm(a)); + expect(screen.queryByText('A explicitly cleared log')).not.toBeInTheDocument(); + expect(localStorage.getItem(aKey) ?? '').not.toContain('A explicitly cleared log'); + expect(localStorage.getItem(bKey)).toContain('B saved log'); + await act(async () => usePersonalDataStore.getState().confirm(b)); + expect(screen.getAllByText('B saved log')).toHaveLength(1); + await act(async () => usePersonalDataStore.getState().confirm(a)); + view.unmount(); + useOutputStore.getState().reset(); + usePersonalDataStore.getState().clearForConnect(); + usePersonalDataStore.getState().confirm(a); + render(); + expect(screen.queryByText('A explicitly cleared log')).not.toBeInTheDocument(); + expect(screen.queryByText('B saved log')).not.toBeInTheDocument(); }); -afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); }); it('leaves legacy output and login commands unattributed before authentication', async () => { localStorage.setItem('outputLog', JSON.stringify({ version: 2, data: [{ diff --git a/src/stores/outputStore.ts b/src/stores/outputStore.ts index 3d16e543..b4d2a7d5 100644 --- a/src/stores/outputStore.ts +++ b/src/stores/outputStore.ts @@ -21,6 +21,7 @@ interface OutputState { addHtml: (html: string) => void; addError: (error: Error) => void; addCommand: (command: string) => void; + flush: () => void; reset: () => void; } @@ -48,6 +49,7 @@ export const useOutputStore = create((set) => ({ addHtml: (html) => addOutputEntry(set, { type: "html", html }), addError: (error) => addOutputEntry(set, { type: "error", error }), addCommand: (command) => addOutputEntry(set, { type: "command", command }), + flush: () => flushPendingEntries(set), reset: () => { nextOutputEntryId = 1; pendingEntries = []; diff --git a/src/stores/personalDataStore.ts b/src/stores/personalDataStore.ts index 2a666a2f..00816e94 100644 --- a/src/stores/personalDataStore.ts +++ b/src/stores/personalDataStore.ts @@ -14,6 +14,7 @@ export function ownerKey(owner: PersonalDataOwner): string { interface PersonalDataState { owner?: PersonalDataOwner; + reconnectingOwner?: PersonalDataOwner; visitor: boolean; visitorWindow: boolean; policyReady: boolean; @@ -49,14 +50,20 @@ export const usePersonalDataStore = create((set, get) => ({ if (previous && ownerKey(previous) === ownerKey(owner)) return; // Switching from one owner to another protects the first from the second. clearTransientData(!previous); - set({ owner }); + set({ owner, reconnectingOwner: undefined }); + }, + clear() { clearTransientData(false); set({ owner: undefined, reconnectingOwner: undefined }); }, + clearForConnect() { + // Deliver the last transport's queued lines before output takes its snapshot. + useOutputStore.getState().flush(); + const reconnectingOwner = get().owner ?? get().reconnectingOwner; + clearTransientData(true); + set({ owner: undefined, reconnectingOwner }); }, - clear() { clearTransientData(false); set({ owner: undefined }); }, - clearForConnect() { clearTransientData(true); set({ owner: undefined }); }, setVisitor(visitor) { if (visitor === get().visitor) return; clearTransientData(false); - set({ visitor }); + set({ visitor, reconnectingOwner: undefined }); }, }));