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)) +})