diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/orphanViewCardKeys.test.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/orphanViewCardKeys.test.ts new file mode 100644 index 00000000..420d783b --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/orphanViewCardKeys.test.ts @@ -0,0 +1,38 @@ +// Run: node --test frontend/src/app/pages/Dashboard/hooks/lifecycle/orphanViewCardKeys.test.ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { orphanViewCardKeys } from './orphanViewCardKeys.ts'; + +const app = { id: 'app1', name: 'Calculator' }; + +test('a live app keeps its primary card', () => { + const cards = { app1: { output_id: 'app1' } }; + assert.deepEqual(orphanViewCardKeys(cards, { app1: app }), []); +}); + +test('a second instance of a live app is not an orphan', () => { + const cards = { app1: { output_id: 'app1' }, 'app1#2': { output_id: 'app1' } }; + assert.deepEqual(orphanViewCardKeys(cards, { app1: app }), []); +}); + +test('deleting the app orphans every instance of it', () => { + const cards = { + app1: { output_id: 'app1' }, + 'app1#2': { output_id: 'app1' }, + 'app1#3': { output_id: 'app1' }, + }; + assert.deepEqual(orphanViewCardKeys(cards, {}), ['app1', 'app1#2', 'app1#3']); +}); + +test('one deleted app does not take a surviving app down with it', () => { + const cards = { + app1: { output_id: 'app1' }, + 'app1#2': { output_id: 'app1' }, + app2: { output_id: 'app2' }, + }; + assert.deepEqual(orphanViewCardKeys(cards, { app2: { id: 'app2' } }), ['app1', 'app1#2']); +}); + +test('no cards means nothing to prune', () => { + assert.deepEqual(orphanViewCardKeys({}, { app1: app }), []); +}); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/orphanViewCardKeys.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/orphanViewCardKeys.ts new file mode 100644 index 00000000..e79c80f4 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/orphanViewCardKeys.ts @@ -0,0 +1,11 @@ +// A view card's record key is the bare output id ONLY for the primary; every extra instance is +// `${output_id}#N`. Reading the key as if it were an output id therefore pronounced every secondary +// instance dead on arrival and swept it off the canvas. Always resolve through output_id. +export function orphanViewCardKeys( + viewCards: Readonly>, + outputs: Readonly>, +): string[] { + return Object.entries(viewCards) + .filter(([, card]) => !outputs[card.output_id]) + .map(([cardKey]) => cardKey); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 879d09b0..be7c4319 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -31,6 +31,7 @@ import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; import { getKeepAliveBrowserIds } from '@/shared/browserFocus'; import { prepareDashboardSwitch } from '@/shared/dashboardSwitchTeardown'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import { orphanViewCardKeys } from './orphanViewCardKeys'; import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { API_BASE } from '@/shared/config'; import type { CanvasActions } from '../interaction/useCanvasControls'; @@ -313,7 +314,7 @@ export function useDashboardLifecycle({ // Prune orphan view cards whose underlying output was deleted (e.g. via the Views page). Without this, the layout entry persists in the minimap and contentBounds even though DashboardViewCard renders nothing. Gated on outputsRefetched (THIS open's fresh fetch), NOT the sticky global outputsLoaded: on a freshly-imported dashboard the global flag is already true from a prior dashboard, so the old gate pruned the just-imported app card against a stale apps list and the debounced save persisted the wipe. useEffect(() => { if (!layoutInitialized || !outputsRefetched) return; - const orphans = Object.keys(viewCards).filter((id) => !outputs[id]); + const orphans = orphanViewCardKeys(viewCards, outputs); if (orphans.length === 0) return; // Serialize teardown (quiesce each GPU surface first): pruning several orphaned app cards in one // pass would rip their webviews out simultaneously, the same GPU-process SIGSEGV as mass delete.