doxa-maps/library/composables/usePaneConfig.js
Doxa Maps 6493872170 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.
2026-09-29 17:32:33 +00:00

94 lines
5.3 KiB
JavaScript

/**
* 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, 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).
* ★ 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) {
// ★ 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) => {
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, 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. 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
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