From a5af1cbfa332912f0fdf35e18997a0dd116ed178 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 23:53:10 -0700 Subject: [PATCH] fix: restore independent UI observer and subscription teardown --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 6 ++ scripts/format-scope.json | 3 +- scripts/qa-ui-disposal.mjs | 123 +++++++++++++++++++++++++++++++++++++ src/ui.js | 10 +++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-ui-disposal.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bddf56bb..a83bbbe08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction. + - Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup. - Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc911cb8d..cb2616ef0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +## UI disposal + +UI disposal releases the CCTV subscription, command-dock observer, legacy drag +resize observer and window resize listener. These remain independent of the +Location component and are included in whole-UI browser teardown acceptance. + ## Radio controls Radio panel, compact controls and tuner presentation have a dedicated owner. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5e0a8b660..91918223e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -172,5 +172,6 @@ "src/ui/radioBindings.js", "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js", - "src/ui/radioControls.test.mjs" + "src/ui/radioControls.test.mjs", + "scripts/qa-ui-disposal.mjs" ] diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs new file mode 100644 index 000000000..155d9116a --- /dev/null +++ b/scripts/qa-ui-disposal.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** Exercise the real UI disposal path, including unrelated resource owners. */ +import puppeteer from 'puppeteer'; +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +function check(name, ok) { + console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); + if (!ok) failures++; +} +try { + await page.goto( + `${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction( + () => + window.__godsEyeView?.styleManager?._dataManager && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 60000 }, + ); + const result = await page.evaluate(async () => { + const ui = window.__godsEyeView.styleManager; + const counts = {}; + const watchObserver = (name) => { + const observer = ui[name]; + if (!observer) return false; + const disconnect = observer.disconnect.bind(observer); + counts[name] = 0; + observer.disconnect = () => { + counts[name]++; + return disconnect(); + }; + return true; + }; + const observed = [ + '_commandDockTrayObserver', + '_draggableResizeObserver', + '_leftStackResizeObserver', + '_rightStackResizeObserver', + '_leftStackMutationObserver', + '_rightStackMutationObserver', + ].filter(watchObserver); + const cctvUnsubscribe = ui._cctvUnsubscribe; + counts.cctv = 0; + if (cctvUnsubscribe) + ui._cctvUnsubscribe = () => { + counts.cctv++; + return cctvUnsubscribe(); + }; + const resizeHandler = ui._windowResizeHandler; + const removeEventListener = window.removeEventListener; + counts.resize = 0; + window.removeEventListener = function (type, callback, options) { + if (type === 'resize' && callback === resizeHandler) counts.resize++; + return removeEventListener.call(this, type, callback, options); + }; + try { + await ui.dispose(); + const once = JSON.stringify(counts); + await ui.dispose(); + window.dispatchEvent(new Event('resize')); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return { + connected: Boolean( + cctvUnsubscribe && + resizeHandler && + observed.includes('_commandDockTrayObserver'), + ), + observersReleased: observed.every( + (name) => counts[name] === 1 && ui[name] === null, + ), + subscriptionReleased: counts.cctv === 1 && ui._cctvUnsubscribe === null, + resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null, + controlsReleased: + ui._radioControls.destroyed && ui._locationControls.destroyed, + idempotent: once === JSON.stringify(counts), + }; + } finally { + window.removeEventListener = removeEventListener; + } + }); + check( + 'real UI has the expected live resources before disposal', + result.connected, + ); + check( + 'UI disposal disconnects each active panel observer once', + result.observersReleased, + ); + check( + 'UI disposal releases the CCTV state subscription', + result.subscriptionReleased, + ); + check( + 'UI disposal removes the registered window resize handler', + result.resizeReleased, + ); + check( + 'Radio and Location controls are disposed with the UI', + result.controlsReleased, + ); + check('repeated UI disposal is inert', result.idempotent); + check( + 'disposal and subsequent resize produce no uncaught browser errors', + errors.length === 0, + ); +} finally { + await browser.close(); +} +if (failures) process.exitCode = 1; diff --git a/src/ui.js b/src/ui.js index 634c0e5b3..d8bf096bc 100644 --- a/src/ui.js +++ b/src/ui.js @@ -8594,6 +8594,16 @@ export class StyleManager { this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe = null; + this._cctvUnsubscribe?.(); + this._cctvUnsubscribe = null; + this._commandDockTrayObserver?.disconnect?.(); + this._commandDockTrayObserver = null; + this._draggableResizeObserver?.disconnect(); + this._draggableResizeObserver = null; + if (this._windowResizeHandler) { + window.removeEventListener('resize', this._windowResizeHandler); + this._windowResizeHandler = null; + } if (this._loadingVisibilityHandler) { document.removeEventListener('visibilitychange', this._loadingVisibilityHandler); this._loadingVisibilityHandler = null;