A data refresh is not a deselection: the legend keeps a selection its new nodes still contain

rega measured the WAGF Regions tab on Simple Research: the polygon highlight lands 51-85 ms after the
click, then 433-667 ms later it is gone — fill-opacity back to 0.7, line-width back to 0.6, no row
marked, every region at full colour. That reads exactly like the Driver's "the legend isn't connecting
with the map", and it is worse than not connecting: it connected and then undid itself.

Those two values are the fingerprint. 0.7 and 0.6 are written only by this profile's CLEAR paths, every
one of which is reached from a deselect — so something sent select(null) half a second after the click,
and nobody clicked twice.

SemanticTreeLegend watches the IDENTITY of its nodes array, and every tree feeding it is a computed that
returns a NEW array each evaluation. Any late async that touches a dependency — pins still arriving, a
lazily loaded layer, a refresh — rebuilds the array with IDENTICAL CONTENT, and the reset in that
watcher then cancelled the reader's selection: expandedIds cleared, selectedNode nulled, select(null)
emitted. The reset is right for genuinely new data; what was wrong is that it could not tell the two
cases apart.

It asks one question now: does the selected row still EXIST in the new nodes? If it does, re-point at
the new object and leave the tab and the expansion alone, because nothing about the reader's state has
become invalid. If it is gone, the reset runs exactly as before and the deselect is honest. Re-pointed
rather than kept, because the old object belongs to the discarded array and holding it would compare the
highlight against a node the tree no longer contains.

This is shared, so it is every tree legend on every map, not only mine: any profile whose data arrives or
refreshes after a click was silently losing that click.

NOT changed, and deliberately: the WAGF Regions TAB click also compiles shaders for regions-fill and
regions-border (rega: 2.9-4.1 s on SwiftShader), because those layers are added on that click. Warming
them at boot would mean paying for two more layers on every reader of every tab to save a one-time cost
on one tab, and the 6.4 MB regions GeoJSON is lazily loaded there on purpose. SwiftShader inflates a
compile by roughly an order of magnitude — the pins variant measured 3.2-3.5 s there and +0.5 s of real
boot delta — so the honest expectation on a GPU is a few hundred milliseconds, once, on a tab that is
already loading megabytes. That is a trade I am declining to make rather than one I missed.

Verified: gates:pre 12/12. Needs eyes: rega, the same WAGF Regions row click — the highlight should now
still be there at 5 s, and the variant compile may appear on that click once the selection sticks.
This commit is contained in:
Doxa Maps 2026-09-28 02:49:53 +00:00
parent daa6ee6eda
commit cdb3ee1e81

View file

@ -288,6 +288,27 @@ watch(() => props.open, (v) => {
})
watch(() => safeNodes.value, () => {
// ★ A DATA REFRESH IS NOT A DESELECTION (rega, WAGF Regions on Simple Research, 2026-09-28). The
// polygon highlight landed 51-85 ms after the click and was wiped 433-667 ms later, leaving no row
// marked and every region at full colour — which reads exactly like the Driver's "the legend isn't
// connecting with the map", and is worse, because it connected and then undid itself.
//
// This watcher fires on the IDENTITY of the nodes array, and every tree here is a computed that
// returns a NEW array on each evaluation. So any late async that touches a dependency — pins still
// arriving, a lazily loaded layer, a refresh — rebuilt the array with IDENTICAL CONTENT and this
// reset cancelled the user's selection. The reset itself is right for genuinely new data; what was
// wrong is that it could not tell one case from the other.
//
// So ask whether the selected row still EXISTS. If it does, re-point at the new object and leave the
// tab and the expansion alone: nothing about the reader's state has become invalid. If it is gone,
// the reset below is correct and the deselect is honest.
const keepId = selectedNode.value?.id || null
if (keepId) {
const survivor = findNodeById(safeNodes.value, keepId)
// Re-pointed, not kept: the old object belongs to the discarded array, and holding it would make
// the highlight compare against a node the tree no longer contains.
if (survivor) { selectedNode.value = survivor; return }
}
// Falls back to defaultTab, not a hardcoded 0 — otherwise a profile that opens on
// a deeper generation silently reverts to the top the first time its nodes change.
// ⚠ AND THE STORE ONLY COUNTS ONCE IT HAS BEEN TOUCHED. This read `instance?.activeTab.value`