diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 2804f04d5..a2e26b2b8 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -2282,13 +2282,20 @@ function emitFilterState() { // transient fills in and is reported correctly. The retries are bounded so a clear can never be // swallowed, only delayed. const isEmpty = !detail.region && !detail.religion && !detail.language && !detail.languageCode - if (isEmpty && _lastEmitWasFilled && _emptyRetries < EMPTY_RETRIES) { - _emptyRetries++ - clearTimeout(_emitTimer) - _emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS) - return + if (isEmpty && _lastEmitWasFilled) { + const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()) + if (!_emptyWaitStartedAt) _emptyWaitStartedAt = now + if (now - _emptyWaitStartedAt < EMPTY_SETTLE_CEILING_MS) { + // Still inside the window in which an empty read is more likely to be the cascade than the + // reader. Re-read rather than believe it; the condition that ends this is the state becoming + // non-empty, not a count running out. + clearTimeout(_emitTimer) + _emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS) + return + } + // The ceiling passed and it is still empty: the reader cleared it. Report that. } - _emptyRetries = 0 + _emptyWaitStartedAt = 0 _lastEmitWasFilled = !isEmpty host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true })) } @@ -2315,8 +2322,19 @@ const EMIT_SETTLE_MS = 60 // genuine clear must always get through, and the worst case here is EMPTY_RETRIES * EMIT_SETTLE_MS // of delay on a clear — about a fifth of a second, well under what a reader notices, and far below // the composite's own 1.2s push window so it cannot collide with that. -const EMPTY_RETRIES = 3 -let _emptyRetries = 0 +// ★ A CEILING ON BELIEVING A CLEAR — NOT A GUESS AT HOW LONG THE CASCADE TAKES. +// rega, row 43 case 3 at 835ec1ac: the retry budget catches the transient on MapLibre and NOT +// reliably on Mapbox (and not at all in ar/mapbox), because 3 x 60ms is still a duration I picked +// without being able to measure the thing it has to outlast. That is the THIRD time I have tuned a +// number on one machine for this one defect, so the number's JOB changes rather than its value. +// +// Before: "wait long enough for the cascade to finish" — unknowable from here, and wrong on any +// machine slower than mine. Now: "keep re-reading until the state is non-empty, and if it is STILL +// empty after this long, the reader really did clear it." Too long merely delays a clear by an +// amount no one perceives; too short reintroduces the bug. So it errs generous, and it stays under +// the composite's 1.2s push window so the two cannot interact. +const EMPTY_SETTLE_CEILING_MS = 900 +let _emptyWaitStartedAt = 0 // Whether the LAST report actually carried a selection. Only then is an empty read suspicious: on // first load, or after a clear that has already been reported, empty is simply the truth. let _lastEmitWasFilled = false @@ -2338,7 +2356,7 @@ function emitFilterStateSettled() { // So the decision is taken here, at schedule time, where the flag still means what it says: a // change that arrived from the host is not a change the reader made, and is not reported back. if (applyingExternal) return - _emptyRetries = 0 // a fresh user action restarts the budget + _emptyWaitStartedAt = 0 // a fresh user action restarts the window clearTimeout(_emitTimer) _emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS) } diff --git a/plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs b/plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs index 18eafdef5..53d07cf44 100644 --- a/plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs +++ b/plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs @@ -271,11 +271,17 @@ if (matchesLanguage) { const src = emitterSrc if (!/_lastEmitWasFilled/.test(src) || !/const isEmpty/.test(src)) { fails.push('G research-map.vue no longer guards an all-empty report. A dialect click passes through a fully-cleared store state, so the settled read can be taken mid-cascade and ship {language:"",languageCode:""} — which the composite correctly reads as "the reader cleared everything".') - } else if (!/_emptyRetries\s*<\s*EMPTY_RETRIES/.test(src)) { - fails.push('G the empty guard is not BOUNDED — without a retry ceiling a genuine clear is deferred for ever and the grid can never be un-narrowed.') - } else if (!/_emptyRetries = 0/.test(src)) { - fails.push('G the retry budget is never reset, so the guard protects only the first cascade of a session.') - } else notes.push('empty guard: a mid-cascade read is re-taken, a real clear still gets through') + } else if (!/EMPTY_SETTLE_CEILING_MS/.test(src)) { + // ★ THE BOUND'S SHAPE CHANGED AND THE INVARIANT DID NOT. It used to be a retry COUNT; rega + // measured that 3 x 60ms catches the cascade on MapLibre and not on Mapbox, so the count became + // a TIME CEILING — the difference being what the number is FOR. A count is a guess at how long + // the cascade takes, which cannot be known from here; a ceiling is how long before a clear is + // believed, which is a choice. What must never change is that SOME bound exists: without one a + // genuine clear is deferred for ever and the grid can never be un-narrowed. + fails.push('G the empty guard is not BOUNDED — without a ceiling a genuine clear is deferred for ever and the grid can never be un-narrowed.') + } else if (!/_emptyWaitStartedAt = 0/.test(src)) { + fails.push('G the wait window is never reset, so the guard protects only the first cascade of a session.') + } else notes.push('empty guard: re-read until the state fills, with a ceiling before a clear is believed') } // ── H. the echo guard survives the coalescing ────────────────────────────────────────────────