[eric] apps: extra instances of an app were pruned off the canvas as orphans on every open

This commit is contained in:
ciregenz
2026-07-31 17:13:59 -07:00
parent bdb5f68bc0
commit bd3751ac25
3 changed files with 51 additions and 1 deletions
@@ -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 }), []);
});
@@ -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<Record<string, { output_id: string }>>,
outputs: Readonly<Record<string, unknown>>,
): string[] {
return Object.entries(viewCards)
.filter(([, card]) => !outputs[card.output_id])
.map(([cardKey]) => cardKey);
}
@@ -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.