diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index d44dad290..a1ffc81d5 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -137,6 +137,11 @@ const profileConfig = inject('profileConfig', ref(null)) // over with a counter, because a key that changes between mounts would be worse than one that // repeats — a gate could not name the instance it wants. // A function, not a computed: it is read twice inside the map-ready path, never rendered. +// The exact objects THIS instance put on window, so teardown can identity-check before deleting a +// shared named slot — the last mount wins that slot, so a blind delete would strip a live sibling's +// handle. Captured at assignment rather than rebuilt, so they cannot drift from what was published. +let _mySearchHandle = null, _myMapHandle = null, _mySelectHandle = null + function testHandleKey() { return profileConfig.value?.instanceId || profileConfig.value?.profile || 'upg-100-list' } @@ -973,6 +978,7 @@ function buildSearch(records) { }, } // Mirrored per instance, same reason and shape as __UPG_MAPS__ (AUDIT34 card 9). + _mySearchHandle = window.__UPG_SEARCH__ ;(window.__UPG_SEARCHES__ ||= {})[testHandleKey()] = window.__UPG_SEARCH__ } } @@ -2273,6 +2279,7 @@ onMounted(async () => { // instance it wants can ask for it; a caller that does not keeps today's behaviour. if (typeof window !== 'undefined') { window.__UPG_MAP__ = map + _myMapHandle = map ;(window.__UPG_MAPS__ ||= {})[testHandleKey()] = map } @@ -2373,6 +2380,7 @@ onMounted(async () => { }, } // Mirrored per instance, same reason and same additive shape as __UPG_MAPS__ above. + _mySelectHandle = window.__UPG_SELECT__ ;(window.__UPG_SELECTS__ ||= {})[testHandleKey()] = window.__UPG_SELECT__ // Legend tree + selectors are already built (bootstrapLegend() at the top of @@ -2770,6 +2778,33 @@ onBeforeUnmount(() => { if (_detachEdges) { try { _detachEdges() } catch (_) {} _detachEdges = null } if (map) { map.remove(); map = null } tapestry = null + + // ── RELEASE EVERY WINDOW GLOBAL THIS PROFILE SET ──────────────────────────── + // ★ A window key holding a closure PINS THE WHOLE APP after unmount — the component, its shadow + // root and everything they reference — measured at roughly 10 MB per mount. The test handles below + // close over searchIndex, the map and selectedTerritories, so each one is a full retention path. + // ⚠ SIX GLOBALS, NOT THREE. Each named handle has a paired per-instance REGISTRY + // (__UPG_SEARCH__/__UPG_SEARCHES__, __UPG_MAP__/__UPG_MAPS__, __UPG_SELECT__/__UPG_SELECTS__), and + // releasing only the named half leaves the registry holding the same closure — the leak survives + // in the copy nobody was looking at. Release the SET. + // ⚠ AND THE TWO HALVES NEED OPPOSITE HANDLING, which is why this is not one loop: + // · a NAMED handle is a single slot the last mount overwrites, so deleting it blindly would strip + // a LIVE sibling's handle. Identity-check: clear it only if it is still MINE. + // · a REGISTRY entry is keyed by instance, so my key is always mine to delete — and deleting the + // whole registry would strip the siblings. + // One delete per line, deliberately: the gate reads these statements, and a loop over a key list + // would release correctly and still be reported as a leak. + if (typeof window !== 'undefined') { + const k = testHandleKey() + try { + if (window.__UPG_SEARCHES__) delete window.__UPG_SEARCHES__[k] + if (window.__UPG_MAPS__) delete window.__UPG_MAPS__[k] + if (window.__UPG_SELECTS__) delete window.__UPG_SELECTS__[k] + if (window.__UPG_SEARCH__ === _mySearchHandle) delete window.__UPG_SEARCH__ + if (window.__UPG_MAP__ === _myMapHandle) delete window.__UPG_MAP__ + if (window.__UPG_SELECT__ === _mySelectHandle) delete window.__UPG_SELECT__ + } catch (_) { /* a frozen or cross-origin window must not break teardown */ } + } })