From 9b8e89a38dcad02d61e81b1b88c2e82f22cc0f81 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:26:25 +0800 Subject: [PATCH] Dispose conversation-owned transports on unmount Treat ConversationCard unmount and explicit close as terminal transport disposal. Disconnect owned runtime and foreground transports without reconnecting, invalidate late messages, and release foreground listeners even when provider startup fails. Keep runtime-Port test doubles aligned with browser disconnect semantics, verify replacement Ports stay component-owned across remote disconnect and clear-conversation flows, prove close entry points dispose before parent callbacks, and confirm provider-failure cleanup removes stale foreground listeners. --- src/components/ConversationCard/index.jsx | 109 +++- ...nversation-card-lifecycle-loader-hooks.mjs | 145 +++++ .../conversation-card-lifecycle.test.mjs | 556 ++++++++++++++++++ 3 files changed, 796 insertions(+), 14 deletions(-) create mode 100644 tests/setup/conversation-card-lifecycle-loader-hooks.mjs create mode 100644 tests/unit/components/conversation-card-lifecycle.test.mjs diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 6835faa6a..abb18dbb1 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -81,6 +81,10 @@ function ConversationCard(props) { const retryRecordRef = useRef(null) const retryGenerationIdRef = useRef(0) const requestGenerationIdRef = useRef(0) + const disposedRef = useRef(false) + const portRef = useRef(port) + const foregroundMessageListeners = useRef([]) + const foregroundPortsRef = useRef(new Set()) const [completeDraggable, setCompleteDraggable] = useState(false) const useForegroundFetch = isUsingBingWebModel(session) const [apiModes, setApiModes] = useState([]) @@ -108,6 +112,35 @@ function ConversationCard(props) { ? '-1' : UNMATCHED_API_MODE_VALUE + const disposeOwnedTransports = () => { + if (disposedRef.current) return + disposedRef.current = true + requestGenerationIdRef.current += 1 + retryGenerationIdRef.current += 1 + + const foregroundPorts = Array.from(foregroundPortsRef.current) + foregroundPortsRef.current.clear() + for (const foregroundPort of foregroundPorts) foregroundPort.disconnect() + foregroundMessageListeners.current = [] + + try { + portRef.current?.disconnect() + } catch (e) { + // The runtime Port may already be disconnected. + } + } + + useLayoutEffect(() => { + portRef.current = port + }, [port]) + + useLayoutEffect(() => { + disposedRef.current = false + return () => { + disposeOwnedTransports() + } + }, []) + useLayoutEffect(() => { if (session.conversationRecords.length === 0) { if (props.question && triggered) @@ -189,6 +222,7 @@ function ConversationCard(props) { } const portMessageListener = (msg) => { + if (disposedRef.current) return if (isSupersededRequestMessage(msg, requestGenerationIdRef.current)) return if (isSupersededGenerationMessage(msg, retryGenerationIdRef.current)) return @@ -278,40 +312,74 @@ function ConversationCard(props) { } } - const foregroundMessageListeners = useRef([]) - /** * @param {Session|undefined} session * @param {boolean|undefined} stop * @param {number|undefined} stopGenerationId */ const postMessage = async ({ session, stop, stopGenerationId }) => { + if (disposedRef.current) return const requestGenerationId = session ? ++requestGenerationIdRef.current : undefined if (useForegroundFetch) { - foregroundMessageListeners.current.forEach((listener) => - listener({ session, stop, stopGenerationId, requestGenerationId }), - ) + for (const listener of [...foregroundMessageListeners.current]) { + listener({ session, stop, stopGenerationId, requestGenerationId }) + } if (session) { + let disconnected = false + const messageListeners = new Set() + const disconnectListeners = new Set() + const removeForegroundMessageListener = (listener) => { + const index = foregroundMessageListeners.current.indexOf(listener) + if (index !== -1) foregroundMessageListeners.current.splice(index, 1) + } const fakePort = { postMessage: (msg) => { + if (disconnected || disposedRef.current) return portMessageListener({ ...msg, requestGenerationId }) }, onMessage: { addListener: (listener) => { + if (disconnected) return + messageListeners.add(listener) foregroundMessageListeners.current.push(listener) }, removeListener: (listener) => { - const index = foregroundMessageListeners.current.indexOf(listener) - if (index !== -1) foregroundMessageListeners.current.splice(index, 1) + messageListeners.delete(listener) + removeForegroundMessageListener(listener) }, }, onDisconnect: { - addListener: () => {}, - removeListener: () => {}, + addListener: (listener) => { + if (disconnected) { + listener() + return + } + disconnectListeners.add(listener) + }, + removeListener: (listener) => { + disconnectListeners.delete(listener) + }, + }, + disconnect: () => { + if (disconnected) return + disconnected = true + for (const listener of messageListeners) removeForegroundMessageListener(listener) + messageListeners.clear() + const listeners = Array.from(disconnectListeners) + disconnectListeners.clear() + for (const listener of listeners) { + try { + listener() + } catch (error) { + console.warn('[ConversationCard] Foreground disconnect listener failed:', error) + } + } }, } + foregroundPortsRef.current.add(fakePort) try { const bingToken = (await getUserConfig()).bingAccessToken + if (disposedRef.current || disconnected) return if (isUsingModelName('bingFreeSydney', session)) await generateAnswersWithBingWebApi( fakePort, @@ -322,7 +390,10 @@ function ConversationCard(props) { ) else await generateAnswersWithBingWebApi(fakePort, session.question, session, bingToken) } catch (err) { - handlePortError(session, fakePort, err, t) + if (!disposedRef.current && !disconnected) handlePortError(session, fakePort, err, t) + } finally { + fakePort.disconnect() + foregroundPortsRef.current.delete(fakePort) } } } else { @@ -343,13 +414,17 @@ function ConversationCard(props) { replacedPortRef.current = null return } - setPort(Browser.runtime.connect()) + if (disposedRef.current) return + const nextPort = Browser.runtime.connect() + portRef.current = nextPort + setPort(nextPort) setIsReady(true) } const closeChatsMessageListener = (message) => { if (message.type === 'CLOSE_CHATS') { - port.disconnect() + if (props.onClose) disposeOwnedTransports() + else port.disconnect() Browser.runtime.onMessage.removeListener(closeChatsMessageListener) window.removeEventListener('keydown', closeChatsEscListener) if (props.onClose) props.onClose() @@ -443,7 +518,8 @@ function ConversationCard(props) { className="gpt-util-icon" title={t('Close the Window')} onClick={() => { - port.disconnect() + if (props.onClose) disposeOwnedTransports() + else port.disconnect() if (props.onClose) props.onClose() }} > @@ -567,10 +643,13 @@ function ConversationCard(props) { error, ) } + if (disposedRef.current) return if (!useForegroundFetch) { replacedPortRef.current = port port.disconnect() - setPort(Browser.runtime.connect()) + const nextPort = Browser.runtime.connect() + portRef.current = nextPort + setPort(nextPort) } partialAnswerRef.current = '' retryRecordRef.current = null @@ -708,8 +787,10 @@ function ConversationCard(props) { try { await postMessage({ session: newSession }) } catch (e) { + if (disposedRef.current) return updateAnswer(e, false, 'error') } + if (disposedRef.current || !bodyRef.current) return bodyRef.current.scrollTo({ top: bodyRef.current.scrollHeight, behavior: 'instant', diff --git a/tests/setup/conversation-card-lifecycle-loader-hooks.mjs b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs new file mode 100644 index 000000000..db41c7a94 --- /dev/null +++ b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs @@ -0,0 +1,145 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +const stubs = new Map([ + ['../InputBox', 'test:conversation-input-box'], + ['../ConversationItem', 'test:conversation-item'], + ['../../utils', 'test:conversation-utils'], + ['@primer/octicons-react', 'test:conversation-primer-icons'], + ['react-bootstrap-icons', 'test:conversation-bootstrap-icons'], + ['file-saver', 'test:conversation-file-saver'], + ['../FloatingToolbar', 'test:conversation-floating-toolbar'], + ['../../hooks/use-clamp-window-size', 'test:conversation-window-size'], + ['../../config/index.mjs', 'test:conversation-config'], + ['react-i18next', 'test:conversation-i18n'], + ['../DeleteButton', 'test:conversation-delete-button'], + ['../../hooks/use-config.mjs', 'test:conversation-use-config'], + ['../../services/local-session.mjs', 'test:conversation-local-session'], + ['uuid', 'test:conversation-uuid'], + ['../../services/init-session.mjs', 'test:conversation-init-session'], + ['lodash-es', 'test:conversation-lodash'], + ['../../services/apis/bing-web.mjs', 'test:conversation-bing'], + ['../../services/wrappers.mjs', 'test:conversation-wrappers'], + ['../../popup/sections/api-modes-provider-utils.mjs', 'test:conversation-provider-utils'], + ['../../utils/error-text.mjs', 'test:conversation-error-text'], + ['webextension-polyfill', 'test:conversation-browser'], +]) + +const iconExports = ` + export const ArchiveIcon = () => null + export const DesktopDownloadIcon = () => null + export const LinkExternalIcon = () => null + export const MoveToBottomIcon = () => null + export const SearchIcon = () => null +` + +const sources = { + 'test:conversation-input-box': ` + export default function InputBox(props) { + globalThis.__CONVERSATION_LIFECYCLE_TEST__.inputBoxProps = props + return null + } + `, + 'test:conversation-item': 'export default function ConversationItem() { return null }', + 'test:conversation-utils': ` + export const apiModeToModelName = () => 'test-model' + export const createElementAtPosition = () => document.createElement('div') + export const getApiModesFromConfig = () => [] + export const getUniquelySelectedApiModeIndex = () => -1 + export const isFirefox = () => false + export const isMobile = () => false + export const isSafari = () => false + export const isUsingModelName = () => false + export const modelNameToDesc = () => 'Test Model' + `, + 'test:conversation-primer-icons': iconExports, + 'test:conversation-bootstrap-icons': ` + export const Pin = () => null + export const WindowDesktop = () => null + export const XLg = () => null + `, + 'test:conversation-file-saver': 'export default { saveAs() {} }', + 'test:conversation-floating-toolbar': 'export default function FloatingToolbar() { return null }', + 'test:conversation-window-size': 'export const useClampWindowSize = () => [1000, 1000]', + 'test:conversation-config': ` + export const getUserConfig = () => globalThis.__CONVERSATION_LIFECYCLE_TEST__.getUserConfig() + export const isUsingBingWebModel = () => globalThis.__CONVERSATION_LIFECYCLE_TEST__.foreground + export const Models = { customModel: { desc: 'Custom Model' } } + `, + 'test:conversation-i18n': 'export const useTranslation = () => ({ t: (value) => value })', + 'test:conversation-delete-button': ` + export default function DeleteButton(props) { + globalThis.__CONVERSATION_LIFECYCLE_TEST__.deleteButtonProps = props + return null + } + `, + 'test:conversation-use-config': ` + export const useConfig = () => globalThis.__CONVERSATION_LIFECYCLE_TEST__.config + `, + 'test:conversation-local-session': 'export const createSession = async () => {}', + 'test:conversation-uuid': "export const v4 = () => 'test-session-id'", + 'test:conversation-init-session': ` + export const initSession = (session) => ({ conversationRecords: [], ...session }) + `, + 'test:conversation-lodash': ` + export const findLastIndex = (array, predicate) => { + for (let index = array.length - 1; index >= 0; --index) { + if (predicate(array[index])) return index + } + return -1 + } + `, + 'test:conversation-bing': ` + export const generateAnswersWithBingWebApi = (...args) => + globalThis.__CONVERSATION_LIFECYCLE_TEST__.generateAnswers(...args) + `, + 'test:conversation-wrappers': 'export const handlePortError = () => {}', + 'test:conversation-provider-utils': ` + export const getApiModeDisplayLabel = () => '' + export const getConversationAiName = () => 'Test AI' + `, + 'test:conversation-error-text': 'export const getDisplayErrorText = (value) => String(value)', + 'test:conversation-browser': ` + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + export default { + runtime: { + connect: () => state.createPort(), + getURL: (path) => 'chrome-extension://test/' + path, + sendMessage: async () => {}, + onMessage: state.runtimeOnMessage, + }, + } + `, +} + +export async function resolve(specifier, context, nextResolve) { + if (context.parentURL?.endsWith('/src/components/ConversationCard/index.jsx')) { + const stubUrl = stubs.get(specifier) + if (stubUrl) return { url: stubUrl, shortCircuit: true } + } + + return nextResolve(specifier, context) +} + +export async function load(url, context, nextLoad) { + if (url.startsWith('test:conversation-')) { + return { + shortCircuit: true, + format: 'module', + source: sources[url], + } + } + + if (url.startsWith('file://') && url.endsWith('.jsx') && !url.includes('node_modules')) { + const source = await readFile(fileURLToPath(url), 'utf8') + const esbuild = await import('esbuild') + const result = await esbuild.transform(source, { + loader: 'jsx', + jsx: 'automatic', + jsxImportSource: 'preact', + }) + return { shortCircuit: true, format: 'module', source: result.code } + } + + return nextLoad(url, context) +} diff --git a/tests/unit/components/conversation-card-lifecycle.test.mjs b/tests/unit/components/conversation-card-lifecycle.test.mjs new file mode 100644 index 000000000..908122ee2 --- /dev/null +++ b/tests/unit/components/conversation-card-lifecycle.test.mjs @@ -0,0 +1,556 @@ +import assert from 'node:assert/strict' +import { register } from 'node:module' +import { cwd } from 'node:process' +import { after, afterEach, before, test } from 'node:test' +import { pathToFileURL } from 'node:url' +import { JSDOM } from 'jsdom' +import { h, render } from 'preact' +import { act } from 'preact/test-utils' + +register( + './tests/setup/conversation-card-lifecycle-loader-hooks.mjs', + pathToFileURL(cwd() + '/').href, +) + +const deferred = () => { + let resolve + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +const createEvent = () => { + const listeners = new Set() + return { + addListener(listener) { + listeners.add(listener) + }, + removeListener(listener) { + listeners.delete(listener) + }, + trigger(...args) { + for (const listener of Array.from(listeners)) listener(...args) + }, + clear() { + listeners.clear() + }, + size() { + return listeners.size + }, + } +} + +const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0)) + +const waitFor = async (predicate, message) => { + for (let attempt = 0; attempt < 50; ++attempt) { + if (predicate()) return + await nextTask() + } + assert.fail(message) +} + +let dom +let ConversationCard +const originalDescriptors = new Map() +const globalNames = ['window', 'document', 'Node', 'HTMLElement', 'Event', 'MouseEvent', 'Blob'] +const mountedContainers = new Set() + +const defaultConfig = () => ({ + lockWhenAnswer: false, + answerScrollMargin: 0, + activeApiModes: [], + customApiModes: [], + azureDeploymentName: '', + ollamaModelName: '', + customOpenAIProviders: [], + customModelName: '', + autoRegenAfterSwitchModel: false, + disableWebModeHistory: true, +}) + +const baseSession = () => ({ + conversationRecords: [], + modelName: 'test-model', + apiMode: null, + question: null, +}) + +const createRuntimePort = () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const onMessage = createEvent() + const onDisconnect = createEvent() + let disconnected = false + let disconnectCount = 0 + const port = { + onMessage, + onDisconnect, + postMessage() {}, + disconnect() { + disconnectCount += 1 + if (disconnected) return + disconnected = true + }, + emitRemoteDisconnect() { + if (disconnected) return + disconnected = true + onDisconnect.trigger() + }, + get disconnectCount() { + return disconnectCount + }, + get disconnected() { + return disconnected + }, + } + state.ports.push(port) + return port +} + +const resetState = () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + state.foreground = false + state.config = defaultConfig() + state.ports = [] + state.inputBoxProps = null + state.deleteButtonProps = null + state.configReadCount = 0 + state.getUserConfig = async () => { + state.configReadCount += 1 + return { bingAccessToken: 'token', allowEscToCloseAll: false } + } + state.generateAnswersCount = 0 + state.generateAnswers = async () => { + state.generateAnswersCount += 1 + } + state.runtimeOnMessage.clear() +} + +const mountCard = (container, props = {}) => { + mountedContainers.add(container) + act(() => { + render( + h(ConversationCard, { + session: baseSession(), + ...props, + }), + container, + ) + }) +} + +before(async () => { + dom = new JSDOM('', { url: 'https://example.com/' }) + + for (const name of globalNames) { + originalDescriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)) + Object.defineProperty(globalThis, name, { + configurable: true, + value: dom.window[name], + }) + } + + globalThis.__CONVERSATION_LIFECYCLE_TEST__ = { + runtimeOnMessage: createEvent(), + createPort: createRuntimePort, + } + resetState() + ;({ default: ConversationCard } = await import( + '../../../src/components/ConversationCard/index.jsx' + )) +}) + +afterEach(() => { + act(() => { + for (const container of mountedContainers) render(null, container) + }) + for (const container of mountedContainers) container.remove() + mountedContainers.clear() + document.body.replaceChildren() + resetState() +}) + +after(() => { + dom.window.close() + delete globalThis.__CONVERSATION_LIFECYCLE_TEST__ + + for (const [name, descriptor] of originalDescriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else delete globalThis[name] + } +}) + +test('unmount disconnects the owned runtime Port without reconnecting', () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + + mountCard(container) + assert.equal(state.ports.length, 1) + const port = state.ports[0] + assert.equal(port.onDisconnect.size(), 1) + + act(() => render(null, container)) + + assert.equal(port.disconnectCount, 1) + assert.equal(port.disconnected, true) + assert.equal(port.onDisconnect.size(), 0) + assert.equal(state.ports.length, 1) +}) + +test('remote runtime Port disconnect reconnects and unmount cleans the replacement', () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + + mountCard(container) + const initialPort = state.ports[0] + + act(() => initialPort.emitRemoteDisconnect()) + + assert.equal(initialPort.disconnectCount, 0) + assert.equal(initialPort.disconnected, true) + assert.equal(state.ports.length, 2) + const replacementPort = state.ports[1] + assert.equal(replacementPort.disconnected, false) + + act(() => render(null, container)) + + assert.equal(replacementPort.disconnectCount, 1) + assert.equal(replacementPort.disconnected, true) + assert.equal(state.ports.length, 2) +}) + +test('close button disposes before onClose and parent unmount stays idempotent', () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let closeCount = 0 + let disconnectedBeforeClose = false + + mountCard(container, { + closeable: true, + onClose: () => { + closeCount += 1 + disconnectedBeforeClose = state.ports[0].disconnected + }, + }) + + const closeButton = Array.from(container.querySelectorAll('.gpt-util-icon')).find( + (element) => element.title === 'Close the Window', + ) + assert.ok(closeButton) + + act(() => { + closeButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + const port = state.ports[0] + assert.equal(disconnectedBeforeClose, true) + assert.equal(closeCount, 1) + assert.equal(port.disconnectCount, 1) + assert.equal(state.ports.length, 1) + + act(() => render(null, container)) + + assert.equal(closeCount, 1) + assert.equal(port.disconnectCount, 1) + assert.equal(state.ports.length, 1) +}) + +test('CLOSE_CHATS disposes before onClose and parent unmount stays idempotent', () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + let closeCount = 0 + let disconnectedBeforeClose = false + + mountCard(container, { + closeable: true, + onClose: () => { + closeCount += 1 + disconnectedBeforeClose = state.ports[0].disconnected + }, + }) + + act(() => state.runtimeOnMessage.trigger({ type: 'CLOSE_CHATS' })) + + const port = state.ports[0] + assert.equal(disconnectedBeforeClose, true) + assert.equal(closeCount, 1) + assert.equal(port.disconnectCount, 1) + assert.equal(state.ports.length, 1) + + act(() => render(null, container)) + + assert.equal(closeCount, 1) + assert.equal(port.disconnectCount, 1) + assert.equal(state.ports.length, 1) +}) + +test('successful clear replacement Port is disposed on unmount', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + + mountCard(container) + await waitFor( + () => typeof state.deleteButtonProps?.onConfirm === 'function', + 'DeleteButton did not render', + ) + + const initialPort = state.ports[0] + await act(async () => { + await state.deleteButtonProps.onConfirm() + await Promise.resolve() + }) + + assert.equal(state.ports.length, 2) + const replacementPort = state.ports[1] + assert.equal(initialPort.disconnectCount, 1) + assert.equal(initialPort.disconnected, true) + assert.equal(replacementPort.disconnectCount, 0) + assert.equal(replacementPort.disconnected, false) + + act(() => render(null, container)) + + assert.equal(replacementPort.disconnectCount, 1) + assert.equal(replacementPort.disconnected, true) + assert.equal(state.ports.length, 2) +}) + +test('clear continuation does not reconnect after unmount', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + + mountCard(container) + await waitFor( + () => typeof state.deleteButtonProps?.onConfirm === 'function', + 'DeleteButton did not render', + ) + + const clear = state.deleteButtonProps.onConfirm() + act(() => render(null, container)) + await clear + + assert.equal(state.ports.length, 1) + assert.equal(state.ports[0].disconnectCount, 1) +}) + +test('unmount while foreground config is pending prevents provider startup', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const pendingConfig = deferred() + state.foreground = true + state.getUserConfig = () => { + state.configReadCount += 1 + return pendingConfig.promise + } + + const container = document.createElement('div') + document.body.append(container) + mountCard(container, { question: 'question' }) + + await waitFor(() => state.configReadCount === 1, 'foreground configuration read did not start') + act(() => render(null, container)) + + pendingConfig.resolve({ bingAccessToken: 'token' }) + await pendingConfig.promise + await nextTask() + await nextTask() + + assert.equal(state.generateAnswersCount, 0) + assert.equal(state.ports.length, 1) + assert.equal(state.ports[0].disconnectCount, 1) +}) + +test('unmount emits foreground disconnect so an active request can abort', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + state.foreground = true + let disconnectCount = 0 + state.generateAnswers = (fakePort) => { + state.generateAnswersCount += 1 + return new Promise((resolve) => { + fakePort.onDisconnect.addListener(() => { + disconnectCount += 1 + resolve() + }) + }) + } + + const container = document.createElement('div') + document.body.append(container) + mountCard(container, { question: 'question' }) + await waitFor(() => state.generateAnswersCount === 1, 'foreground provider did not start') + + assert.equal(state.generateAnswersCount, 1) + act(() => render(null, container)) + await nextTask() + + assert.equal(disconnectCount, 1) + assert.equal(state.ports.length, 1) + assert.equal(state.ports[0].disconnectCount, 1) +}) + +test('foreground disconnect cleanup survives a throwing listener', async (t) => { + t.mock.method(console, 'warn', () => {}) + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + state.foreground = true + let survivingDisconnectCount = 0 + state.generateAnswers = (fakePort) => { + state.generateAnswersCount += 1 + return new Promise((resolve) => { + fakePort.onDisconnect.addListener(() => { + throw new Error('disconnect listener failed') + }) + fakePort.onDisconnect.addListener(() => { + survivingDisconnectCount += 1 + resolve() + }) + }) + } + + const container = document.createElement('div') + document.body.append(container) + mountCard(container, { question: 'question' }) + await waitFor(() => state.generateAnswersCount === 1, 'foreground provider did not start') + + const runtimePort = state.ports[0] + act(() => render(null, container)) + + await waitFor(() => survivingDisconnectCount === 1, 'later disconnect listener did not run') + assert.equal(runtimePort.disconnectCount, 1) + assert.equal(runtimePort.disconnected, true) +}) + +test('foreground stop reaches listeners that remove themselves during dispatch', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + state.foreground = true + const stoppedRequests = [] + state.generateAnswers = (fakePort) => { + state.generateAnswersCount += 1 + const requestNumber = state.generateAnswersCount + return new Promise((resolve) => { + const stopListener = (message) => { + if (!message.stop) return + fakePort.onMessage.removeListener(stopListener) + stoppedRequests.push(requestNumber) + resolve() + } + fakePort.onMessage.addListener(stopListener) + }) + } + + const container = document.createElement('div') + document.body.append(container) + mountCard(container) + await waitFor( + () => typeof state.inputBoxProps?.postMessage === 'function', + 'InputBox did not render', + ) + + let firstSettled = false + let secondSettled = false + state.inputBoxProps + .postMessage({ session: { ...baseSession(), question: 'first' } }) + .finally(() => { + firstSettled = true + }) + await waitFor(() => state.generateAnswersCount === 1, 'first foreground request did not start') + + state.inputBoxProps + .postMessage({ session: { ...baseSession(), question: 'second' } }) + .finally(() => { + secondSettled = true + }) + await waitFor(() => state.generateAnswersCount === 2, 'second foreground request did not start') + + await state.inputBoxProps.postMessage({ stop: true, stopGenerationId: 1 }) + await waitFor(() => stoppedRequests.length === 2, 'stop did not reach every foreground request') + await waitFor( + () => firstSettled && secondSettled, + 'foreground requests did not settle after stop', + ) + + assert.deepEqual(stoppedRequests, [1, 2]) +}) + +test('foreground submit can settle safely after unmount', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + state.foreground = true + let disconnectCount = 0 + state.generateAnswers = (fakePort) => { + state.generateAnswersCount += 1 + return new Promise((resolve) => { + fakePort.onDisconnect.addListener(() => { + disconnectCount += 1 + resolve() + }) + }) + } + + const container = document.createElement('div') + document.body.append(container) + mountCard(container) + await waitFor( + () => typeof state.inputBoxProps?.onSubmit === 'function', + 'InputBox did not render', + ) + + let submitSettled = false + let submitError + const submit = state.inputBoxProps.onSubmit('question') + submit.then( + () => { + submitSettled = true + }, + (error) => { + submitError = error + submitSettled = true + }, + ) + await waitFor(() => state.generateAnswersCount === 1, 'foreground provider did not start') + act(() => render(null, container)) + await waitFor(() => submitSettled, 'foreground submit did not settle after unmount') + if (submitError) throw submitError + + assert.equal(disconnectCount, 1) + assert.equal(state.generateAnswersCount, 1) + assert.equal(state.ports[0].disconnectCount, 1) +}) + +test('foreground provider failure disconnects its fake Port and removes stale listeners', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + state.foreground = true + let disconnectCount = 0 + let staleMessageCount = 0 + state.generateAnswers = async (fakePort) => { + state.generateAnswersCount += 1 + fakePort.onMessage.addListener(() => { + staleMessageCount += 1 + }) + fakePort.onDisconnect.addListener(() => { + disconnectCount += 1 + }) + throw new Error('provider failed before cleanup') + } + + const container = document.createElement('div') + document.body.append(container) + mountCard(container, { question: 'question' }) + + await waitFor(() => state.generateAnswersCount === 1, 'foreground provider did not start') + await waitFor(() => disconnectCount === 1, 'foreground fake Port was not disconnected') + await waitFor( + () => typeof state.inputBoxProps?.postMessage === 'function', + 'InputBox did not render', + ) + + await state.inputBoxProps.postMessage({ stop: true, stopGenerationId: 1 }) + + assert.equal(staleMessageCount, 0) + assert.equal(disconnectCount, 1) + + act(() => render(null, container)) +})