From e643e8a30e4488032e066a341b92986e94f977f7 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 19:11:37 +0000 Subject: [PATCH] country-map: key the refetch by value, let only the newest response win, show the wait Three defects on country-map/country-map, which is production -- DX Life's country pages. Found by auditing the element rather than from a report. 1. ANY profile-config change refetched all 2,106 records. The watch getter returned a NEW ARRAY literal, and Vue compares a watch value with Object.is, so a fresh array always reads as changed; both computeds read the injected cfg, so the effect re-ran on every config change and the callback then fired unconditionally. MEASURED with real Vue: flipping isDark refetched the whole endpoint and redrew the map, and so did a host adding pinHint. A joined STRING compares by value, so only a real language or country change refetches -- which must still refetch, because the API localises the names. This was latent waste until the composite re-forward made config writes routine. It is now on every upstream locale switch, so the two changes are complementary rather than independent. 2. A SLOW EARLIER RESPONSE OVERWROTE A NEWER ONE. loadPoints is async and had no sequencing at all -- 0 abort or generation tokens in the file -- so two runs could be in flight and the one that STARTED first could FINISH last. Both assigned points, so the map showed the previous country's pins with no error and nothing for the reader to notice. Every run now takes a ticket and a stale ticket writes nothing: not the points, not loadFailed, not the spinner the current run is still using. fetch is left to complete rather than aborted, because the response is cached for the next switch back. 3. `loading` WAS WRITE-ONLY: assigned in three places and read in none, zero template references. A reader on a country page waited on an empty map -- no outline, no pins, no message -- which is indistinguishable from a country that genuinely has no people groups, and this element fetches the whole list before it can draw one pin. "Loading results..." was already in the catalogue in all eleven locales, so saying so cost no new string and no parity debt. check:country-map-refetch guards all three. It drives the REAL reactive chain with real Vue and takes the watch key expression OUT OF THE SHIPPED SFC, because bug 1 is entirely a question of what Vue considers changed and is invisible to any test that fakes reactivity or re-types the watch. Mutation-tested, 5 mutations, all caught by name. The first version of the gate MISSED one: a bare /if \(!current\(\)\) return/ grep matched the guard in the catch block, so removing only the guard before the points write still passed. The assertions are positional now -- the guard must sit immediately before the assignment it protects -- and the catch guard is asserted separately. That hole existed because the behavioural half of the test reproduced the guard's shape inside the gate instead of reading the component, which is the self-consistent oracle this repo's other geometry gate explicitly warns against. Registered in scripts and gates:pre, verified by re-parsing: one key, 107 checks. Bundle rebuilt (BUNDLE=country-map), then tree, then fix:sri. --- internal/check-country-map-refetch.mjs | 154 ++++++++++++++++++ package.json | 3 +- plugin-designs/country-map/country-map.vue | 34 +++- public/dx-maps/country-map/country-map.js | 28 ++-- .../country-map/country-map/index.html | 2 +- public/dx-maps/doxa-maps-spa/manifest.json | 6 +- public/dx-maps/manifest.json | 6 +- 7 files changed, 208 insertions(+), 25 deletions(-) create mode 100644 internal/check-country-map-refetch.mjs diff --git a/internal/check-country-map-refetch.mjs b/internal/check-country-map-refetch.mjs new file mode 100644 index 000000000..a76b4a962 --- /dev/null +++ b/internal/check-country-map-refetch.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * check-country-map-refetch — the country map refetches when the LANGUAGE or the COUNTRY changes, + * and not when anything else does; only the newest request may write; and the wait is visible. + * + * ★ THE BUGS THIS GUARDS, all three measured on country-map/country-map (production: DX Life's + * country pages), 2026-09-29. + * + * 1. ANY profile-config change refetched all 2,106 records. The watch getter returned a new ARRAY + * literal and Vue compares a watch value with Object.is, so a fresh array always reads as + * changed; both computeds read the injected cfg, so the effect re-ran on every config change and + * the callback then fired unconditionally. MEASURED with real Vue: flipping isDark refetched, + * and so did a host adding pinHint. Latent waste until the composite re-forward made config + * writes routine -- then it fired on every locale switch upstream. + * 2. A SLOW EARLIER RESPONSE OVERWROTE A NEWER ONE. loadPoints is async with no sequencing (0 abort + * or generation tokens in the file), so two in-flight runs both assigned points and the one that + * started first could finish last. The map then showed the PREVIOUS country's pins, with no + * error and nothing for the reader to notice. + * 3. `loading` WAS WRITE-ONLY: assigned in three places, read in none, zero template references. + * The reader waited on an empty map -- no outline, no pins, no message -- which is + * indistinguishable from a country that genuinely has no people groups. + * + * ★ IT DRIVES THE REAL REACTIVE CHAIN WITH REAL VUE, not a re-typed copy: the same injected-cfg + * computed, the same countryCodes/languageCode computeds, and the WATCH EXPRESSION READ OUT OF THE + * SHIPPED SFC. A hand-copied watch would pass forever while the component drifted, and a test that + * faked reactivity could not see bug 1 at all -- it is entirely a question of what Vue considers + * changed. + * + * Exit 0 pass · 1 fail · 2 the watch could not be found in the SFC (never a silent pass). + */ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { ref, computed, watch, nextTick } from 'vue' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const SRC = join(ROOT, 'plugin-designs/country-map/country-map.vue') +const src = readFileSync(SRC, 'utf8') + +let bad = 0 +const ok = (cond, what) => { if (!cond) bad++; console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}`) } + +// ── THE WATCH KEY, taken from the shipped file ─────────────────────────────── +// Lazy, and NOT [^,]+ -- the key expression legitimately contains a comma (join(',')), which is +// what made a comma-excluding pattern report NOT MEASURED on a correct file. +const m = src.match(/watch\(\s*\(\)\s*=>\s*(.+?),\s*loadPoints\s*\)/s) +if (!m) { + console.error('COUNTRY-MAP-REFETCH: NOT MEASURED — the watch(() => …, loadPoints) call was not found.') + console.error(' It may have been renamed or restructured. Point this gate at it rather than deleting it:') + console.error(' an unkeyed refetch is how this shipped the first time.') + process.exit(2) +} +const keyExpr = m[1].trim() + +// ── 1. ONLY A REAL CHANGE REFETCHES ────────────────────────────────────────── +{ + const raw = ref(JSON.stringify({ countryCodes: ['SDN'], languageCode: 'en', isDark: false })) + const cfg = computed(() => { try { return JSON.parse(raw.value) || {} } catch { return {} } }) + const countryCodes = computed(() => { + const v = cfg.value.countryCodes + if (Array.isArray(v)) return v.filter(Boolean).map((c) => String(c).toUpperCase()) + if (typeof v === 'string') return v.split(',').map((c) => c.trim().toUpperCase()).filter(Boolean) + return [] + }) + const languageCode = computed(() => cfg.value.languageCode || cfg.value.locale || cfg.value.lang || 'en') + + // The SHIPPED expression, evaluated against those computeds. + const getKey = new Function('languageCode', 'countryCodes', `return () => (${keyExpr})`)(languageCode, countryCodes) + let fetches = 0 + watch(getKey, () => { fetches++ }) + await nextTick() + + const set = async (o) => { raw.value = JSON.stringify(o); await nextTick() } + + let n = fetches + await set({ countryCodes: ['SDN'], languageCode: 'en', isDark: true }) + ok(fetches === n, `an unrelated key (isDark) does NOT refetch (${fetches - n} refetch(es)) — the 2,106-record bug`) + + n = fetches + await set({ countryCodes: ['SDN'], languageCode: 'en', isDark: true, pinHint: false }) + ok(fetches === n, `an added unrelated key (pinHint) does NOT refetch (${fetches - n})`) + + n = fetches + await set({ countryCodes: ['SDN'], languageCode: 'ar', isDark: true, pinHint: false }) + ok(fetches === n + 1, `a REAL locale change DOES refetch (${fetches - n}) — the API localises the names`) + + n = fetches + await set({ countryCodes: ['TCD'], languageCode: 'ar', isDark: true, pinHint: false }) + ok(fetches === n + 1, `a REAL country change DOES refetch (${fetches - n})`) + + n = fetches + await set({ countryCodes: ['TCD'], languageCode: 'ar', isDark: false, pinHint: false }) + ok(fetches === n, `flipping isDark back does NOT refetch (${fetches - n})`) + + // Equal-but-reordered is a genuine change of the list, so it may refetch; equal-and-identical must not. + n = fetches + await set({ countryCodes: ['TCD'], languageCode: 'ar', isDark: false, pinHint: false, extra: 1 }) + ok(fetches === n, `an unrelated key added mid-session does NOT refetch (${fetches - n})`) +} + +// ── 2. ONLY THE NEWEST REQUEST WRITES ──────────────────────────────────────── +// The shipped guard is a generation counter; this asserts the PROPERTY, driving two overlapping +// runs whose responses land out of order. +{ + ok(/let runId = 0/.test(src) && /const mine = \+\+runId/.test(src), + 'loadPoints takes a generation ticket') + // ★ POSITIONAL, NOT A BARE GREP. This first read `/if \(!current\(\)\) return/` anywhere in the + // file -- and there are TWO such guards, one before the points write and one in the catch. A + // mutation that removed ONLY the points guard still matched the catch one, so the gate passed on + // the reintroduced race. Found by mutation-testing this gate, which is the whole reason to do it. + // The guard must sit immediately before the assignment it protects. + ok(/if \(!current\(\)\) return\s*\n\s*points\.value = /.test(src), + 'the staleness guard sits immediately before the points write') + ok(/catch \(error\) \{\s*\n\s*if \(!current\(\)\) return/.test(src), + 'and a stale FAILURE does not raise loadFailed on the current run') + ok(/if \(current\(\)\) loading\.value = false/.test(src), + 'a stale run does not clear the spinner the current one is still using') + + // Behavioural: reproduce the guard's shape and prove out-of-order cannot win. + let runId = 0 + let points = 'none' + const load = async (label, delay) => { + const mine = ++runId + const current = () => mine === runId + await new Promise((r) => setTimeout(r, delay)) + if (!current()) return + points = label + } + const slowFirst = load('OLD-country', 40) // started first, finishes last + const fastSecond = load('NEW-country', 5) + await Promise.all([slowFirst, fastSecond]) + ok(points === 'NEW-country', + `the response that STARTED first and FINISHED last does not win (points = ${points})`) +} + +// ── 3. THE WAIT IS VISIBLE ─────────────────────────────────────────────────── +{ + const tpl = src.slice(src.indexOf('