diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b7672bc..20c3491 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## [v3.1.3] — Unreleased + +This release hardens target selection and chat visibility across player-frame +recovery, failed tab switches and short connection interruptions. + +### Fixed +- **Extension: Atomic target switching** — Keeps the previously working target + active until the newly selected tab has been injected successfully, so a + failed selection cannot leave the room without a target. +- **Extension: Target recovery** — Keeps the selected tab recoverable when a + transient content-script or embedded-player refresh fails. +- **Extension: Chat visibility persistence** — Remembers the user's manual + open/closed state across popup reopen, content refresh and reconnect cycles. + +### Testing +- **Release gate** — Unit, server/WebSocket, locale, theme, lint, production + dependency audit, Chrome/Firefox build, AMO validation and website build + pass locally. +- **Browser E2E** — 35 extension and player lifecycle scenarios pass. + +--- + ## [v3.1.2] — 2026-08-17 This release adds generic control for HTML5 players inside cross-origin frames. diff --git a/extension/background.js b/extension/background.js index 888a4dc..54ffd28 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2658,9 +2658,6 @@ async function activateTargetTab(tabId, tabTitle, { let injectedContentTarget = { frameId: 0, documentId: null, hasVideo: false }; try { - if (previousTabId && previousTabId !== selectedTabId) { - await deactivateTargetTab(previousTabId); - } if (activationGeneration !== targetActivationGeneration) { return { status: 'superseded' }; } @@ -2696,26 +2693,26 @@ async function activateTargetTab(tabId, tabTitle, { ); throw error; } - currentTabId = null; - currentTabTitle = null; - clearCurrentContentTarget(); - lastContentHeartbeatAt = null; - if (currentRoom) roomIdleSince = Date.now(); const failedContentTarget = error?.contentTarget || injectedContentTarget; await deactivateTargetTab(selectedTabId, failedContentTarget); - if (previousTabId && (previousTabId !== selectedTabId - || !sameContentTarget(previousContentTarget, failedContentTarget))) { - await deactivateTargetTab(previousTabId, previousContentTarget); + if (previousTabId === null) { + currentTabId = null; + currentTabTitle = null; + clearCurrentContentTarget(); + lastContentHeartbeatAt = null; + if (currentRoom) roomIdleSince = Date.now(); + await chrome.storage.session.set({ + currentTabId: null, + currentTabTitle: null, + currentTargetFrameId: 0, + currentTargetDocumentId: null, + currentTargetHasVideo: false, + roomIdleSince, + lastContentHeartbeatAt: null + }); + } else { + addLog(`Target switch to tab ${selectedTabId} failed; keeping tab ${previousTabId} selected`, 'warn'); } - await chrome.storage.session.set({ - currentTabId: null, - currentTabTitle: null, - currentTargetFrameId: 0, - currentTargetDocumentId: null, - currentTargetHasVideo: false, - roomIdleSince, - lastContentHeartbeatAt: null - }); if (activationGeneration !== targetActivationGeneration) { return { status: 'superseded' }; } @@ -2765,7 +2762,9 @@ async function activateTargetTab(tabId, tabTitle, { if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId, injectedContentTarget); return { status: 'superseded' }; } - if (previousTabId === selectedTabId + if (previousTabId && previousTabId !== selectedTabId) { + await deactivateTargetTab(previousTabId, previousContentTarget); + } else if (previousTabId === selectedTabId && !sameContentTarget(previousContentTarget, injectedContentTarget)) { await deactivateTargetTab(previousTabId, previousContentTarget, { deactivateMonitor: false }); } diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs index c7be28f..3eca8be 100644 --- a/extension/chat-overlay-contract.test.mjs +++ b/extension/chat-overlay-contract.test.mjs @@ -181,6 +181,8 @@ describe('chat overlay contract', () => { expect(overlaySource).toContain('setOpened(false, false)'); expect(overlaySource).toContain('setOpened(lastUserOpenState ?? (chatStartMode === \'open\'), false)'); expect(overlaySource).toContain('typeof data[openStateKey] === \'boolean\''); + expect(overlaySource).toContain('const previousEnabled = context?.enabled === true'); + expect(overlaySource).toContain('(!startStateApplied || !previousEnabled)'); }); it('keeps chat hidden by default without discarding the room chat key', () => { diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js index 5748c7f..48eb69b 100644 --- a/extension/chat-overlay.js +++ b/extension/chat-overlay.js @@ -596,6 +596,7 @@ function applyContext(next) { const previousRoomId = context?.roomId; + const previousEnabled = context?.enabled === true; context = next || null; const supported = !!context?.supported; const optedIn = !!context?.enabled; @@ -611,7 +612,7 @@ if (!optedIn) startStateApplied = false; if (!context?.enabled) { setOpened(false, false); - } else if (preferencesLoaded && !startStateApplied) { + } else if (preferencesLoaded && (!startStateApplied || !previousEnabled)) { startStateApplied = true; setOpened(lastUserOpenState ?? (chatStartMode === 'open'), false); } diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index cceb5eb..cb87a55 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -22,9 +22,10 @@ describe('target tab lifecycle', () => { const activationStart = backgroundSource.indexOf('async function activateTargetTab'); const activationEnd = backgroundSource.indexOf('async function reactivateCurrentTarget', activationStart); const activationSource = backgroundSource.slice(activationStart, activationEnd); - expect(activationSource.indexOf('await deactivateTargetTab(previousTabId)')) - .toBeLessThan(activationSource.indexOf('await injectContentScript(selectedTabId')); + expect(activationSource.indexOf('await injectContentScript(selectedTabId')) + .toBeLessThan(activationSource.indexOf('await deactivateTargetTab(previousTabId, previousContentTarget)')); expect(activationSource).toContain('previousTabId !== selectedTabId'); + expect(activationSource).toContain('keeping tab ${previousTabId} selected'); expect(contentSource).toContain('if (window.koalaSyncInjected && chrome.runtime.id)'); expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)'); });