From 89bf1feb5415a762cb34a77c4991a503b70302af Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 17:27:25 +0000 Subject: [PATCH 1/5] A composite's panes follow the outer profile-config, instead of freezing at mount regions-mfe built each pane by creating the child element and writing its profile-config once, inside onMounted, and nothing rewrote it. The pane is handed a JSON STRING rather than a reactive object, so a later change to the OUTER element's profile-config updated the composite's own cfg and reached nothing below it. regions-dashboard is kept alive by v-show rather than remounted, so the one code path that would have rewritten the attribute never runs again and the panes stay in the old language for the whole session. The leaf was never at fault: ProfileLoader's config is a computed over its prop, lang is a computed over config, and a watchEffect drives appI18n on every change. Every link is reactive -- it simply never receives a new prop. library/composables/usePaneConfig.js owns the missing half: a composite registers each pane element with a getter for its config, and a watchEffect re-forwards whenever any reactive source that getter reads changes. It writes only when the serialised config actually differs, because setting profile-config re-parses it in the child and can remount what it drives -- an unconditional write per tick would be a rerender loop. Stale elements (a rebuilt pane column) are skipped. It is a library helper rather than three copies because regions-mfe, upg-100-mfe and country-posters-mfe all assemble a config and set the attribute once; they differ only in which keys they inherit, so each keeps its own INHERITED_PAGE_KEYS and hands the result to the helper. This commit adopts it in regions-mfe only; the other two are upg100's to adopt. Two-sided, headlessly: a check that fails when a composite writes profile-config on a child without re-forwarding names all THREE composites on development, and on this branch regions-dashboard reports "re-forwarding=usePaneConfig" while the other two still fail. NOT VERIFIED IN A BROWSER YET. rega's LIVEDIAG is the falsifier: the nested [profile-config] should show the OLD locale on development and the NEW one here. --- library/composables/usePaneConfig.js | 80 +++++++++++++++++++ .../regions-dashboard/regions-mfe.vue | 23 ++++-- 2 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 library/composables/usePaneConfig.js diff --git a/library/composables/usePaneConfig.js b/library/composables/usePaneConfig.js new file mode 100644 index 000000000..793e7be1b --- /dev/null +++ b/library/composables/usePaneConfig.js @@ -0,0 +1,80 @@ +/** + * usePaneConfig — keep a composite's panes' profile-config in step with the outer element's. + * + * ★ THE BUG THIS EXISTS FOR. A composite builds each pane by creating the child custom element and + * writing its profile-config ONCE, inside onMounted: + * + * el.setAttribute('profile-config', JSON.stringify(pcfg)) // and never again + * + * That is correct at mount and wrong for the rest of the session. The pane is handed a JSON STRING, + * not a reactive object, so a later change to the OUTER element's profile-config updates the + * composite's own `cfg` and reaches nothing below it. MEASURED on Regions: a live locale switch + * en -> ar left the panes in English for the whole session, because regions-dashboard is kept alive + * by v-show rather than remounted — the one code path that would have rewritten the attribute never + * runs again. The leaf's own chain is fully reactive and was never at fault: it is reactive to its + * own prop, and its prop is frozen. + * + * ★ WHY A HELPER RATHER THAN THREE FIXES. regions-mfe, upg-100-mfe and country-posters-mfe all build + * their panes the same way — assemble a config object, stringify it, set the attribute once. They + * differ only in WHICH keys they inherit. So the composite keeps its own INHERITED_PAGE_KEYS and its + * own assembly, and hands the result here; this owns the part that was missing everywhere. + * + * ★ IT WRITES ONLY WHEN THE STRING CHANGES. Setting profile-config re-parses it in the child and can + * remount what it drives, so an unconditional write on every reactive tick would be a rerender loop. + * The previous value is remembered per element and compared before touching the DOM. + * + * const panes = usePaneConfig(() => stage.value, TAG) + * panes.register(el, () => paneConfigFor(pane)) // at mount, after appendChild + * + * 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' + +/** + * @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. + * @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 = [] + let stop = null + + const alive = (el) => { + if (!el || !el.isConnected) return false + const host = typeof hostGetter === 'function' ? hostGetter() : hostGetter + // A pane column rebuild replaces the elements; an entry whose element is no longer under the + // current host is stale and must not be written to. + return !host || host.contains(el) + } + + function register(el, configGetter) { + if (!el || typeof configGetter !== 'function') return + // 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 }) + if (!stop) { + stop = watchEffect(() => { + 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 } + if (next === entry.last) continue + if (!alive(entry.el)) continue + entry.last = next + entry.el.setAttribute('profile-config', next) + } + }) + } + } + + onBeforeUnmount(() => { if (stop) { stop(); stop = null } entries.length = 0 }) + + return { register, count: () => entries.length } +} + +export default usePaneConfig diff --git a/plugin-designs/regions-dashboard/regions-mfe.vue b/plugin-designs/regions-dashboard/regions-mfe.vue index 180fe84be..44b793b20 100644 --- a/plugin-designs/regions-dashboard/regions-mfe.vue +++ b/plugin-designs/regions-dashboard/regions-mfe.vue @@ -25,6 +25,7 @@ * wall's 'show-on-map', and it keeps each pane under its own ProfileLoader/Pinia. */ import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue' +import { usePaneConfig } from '@map/composables/usePaneConfig.js' import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js' @@ -113,16 +114,25 @@ let offScroll = null // below), and this was the one genuinely missing. const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'tokenUrl', 'dataSource', 'locale', 'lang', 'languageCode', 'isDark', 'qa', 'pinHint', 'urlSync', 'provider', 'list'] -function paneElement(pane) { - const el = document.createElement(TAG) +// ★ SPLIT OUT SO IT CAN BE RE-EVALUATED. This reads cfg.value and spec.value, so calling it again +// 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) + +function paneConfigFor(pane) { const host = cfg.value || {} const inherited = {} for (const k of INHERITED_PAGE_KEYS) { if (host[k] !== undefined && host[k] !== null && host[k] !== '') inherited[k] = host[k] } if (inherited.locale === undefined && inherited.lang !== undefined) inherited.locale = inherited.lang - const pcfg = { ...inherited, ...spec.value.config, ...pane.config } - el.setAttribute('profile-config', JSON.stringify(pcfg)) + return { ...inherited, ...spec.value.config, ...pane.config } +} + +function paneElement(pane) { + const el = document.createElement(TAG) + el.setAttribute('profile-config', JSON.stringify(paneConfigFor(pane))) return el } @@ -160,7 +170,10 @@ onMounted(() => { if (!column) return for (const pane of column.panes) { if (!pane.config) continue - column.mountEl(pane.id)?.appendChild(paneElement(pane)) + const el = paneElement(pane) + column.mountEl(pane.id)?.appendChild(el) + // Keep this pane's profile-config in step with the outer element's for the rest of the session. + paneConfig.register(el, () => paneConfigFor(pane)) } // Tabs are a readout as well as a control — follow the user's scroll. offScroll = column.onScroll(() => { From 6493872170a0d3e89a72e7a633d5d3389554b7fc Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 17:32:33 +0000 Subject: [PATCH 2/5] 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 @@ - + + - + \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 aeb67bee3..0345561ef 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-z4ZtI4c3ouh3yVdWy9vGTsmNm0aK7GfGlsBMGtrGa+9UhUtf7iXng+k9XsBEfev3", + "integrity": "sha384-yO7462/K+x8yGefpEiZG7GWviAwgJe/N6J7WUhC3qK8mQyCLIq0SeyszkL+1FUhh", "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 0987b736b..21d9d9027 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 @@ - + - + - + \n\n\n", - "scriptMapbox": "\n\n\n\n\n\n" + "script": "\n\n\n\n\n\n", + "scriptMapbox": "\n\n\n\n\n\n" } }, { @@ -1166,7 +1166,7 @@ "scriptUrl": "./regions-dashboard/regions-dashboard.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } }, { @@ -1182,8 +1182,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 d4364686e..9cf303d0f 100644 --- a/public/dx-maps/manifest.json +++ b/public/dx-maps/manifest.json @@ -1126,7 +1126,7 @@ "bundle": "regions-dashboard", "scriptUrl": "./regions-dashboard/regions-dashboard.js", "output": "dx-maps/regions-dashboard/regions-dashboard.js", - "integrity": "sha384-yO7462/K+x8yGefpEiZG7GWviAwgJe/N6J7WUhC3qK8mQyCLIq0SeyszkL+1FUhh", + "integrity": "sha384-YNNxkm78b9Z8oKetaufWw+ajPKAEqk89gSrY5imw8fbVMMAyVvg6hU+9E+osig8s", "elements": [ "regions-dashboard" ], @@ -1149,8 +1149,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" } }, { @@ -1166,7 +1166,7 @@ "scriptUrl": "./regions-dashboard/regions-dashboard.js", "embed": { "iframe": "", - "script": "\n\n\n" + "script": "\n\n\n" } }, { @@ -1182,8 +1182,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 21d9d9027..6afd37845 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 @@ - + + - +