From 6493872170a0d3e89a72e7a633d5d3389554b7fc Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 17:32:33 +0000 Subject: [PATCH] Track every pane's getter, prune detached ones, and say when one throws Studio's 5B review found a dependency-tracking hole by testing the helper with real Vue rather than reading it. entries was a plain array, so the watchEffect tracked only what the FIRST registered getter read: with two panes reading different refs, changing pane 2's source produced ZERO writes, and pane 2 caught up only when pane 1's source happened to change. That is this helper's own bug reappearing for any adopter whose panes read different sources. regions-mfe is safe today because every getter reads the same cfg and spec -- which is exactly why a reading could not find it. entries is now shallowReactive, so register() re-runs the effect and every getter is tracked. Also from the review: - stale entries are PRUNED, not skipped: an adopter that rebuilds its pane column would otherwise keep detached elements and re-run their getters on every pass forever; - a getter that throws is reported once per entry instead of being swallowed, since silence recreates the symptom this helper exists to fix; - the unused `tag` parameter is gone -- a documented check that does not exist misleads the next adopter; - the host argument now documents that it must be in the SAME tree as the panes, because Element.contains() does not cross a shadow boundary and passing the custom element would silently skip every write. regions-dashboard rebuilt so public/ is servable at this sha for the browser A/B. It is the only bundle that compiles the helper. --- library/composables/usePaneConfig.js | 32 ++-- .../regions-dashboard/regions-mfe.vue | 4 +- public/dx-maps/doxa-maps-spa/manifest.json | 12 +- public/dx-maps/manifest.json | 12 +- .../region-dashboards/index.html | 2 +- .../regions-dashboard/regions-dashboard.js | 146 +++++++++--------- .../regions-dashboard/regions-list/index.html | 2 +- .../regions-dashboard/regions-mfe/index.html | 2 +- 8 files changed, 114 insertions(+), 98 deletions(-) diff --git a/library/composables/usePaneConfig.js b/library/composables/usePaneConfig.js index 793e7be1b..07bcbf5dd 100644 --- a/library/composables/usePaneConfig.js +++ b/library/composables/usePaneConfig.js @@ -29,17 +29,25 @@ * Re-forwarding happens inside a watchEffect, so any reactive source the getter reads — the outer * config, a computed spec, the pane's own config — triggers it. */ -import { watchEffect, onBeforeUnmount } from 'vue' +import { watchEffect, onBeforeUnmount, shallowReactive } from 'vue' /** * @param {() => (Element|null|undefined)} hostGetter the element the panes were appended under; used * only to drop registrations whose element has left the DOM (a pane column can be rebuilt). - * @param {string} tag the child element's tag name, for the same staleness check. + * ★ IT MUST BE IN THE SAME TREE AS THE PANES. Element.contains() does NOT cross a shadow + * boundary, so passing the custom element while its panes live in its shadow root would make + * every pane look detached and skip every write, silently. * @returns {{ register: (el: Element, configGetter: () => object) => void, count: () => number }} */ -export function usePaneConfig(hostGetter, tag) { - /** @type {Array<{el: Element, get: () => object, last: string|null}>} */ - const entries = [] +export function usePaneConfig(hostGetter) { + // ★ shallowReactive, NOT a plain array. The watchEffect below runs once, at the FIRST register(); + // with a plain array a LATER entry's getter is never tracked, so its pane stays frozen until + // something the first entry reads happens to change. MEASURED with real Vue by studio: two panes + // reading different refs, changing only pane 2's source produced ZERO writes. That is the very bug + // this helper exists to fix, reappearing for any adopter whose panes read different sources. + // Making the list reactive re-runs the effect on register() and re-tracks every getter. + /** @type {Array<{el: Element, get: () => object, last: string|null, warned: boolean}>} */ + const entries = shallowReactive([]) let stop = null const alive = (el) => { @@ -55,16 +63,22 @@ export function usePaneConfig(hostGetter, tag) { // The attribute the caller already set at mount is the baseline: recording it here means the // first watchEffect pass does not rewrite an identical value. const last = el.getAttribute('profile-config') - entries.push({ el, get: configGetter, last }) + entries.push({ el, get: configGetter, last, warned: false }) if (!stop) { stop = watchEffect(() => { + // PRUNE rather than skip: an adopter that rebuilds its pane column would otherwise keep + // detached elements in the list and re-run their getters on every pass, forever. + for (let i = entries.length - 1; i >= 0; i--) if (!alive(entries[i].el)) entries.splice(i, 1) for (const entry of entries) { let next // A getter that throws must not take the other panes down with it: this runs inside a - // watchEffect, where an exception would stop every later entry in the same pass. - try { next = JSON.stringify(entry.get()) } catch (_) { continue } + // watchEffect, where an exception would stop every later entry in the same pass. It is + // reported ONCE per entry -- silence here would recreate the symptom this helper fixes. + try { next = JSON.stringify(entry.get()) } catch (e) { + if (!entry.warned) { entry.warned = true; console.error('[usePaneConfig] pane config getter threw; this pane will not follow the outer config:', e) } + continue + } if (next === entry.last) continue - if (!alive(entry.el)) continue entry.last = next entry.el.setAttribute('profile-config', next) } diff --git a/plugin-designs/regions-dashboard/regions-mfe.vue b/plugin-designs/regions-dashboard/regions-mfe.vue index 44b793b20..bccb01811 100644 --- a/plugin-designs/regions-dashboard/regions-mfe.vue +++ b/plugin-designs/regions-dashboard/regions-mfe.vue @@ -118,7 +118,9 @@ const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'tokenUr // after the outer profile-config changes yields the NEW pane config. It used to be inline in // paneElement, which ran once per pane in onMounted and never again -- that is the whole defect: // the pane is handed a JSON STRING, so a later locale change reached this element and stopped. -const paneConfig = usePaneConfig(() => stage.value, TAG) +// stage is the element the panes are appended UNDER, in the same tree as them -- contains() does +// not cross a shadow boundary, so the host must not be the custom element itself. +const paneConfig = usePaneConfig(() => stage.value) function paneConfigFor(pane) { const host = cfg.value || {} diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json index 6d34eeccd..aeb67bee3 100644 --- a/public/dx-maps/doxa-maps-spa/manifest.json +++ b/public/dx-maps/doxa-maps-spa/manifest.json @@ -1144,7 +1144,7 @@ "bundle": "regions-dashboard", "scriptUrl": "./regions-dashboard/regions-dashboard.js", "output": "dx-maps/regions-dashboard/regions-dashboard.js", - "integrity": "sha384-+u2vDSnYni3VeNKSYdNWfw5bjKtriFjik/k10lugA0nw6Ozfi/vKUYp3a9sbMAp6", + "integrity": "sha384-z4ZtI4c3ouh3yVdWy9vGTsmNm0aK7GfGlsBMGtrGa+9UhUtf7iXng+k9XsBEfev3", "elements": [ "regions-dashboard" ], @@ -1167,8 +1167,8 @@ "scriptUrl": "./regions-dashboard/regions-dashboard.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -1184,7 +1184,7 @@ "scriptUrl": "./regions-dashboard/regions-dashboard.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } }, { @@ -1200,8 +1200,8 @@ "scriptUrl": "./regions-dashboard/regions-dashboard.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } } ] diff --git a/public/dx-maps/manifest.json b/public/dx-maps/manifest.json index 6d34eeccd..aeb67bee3 100644 --- a/public/dx-maps/manifest.json +++ b/public/dx-maps/manifest.json @@ -1144,7 +1144,7 @@ "bundle": "regions-dashboard", "scriptUrl": "./regions-dashboard/regions-dashboard.js", "output": "dx-maps/regions-dashboard/regions-dashboard.js", - "integrity": "sha384-+u2vDSnYni3VeNKSYdNWfw5bjKtriFjik/k10lugA0nw6Ozfi/vKUYp3a9sbMAp6", + "integrity": "sha384-z4ZtI4c3ouh3yVdWy9vGTsmNm0aK7GfGlsBMGtrGa+9UhUtf7iXng+k9XsBEfev3", "elements": [ "regions-dashboard" ], @@ -1167,8 +1167,8 @@ "scriptUrl": "./regions-dashboard/regions-dashboard.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -1184,7 +1184,7 @@ "scriptUrl": "./regions-dashboard/regions-dashboard.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } }, { @@ -1200,8 +1200,8 @@ "scriptUrl": "./regions-dashboard/regions-dashboard.js", "embed": { "iframe": "", - "script": "\n\n\n\n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } } ] diff --git a/public/dx-maps/regions-dashboard/region-dashboards/index.html b/public/dx-maps/regions-dashboard/region-dashboards/index.html index 1706f1e7c..0987b736b 100644 --- a/public/dx-maps/regions-dashboard/region-dashboards/index.html +++ b/public/dx-maps/regions-dashboard/region-dashboards/index.html @@ -70,7 +70,7 @@ - + + - +