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.
94 lines
5.3 KiB
JavaScript
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
|