diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 477b7791f..2804f04d5 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -2321,6 +2321,23 @@ let _emptyRetries = 0 // first load, or after a clear that has already been reported, empty is simply the truth. let _lastEmitWasFilled = false function emitFilterStateSettled() { + // ★ THE ECHO GUARD MUST BE READ WHEN THE CHANGE HAPPENS, NOT WHEN THE TIMER FIRES. + // rega, row 50: a region row click emitted {region:"asia"} and then an EMPTY event, on both + // engines. It passed only because the composite's 1.2s push window happened to swallow the + // second one — a pass that depends on a swallow is not a pass. + // + // THE CAUSE IS MINE, AND IT IS THE COALESCING I ADDED. emitFilterState has always opened with + // `if (applyingExternal) return`: when the HOST pushes state down through externalFilters, the + // store changes, and reporting that back up is an echo of the host's own instruction. That guard + // worked because the emit ran SYNCHRONOUSLY inside the watcher, while the flag was still set. + // Debouncing moved the emit 60ms later — and applyingExternal is released on nextTick, long + // before that — so by the time the emit ran the flag was false and the echo went out. I fixed a + // burst and broke a guard that depended on the burst's timing, which is the cost of changing WHEN + // something runs without re-reading what else depended on when. + // + // 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 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 0b30de8e4..18eafdef5 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 @@ -278,6 +278,26 @@ if (matchesLanguage) { } else notes.push('empty guard: a mid-cascade read is re-taken, a real clear still gets through') } +// ── H. the echo guard survives the coalescing ──────────────────────────────────────────────── +// rega, row 50: a region click emitted the selection and then an EMPTY event, passing only because +// the composite's 1.2s push window swallowed the second. emitFilterState has always opened with +// `if (applyingExternal) return` — a change pushed DOWN by the host is not a change to report back +// up — and that worked while the emit ran synchronously inside the watcher. Debouncing moved the +// emit 60ms later while applyingExternal is released on nextTick, so the flag was already false +// when the emit ran and the echo went out. The guard has to be read where the change happens. +{ + const src = emitterSrc + const sched = /function emitFilterStateSettled\(\) \{([\s\S]*?)\n\}/.exec(src) + if (!sched) fails.push('H research-map.vue: emitFilterStateSettled() not found — this gate cannot see whether the echo guard survives the debounce.') + // ★ COMMENTS STRIPPED FIRST, and this is the THIRD time the same hole has appeared in my own + // checks. The body below explains the fix in prose that QUOTES the line it is asserting, so a + // naive search matched the comment and stayed green after the code was deleted. A check that its + // own documentation can satisfy is not a check; only executable lines count. + else if (!/if \(applyingExternal\) return/.test(sched[1].split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n'))) { + fails.push('H the settle scheduler does not check applyingExternal. The check inside emitFilterState runs 60ms later, and applyingExternal is released on nextTick — so a change the HOST pushed down is reported back up as if the reader had made it. That is an echo, and it reaches the composite as an empty report.') + } else notes.push('echo guard: read at schedule time, where applyingExternal still means what it says') +} + for (const n of notes) console.log(' · ' + n) if (fails.length) { console.error(`\n${TAG}: FAIL`)