From bd3751ac252216b771a1d0f2cbe3d958c220fdf7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 17:13:59 -0700 Subject: [PATCH] [eric] apps: extra instances of an app were pruned off the canvas as orphans on every open --- .../lifecycle/orphanViewCardKeys.test.ts | 38 +++++++++++++++++++ .../hooks/lifecycle/orphanViewCardKeys.ts | 11 ++++++ .../hooks/lifecycle/useDashboardLifecycle.ts | 3 +- 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/pages/Dashboard/hooks/lifecycle/orphanViewCardKeys.test.ts create mode 100644 frontend/src/app/pages/Dashboard/hooks/lifecycle/orphanViewCardKeys.ts 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.