Two setup-time defects: a TDZ that blanked the map, and a cleanup that never ran
Both came from the Driver's console in the gallery's Simple Research tab, and both come from one
gap: where a line SITS in a setup block is not WHEN it runs.
research-map.vue — "Cannot access '_lastFitKey' before initialization", thrown during setup, which
left the map blank. The externalFilters watcher is immediate:true, so it runs while setup is still
on the stack and calls applyFitSlugs() 350 lines below it. The CALL resolved, because a function
declaration is hoisted; its constants are not, so the first read hit their temporal dead zone. A
hoisted function promises that its NAME resolves early, not that its BODY can run early, and that
asymmetry is invisible from both ends: the watcher looks like it only touches the store, and the
constants look like they sit right beside the function that reads them. They and the guard now sit
above the watcher, with a note saying why they cannot live next to applyFitSlugs().
DashboardMapPanel.vue — "onBeforeUnmount is called when there is no active component instance".
A lifecycle registrar closes over Vue's current instance, which exists only while setup is on the
stack, so a hook registered after "await initializeMap()" is silently dropped: a ResizeObserver on
document.body plus a window resize listener outlived the component. Nothing threw and the feature
worked, so it showed only as a studio that slowed down the more tabs a reader had visited. The fix
is not to move the line — the code that creates those handles is past the await either way. The
handles move to setup scope and are torn down in the hook this file already registers correctly,
which is the pattern it was using three lines away for containerRO and attribRO.
research-map.vue held a second copy of the same leak, its scroll-zoom resync listener. The new
gate found that one; reading the file had not.
check:setup-order covers both classes. Its own false positives are the instructive part: 6 hits
down to 1 over three rounds, and every one it dropped was prose or shadowing read as code — an
onBeforeUnmount inside a console.error STRING, the word "table" inside a new Error(...) template
literal in four files, and a destructured byCountry shadowing a computed 220 lines away. So it
blanks comments, string text and template text while keeping ${...}, which is code, and subtracts
every name a span declares for itself. It self-tests on both shipped defects, both fixes and all
three decoys, and exits 3 if an arm goes blind rather than passing quietly.
Source only. doxa-research-map, research-dashboard and dashboard-plus-grid need a rebuild.
This commit is contained in:
parent
23976fb0d8
commit
2c5d9f47ec
4 changed files with 400 additions and 23 deletions
356
internal/check-setup-order.mjs
Normal file
356
internal/check-setup-order.mjs
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-setup-order.mjs — code that RUNS during setup must not be written below what it reads.
|
||||
*
|
||||
* Two arms, one root cause: a line's POSITION in a setup block is not the same as the MOMENT it
|
||||
* runs, and both defects below shipped because the author read the file top-to-bottom and assumed
|
||||
* the runtime would too. Both were found in the Driver's own console, in a gallery tab, on code
|
||||
* that built clean, parsed clean and passed every other gate in this tree.
|
||||
*
|
||||
* ── ARM A: IMMEDIATE-TDZ ──────────────────────────────────────────────────────────────────────
|
||||
* research-map.vue:2427 — `ReferenceError: Cannot access '_lastFitKey' before initialization`,
|
||||
* and the whole map blanked, because setup threw.
|
||||
*
|
||||
* `watch(src, cb, { immediate: true })` runs cb DURING setup, at the line the watch() sits on.
|
||||
* That watcher called applyFitSlugs(), a `function` declaration 350 lines further down — which is
|
||||
* hoisted, so the CALL resolved fine. Its constants were not: `let _lastFitKey` and seven
|
||||
* `const FIT_*` above it are lexical, still in their temporal dead zone, and the first read threw.
|
||||
*
|
||||
* THE HOISTING ASYMMETRY IS THE WHOLE TRAP. A hoisted function is a promise that the NAME
|
||||
* resolves early, not that its BODY can run early. So the dangerous edge is not "does the watcher
|
||||
* read something below it" — that is easy to see — it is "does the watcher CALL something that
|
||||
* reads something below it", one level of indirection away, and invisible at both ends: the
|
||||
* watcher looks like it only touches the store, and the function looks like it only touches
|
||||
* constants that are obviously right there beside it.
|
||||
*
|
||||
* ── ARM B: HOOK-AFTER-AWAIT ───────────────────────────────────────────────────────────────────
|
||||
* DashboardMapPanel.vue:1233 — `onBeforeUnmount is called when there is no active component
|
||||
* instance` (a warning, which is why it survived), and the cleanup silently never ran.
|
||||
*
|
||||
* Every lifecycle registrar closes over Vue's CURRENT INSTANCE, which exists only while setup is
|
||||
* on the stack. `onMounted(async () => { await initializeMap(); ...; onBeforeUnmount(teardown) })`
|
||||
* resumes after a microtask, with no current instance — so the registration is dropped and a
|
||||
* ResizeObserver on document.body plus a window 'resize' listener outlive the component. The
|
||||
* failure mode is the worst kind: nothing throws, the feature works, and the leak only shows up
|
||||
* as a studio that gets slower the more tabs the reader has visited.
|
||||
*
|
||||
* FIX SHAPE, not just "move the line": the teardown cannot live next to the code that creates the
|
||||
* thing, because that code is past the await. Declare the handle at setup scope and tear it down
|
||||
* in one hook registered during setup — which is the pattern the same file already used for
|
||||
* containerRO and attribRO, three lines away from the one that got it wrong.
|
||||
*
|
||||
* Both arms STRIP COMMENTS FIRST. This tree has been bitten five times by a check satisfied by a
|
||||
* comment quoting the code it was looking for, and this file's own header names both symbols.
|
||||
*
|
||||
* USAGE: node internal/check-setup-order.mjs [--poison]
|
||||
* EXIT: 0 = clean, 1 = a defect, 3 = the self-test failed (the gate cannot be trusted).
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { resolve, dirname, relative, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '..')
|
||||
const POISON = process.argv.includes('--poison')
|
||||
|
||||
const HOOKS = [
|
||||
'onMounted', 'onBeforeMount', 'onBeforeUnmount', 'onUnmounted', 'onUpdated', 'onBeforeUpdate',
|
||||
'onActivated', 'onDeactivated', 'onErrorCaptured', 'onRenderTracked', 'onRenderTriggered',
|
||||
'onServerPrefetch',
|
||||
]
|
||||
const HOOK_RE = new RegExp('\\b(' + HOOKS.join('|') + ')\\s*\\(', 'g')
|
||||
|
||||
/* Blank out comments and string bodies in place, so every index stays where it was. */
|
||||
const blank = (m) => m.replace(/[^\n]/g, ' ')
|
||||
const stripComments = (src) => src
|
||||
.replace(/<!--[\s\S]*?-->/g, blank)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
||||
.replace(/(^|[^:\\'"`/])\/\/[^\n]*/g, (m, p) => p + ' '.repeat(m.length - p.length))
|
||||
/* Blank the TEXT of every literal while keeping `${...}` interpolations, which are code.
|
||||
* One state machine rather than three regexes, because the three quote styles nest: version 2 of
|
||||
* this gate flagged four data-tables.vue files for "ensure() reads `table`" when the only `table`
|
||||
* in ensure() is the word in `new Error(`table not found (${r.status})`)`. Prose in a template
|
||||
* literal is exactly as inert as prose in a comment, and a regex cannot tell them apart. */
|
||||
const stripStrings = (src) => {
|
||||
const out = src.split('')
|
||||
const stack = []
|
||||
let i = 0
|
||||
const blankAt = (n) => { if (src[n] !== '\n') out[n] = ' ' }
|
||||
while (i < src.length) {
|
||||
const c = src[i]
|
||||
const top = stack[stack.length - 1]
|
||||
if (top && (top.kind === 'sq' || top.kind === 'dq')) {
|
||||
if (c === '\\') { blankAt(i); if (i + 1 < src.length) blankAt(i + 1); i += 2; continue }
|
||||
if ((c === "'" && top.kind === 'sq') || (c === '"' && top.kind === 'dq') || c === '\n') { stack.pop(); i++; continue }
|
||||
blankAt(i); i++; continue
|
||||
}
|
||||
if (top && top.kind === 'tpl') {
|
||||
if (c === '\\') { blankAt(i); if (i + 1 < src.length) blankAt(i + 1); i += 2; continue }
|
||||
if (c === '`') { stack.pop(); i++; continue }
|
||||
if (c === '$' && src[i + 1] === '{') { stack.push({ kind: 'expr', depth: 0 }); i += 2; continue }
|
||||
blankAt(i); i++; continue
|
||||
}
|
||||
if (c === "'") { stack.push({ kind: 'sq' }); i++; continue }
|
||||
if (c === '"') { stack.push({ kind: 'dq' }); i++; continue }
|
||||
if (c === '`') { stack.push({ kind: 'tpl' }); i++; continue }
|
||||
if (top && top.kind === 'expr') {
|
||||
if (c === '{') top.depth++
|
||||
else if (c === '}') { if (top.depth === 0) { stack.pop(); i++; continue } top.depth-- }
|
||||
}
|
||||
i++
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
/* Only the <script> half of an SFC can run at setup time; the template is a different phase. */
|
||||
function scriptOf(src, file) {
|
||||
if (!file.endsWith('.vue')) return src
|
||||
const out = src.replace(/[^\n]/g, ' ').split('')
|
||||
const re = /<script\b[^>]*>([\s\S]*?)<\/script>/g
|
||||
let m
|
||||
while ((m = re.exec(src))) {
|
||||
for (let i = m.index + m[0].indexOf('>') + 1; i < m.index + m[0].length - 9; i++) out[i] = src[i]
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
/* Brace-match forward from the first `{` at or after `from`. Returns [open, close] or null. */
|
||||
function braceSpan(src, from) {
|
||||
const open = src.indexOf('{', from)
|
||||
if (open < 0) return null
|
||||
let d = 0
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === '{') d++
|
||||
else if (src[i] === '}' && --d === 0) return [open, i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const lineOf = (src, i) => src.slice(0, i).split('\n').length
|
||||
|
||||
const namesInPattern = (pat) => String(pat)
|
||||
.replace(/[{}[\]]/g, ' ')
|
||||
.split(',')
|
||||
.map((s) => s.replace(/:[\s\S]*$/, '').replace(/=[\s\S]*$/, '').replace(/\.\.\./, '').trim())
|
||||
.filter((n) => /^[A-Za-z_$][\w$]*$/.test(n))
|
||||
|
||||
/* Names a span DECLARES for itself: any of these resolves locally, so reading one says nothing
|
||||
* about a binding further down the file. Version 3 of this gate flagged charts.vue for "load()
|
||||
* reads byCountry, declared at line 354" when load() destructures its OWN `byCountry` on its
|
||||
* second line — a read of a shadowing local, 220 lines away from the const it appears to name.
|
||||
* Shadowing is the normal case in this tree (short names, small helpers), not an exotic one. */
|
||||
function localNames(span) {
|
||||
const out = new Set()
|
||||
const add = (pat) => { for (const n of namesInPattern(pat)) out.add(n) }
|
||||
let m
|
||||
const decl = /\b(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[A-Za-z_$][\w$]*)/g
|
||||
while ((m = decl.exec(span))) add(m[1])
|
||||
const params = /\bfunction\s*[A-Za-z_$]?[\w$]*\s*\(([^)]*)\)|\(([^)]*)\)\s*=>|\bcatch\s*\(([^)]*)\)/g
|
||||
while ((m = params.exec(span))) add(m[1] ?? m[2] ?? m[3])
|
||||
const bare = /\b([A-Za-z_$][\w$]*)\s*=>/g
|
||||
while ((m = bare.exec(span))) out.add(m[1])
|
||||
return out
|
||||
}
|
||||
|
||||
/* Identifiers a span READS FROM AN OUTER SCOPE: member names, object keys and anything the span
|
||||
* declares itself are all removed, because none of them is a binding this scope has to resolve. */
|
||||
function identsIn(span) {
|
||||
const local = localNames(span)
|
||||
const read = stripStrings(span)
|
||||
.replace(/\.\s*[A-Za-z_$][\w$]*/g, ' ') // r.slug — a property, not a binding
|
||||
.replace(/\b[A-Za-z_$][\w$]*\s*:/g, ' ') // { padding: … } — a key, not a binding
|
||||
.match(/\b[A-Za-z_$][\w$]*\b/g) || []
|
||||
return new Set(read.filter((n) => !local.has(n)))
|
||||
}
|
||||
|
||||
/* Top-level (column-0) lexical declarations, which are the ones a setup block resolves. `var`
|
||||
* hoists to undefined rather than throwing, so it is a different bug and not this gate's. */
|
||||
function topLevelLexical(src) {
|
||||
const out = []
|
||||
const re = /^(?:const|let)\s+(?:\{([^}]*)\}|\[([^\]]*)\]|([A-Za-z_$][\w$]*))/gm
|
||||
let m
|
||||
while ((m = re.exec(src))) {
|
||||
const names = m[3] ? [m[3]]
|
||||
: String(m[1] || m[2]).split(',').map((s) => s.replace(/:.*$/, '').replace(/=.*$/, '').replace(/\.\.\./, '').trim())
|
||||
for (const n of names) if (/^[A-Za-z_$][\w$]*$/.test(n)) out.push({ name: n, at: m.index })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* Top-level `function NAME(...) {}` declarations — hoisted, so reachable from an immediate watcher. */
|
||||
function topLevelFunctions(src) {
|
||||
const out = new Map()
|
||||
const re = /^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/gm
|
||||
let m
|
||||
while ((m = re.exec(src))) {
|
||||
const span = braceSpan(src, m.index)
|
||||
if (span) out.set(m[1], { at: m.index, body: src.slice(span[0], span[1] + 1) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── ARM A ────────────────────────────────────────────────────────────────────────────────────────
|
||||
function armA(code) {
|
||||
const hits = []
|
||||
const lexical = topLevelLexical(code)
|
||||
const fns = topLevelFunctions(code)
|
||||
const re = /\bimmediate\s*:\s*true\b/g
|
||||
let m
|
||||
while ((m = re.exec(code))) {
|
||||
// Walk back to the watch()/watchEffect() this option belongs to: the nearest one before it.
|
||||
const before = code.slice(0, m.index)
|
||||
const start = Math.max(before.lastIndexOf('watch('), before.lastIndexOf('watchEffect('))
|
||||
if (start < 0) continue
|
||||
const span = code.slice(start, m.index)
|
||||
const reads = identsIn(span)
|
||||
// Declared BELOW the watcher = in its temporal dead zone while the immediate run is on the stack.
|
||||
for (const d of lexical) {
|
||||
if (d.at > start && reads.has(d.name)) {
|
||||
hits.push({ line: lineOf(code, start), why: `immediate watcher reads '${d.name}', declared at line ${lineOf(code, d.at)}` })
|
||||
}
|
||||
}
|
||||
// One level of indirection: a hoisted function it calls, whose own body reads something below.
|
||||
for (const [name, fn] of fns) {
|
||||
if (!reads.has(name)) continue
|
||||
const inner = identsIn(fn.body)
|
||||
for (const d of lexical) {
|
||||
if (d.at > start && inner.has(d.name)) {
|
||||
hits.push({ line: lineOf(code, start), why: `immediate watcher calls ${name}(), which reads '${d.name}', declared at line ${lineOf(code, d.at)}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// ── ARM B ────────────────────────────────────────────────────────────────────────────────────────
|
||||
function armB(code) {
|
||||
const hits = []
|
||||
const re = /\basync\b/g
|
||||
let m
|
||||
while ((m = re.exec(code))) {
|
||||
const span = braceSpan(code, m.index)
|
||||
if (!span) continue
|
||||
const body = code.slice(span[0], span[1] + 1)
|
||||
const firstAwait = body.search(/\bawait\b/)
|
||||
if (firstAwait < 0) continue
|
||||
let h
|
||||
const hre = new RegExp(HOOK_RE.source, 'g')
|
||||
while ((h = hre.exec(body))) {
|
||||
if (h.index > firstAwait) {
|
||||
hits.push({ line: lineOf(code, span[0] + h.index), why: `${h[1]}() is registered after an await, so it never attaches` })
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
const run = (src, file) => {
|
||||
/* Strings are blanked HERE, for both arms. Arm B's first version flagged useMapInstance.js:430 —
|
||||
* where the only `onBeforeUnmount` on the line is inside the text of a console.error advising a
|
||||
* caller to use one. A gate that reads prose as code cries wolf, and a crying gate gets ignored. */
|
||||
const code = stripStrings(stripComments(scriptOf(src, file)))
|
||||
return [...armA(code).map((h) => ({ ...h, arm: 'IMMEDIATE-TDZ' })), ...armB(code).map((h) => ({ ...h, arm: 'HOOK-AFTER-AWAIT' }))]
|
||||
}
|
||||
|
||||
// ── self-test: both defects as they actually shipped, and the fixes, which must stay quiet ───────
|
||||
const PLANTED_A = `<script setup>
|
||||
const mapStore = inject('mapStore')
|
||||
watch(() => cfg?.value?.externalFilters, (raw) => {
|
||||
if ('fitSlugs' in raw) applyFitSlugs(raw.fitSlugs)
|
||||
}, { deep: true, immediate: true })
|
||||
const FIT_MAX_ZOOM = 5.5
|
||||
let _lastFitKey = ''
|
||||
function applyFitSlugs(slugs) {
|
||||
const key = String(slugs)
|
||||
if (key === _lastFitKey) return
|
||||
_lastFitKey = key
|
||||
fit({ maxZoom: FIT_MAX_ZOOM })
|
||||
}
|
||||
</script>`
|
||||
const PLANTED_B = `<script setup>
|
||||
onMounted(async () => {
|
||||
await initializeMap()
|
||||
const resync = () => sync(map.value)
|
||||
window.addEventListener('resize', resync)
|
||||
onBeforeUnmount(() => { window.removeEventListener('resize', resync) })
|
||||
})
|
||||
</script>`
|
||||
/* The same two files after the fix, plus the shapes most likely to be mistaken for the defects:
|
||||
* a NON-immediate watcher above its constants (legal — it cannot run until they exist), a hook
|
||||
* registered BEFORE an await, and a comment that names the symbol the arms search for. */
|
||||
const CLEAN = `<script setup>
|
||||
console.error('containerRef.value is null - call initializeMap() inside onMounted()')
|
||||
async function ensure(file) { if (!ok) throw new Error(\`table not found (\${file})\`) }
|
||||
async function load() { const [pins, byCountry] = await all(); tables.value = { pins, byCountry } }
|
||||
watch(profileConfig, load, { immediate: true })
|
||||
const byCountry = computed(() => series(tables.value))
|
||||
watch(current, (t) => t && ensure(t.file), { immediate: true })
|
||||
const table = computed(() => cache.value[current.value?.file] || null)
|
||||
const FIT_MAX_ZOOM = 5.5
|
||||
let _lastFitKey = ''
|
||||
watch(() => cfg?.value?.externalFilters, (raw) => {
|
||||
if ('fitSlugs' in raw) applyFitSlugs(raw.fitSlugs)
|
||||
}, { deep: true, immediate: true })
|
||||
watch(() => store.other, () => { later({ maxZoom: LATE_ZOOM }) })
|
||||
const LATE_ZOOM = 3
|
||||
function applyFitSlugs(slugs) {
|
||||
if (String(slugs) === _lastFitKey) return
|
||||
_lastFitKey = String(slugs)
|
||||
fit({ maxZoom: FIT_MAX_ZOOM, padding: 70 })
|
||||
}
|
||||
let scrollRO = null
|
||||
onBeforeUnmount(() => { if (scrollRO) scrollRO.disconnect() })
|
||||
onMounted(async () => {
|
||||
await initializeMap()
|
||||
scrollRO = new ResizeObserver(() => sync(map.value))
|
||||
})
|
||||
</script>`
|
||||
const blindA = run(PLANTED_A, 'x.vue').filter((h) => h.arm === 'IMMEDIATE-TDZ').length === 0
|
||||
const blindB = run(PLANTED_B, 'x.vue').filter((h) => h.arm === 'HOOK-AFTER-AWAIT').length === 0
|
||||
const noisy = run(CLEAN, 'x.vue')
|
||||
if (blindA || blindB || noisy.length) {
|
||||
console.error('SETUP-ORDER: SELF-TEST FAILED — '
|
||||
+ (blindA ? 'arm A is blind to the shipped TDZ. ' : '')
|
||||
+ (blindB ? 'arm B is blind to the shipped late hook. ' : '')
|
||||
+ (noisy.length ? 'false positives on the fixed code: ' + noisy.map((h) => `${h.arm} ${h.why}`).join('; ') : ''))
|
||||
process.exit(3)
|
||||
}
|
||||
if (POISON) { console.log('SETUP-ORDER: self-test passed (both defects caught, both fixes quiet).'); process.exit(0) }
|
||||
|
||||
// ── scan ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
const ROOTS = ['plugin-designs', 'library']
|
||||
const SKIP = /(^|\/)(node_modules|dist|public|\.git|fixtures)(\/|$)/
|
||||
function files(dir, acc = []) {
|
||||
let entries
|
||||
try { entries = readdirSync(dir) } catch (_) { return acc }
|
||||
for (const e of entries) {
|
||||
const p = join(dir, e)
|
||||
if (SKIP.test(relative(REPO, p))) continue
|
||||
let st
|
||||
try { st = statSync(p) } catch (_) { continue }
|
||||
if (st.isDirectory()) files(p, acc)
|
||||
else if (/\.(vue|js|mjs)$/.test(e)) acc.push(p)
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
const found = []
|
||||
for (const root of ROOTS) {
|
||||
for (const f of files(resolve(REPO, root))) {
|
||||
let src
|
||||
try { src = readFileSync(f, 'utf8') } catch (_) { continue }
|
||||
if (!/\bimmediate\s*:\s*true\b|\basync\b/.test(src)) continue
|
||||
for (const h of run(src, f)) found.push({ file: relative(REPO, f), ...h })
|
||||
}
|
||||
}
|
||||
|
||||
if (!found.length) {
|
||||
console.log('SETUP-ORDER: OK — no immediate watcher reads below itself, and no lifecycle hook is registered after an await.')
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`SETUP-ORDER: ${found.length} defect(s) —`)
|
||||
for (const h of found) console.error(` ${h.arm} ${h.file}:${h.line} ${h.why}`)
|
||||
console.error('\nARM A: move the declaration above the watcher (a hoisted function resolves its NAME early, not its BODY).')
|
||||
console.error('ARM B: declare the handle at setup scope and tear it down in a hook registered BEFORE the first await.')
|
||||
process.exit(1)
|
||||
|
|
@ -194,6 +194,7 @@
|
|||
"check:scrim-viewport:self-test": "node internal/check-scrim-covers-viewport.mjs --self-test",
|
||||
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
|
||||
"check:settled": "node internal/settled-tree-gate.mjs",
|
||||
"check:setup-order": "node internal/check-setup-order.mjs",
|
||||
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
|
||||
"check:regions-uses-library": "node plugin-designs/regions-dashboard/scripts/check-regions-uses-library.mjs",
|
||||
"check:religion-label-not-value": "node plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs",
|
||||
|
|
@ -234,7 +235,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
"lists": "node library/scripts/people-groups/build-lists.mjs",
|
||||
|
|
@ -396,6 +397,7 @@
|
|||
"check:religion-label-not-value": "the library's two RELIGION_FAMILIES sites (useLegendData, useSearch) show religionFamilyLabel() to the reader, while the value a filter or search matches on stays the canonical English family name: a translated label must never become a wire value.",
|
||||
"check:scrim-viewport": "a full-screen SCRIM must cover the VIEWPORT, not just the content pane. A position:fixed;inset:0 scrim is PANE-relative wherever an ancestor sets contain/transform — VerticalTabsLayout sets contain:layout on .vtl-content — so a scrim dimmed 1024x793 @ (256,107) at a 1280x900 viewport and left the 256px rail CLICKABLE behind a modal. A source grep cannot see it: the declaration is correct, the ANCESTOR is the variable. Needs `bun run dev`; browser gate, not in build:unlocked.",
|
||||
"check:scrim-viewport:self-test": "reverts each scrim to plain inset:0 and requires EVERY case to FAIL — a gate that cannot fail is worse than no gate, and this one measures a property that looks fine in a screenshot either way.",
|
||||
"check:setup-order": "a line's POSITION in a setup block is not the MOMENT it runs, and two P0s from the Driver's console came from that gap. ARM A: `watch(..., { immediate: true })` runs during setup, so anything it reaches must be declared ABOVE it - research-map.vue threw `Cannot access '_lastFitKey' before initialization` and blanked the whole map, because the watcher called a HOISTED function 350 lines down whose own constants were still in their temporal dead zone. A hoisted function promises that its NAME resolves early, not that its BODY can run early, and that asymmetry is invisible from both ends. ARM B: a lifecycle registrar closes over Vue's current instance, which exists only while setup is on the stack, so `onMounted(async () => { await init(); onBeforeUnmount(clean) })` drops the registration silently - DashboardMapPanel.vue leaked a ResizeObserver on document.body plus a window listener, warning only, feature working, and research-map.vue had a second copy this gate found. The fix is never 'move the line': declare the handle at setup scope and tear it down in a hook registered before the first await. Its own false positives are the interesting part - it went 6 hits -> 1 over three rounds, and every one it dropped was prose or shadowing read as code: `onBeforeUnmount` inside a console.error STRING, `table` inside `new Error(`table not found (${r.status})`)` in four files, and a `const [pins, byCountry]` destructured INSIDE the function, shadowing a computed 220 lines away. So it blanks comments, string text and template text (keeping ${...}, which is code) and subtracts every name a span declares for itself. Self-tests on both shipped defects, both fixes, and all three decoys; exits 3 if any arm goes blind.",
|
||||
"check:sfc-parse": "every .vue script block must parse as the BUILD parses it — catches the LIVE-CSS backtick that check:template-literal cannot see (it reads CSS comments only). 0 false positives on 83 files, where 3 lookahead heuristics scored 98 and 136.",
|
||||
"check:shared-reached": "a shared library fix is only as deployed as the last rebuild of EVERY bundle that consumes it. Each bundle ships its own COMPILED copy, so a one-line fix in library/ is live in whichever bundles happened to be rebuilt after it and silently absent from the rest — and every other gate measures ONE leaf, which renders the old behaviour perfectly. Asserts a distinctive marker string per tracked change; consumers are DERIVED from imports, never hardcoded.",
|
||||
"check:shot-distinctness": "two README slots must not show the SAME view. README shipped \"List — page 1\" and \"page 2\" as the same picture: the shot chased a Next control and a scroll that DO NOT EXIST, every attempt inside try/catch so the miss was silent, and the PNGs differed by 0.16% render noise so no checksum caught them. Keys on url+VIEWPORT — same url at a different vp (deploy2 mobile) is intended.",
|
||||
|
|
|
|||
|
|
@ -2012,6 +2012,22 @@ watch(() => mapStore.filters, () => {
|
|||
// would mean guessing a mapping, and a wrong link is worse than an honest gap.
|
||||
let applyingExternal = false
|
||||
|
||||
// ─── FIT THE MAP TO A SET OF PEOPLES: state, declared here on purpose ────────
|
||||
// These belong with applyFitSlugs() further down, and that is where they used to be. They cannot
|
||||
// live there: the watcher immediately below is `immediate: true`, so it runs DURING setup and
|
||||
// calls applyFitSlugs(raw.fitSlugs) — while a `let`/`const` further down the file is still in its
|
||||
// temporal dead zone. `function applyFitSlugs` hoists; its constants do not, and reading
|
||||
// _lastFitKey threw `Cannot access '_lastFitKey' before initialization`, which killed the whole
|
||||
// setup and blanked the map. Anything an immediate watcher can reach must be declared above it.
|
||||
const FIT_PADDING_PX = 70
|
||||
const FIT_MAX_ZOOM = 5.5 // several peoples: stop at regional framing, never street level
|
||||
const SINGLE_ROW_ZOOM = 6 // one people group: its coordinate is a country-scale centroid
|
||||
const DEGENERATE_SPAN_DEG = 0.01 // a cluster this tight is a point by another route
|
||||
const WIDE_SPAN_DEG = 110 // past this the set wraps the globe; frame the world instead
|
||||
const WORLD_ZOOM = 1.45
|
||||
const FLY_DURATION_MS = 900
|
||||
let _lastFitKey = ''
|
||||
|
||||
watch(() => profileConfig?.value?.externalFilters, (raw) => {
|
||||
if (!raw || typeof raw !== 'object' || !mapStore) return
|
||||
applyingExternal = true
|
||||
|
|
@ -2419,15 +2435,9 @@ watch(() => uiStore.religionFilter, (key) => {
|
|||
else applyDimFilter({ kind: null })
|
||||
})
|
||||
|
||||
// ─── FIT THE MAP TO A SET OF PEOPLES (see the fitSlugs note in the externalFilters watcher) ───
|
||||
const FIT_PADDING_PX = 70
|
||||
const FIT_MAX_ZOOM = 5.5 // several peoples: stop at regional framing, never street level
|
||||
const SINGLE_ROW_ZOOM = 6 // one people group: its coordinate is a country-scale centroid
|
||||
const DEGENERATE_SPAN_DEG = 0.01 // a cluster this tight is a point by another route
|
||||
const WIDE_SPAN_DEG = 110 // past this the set wraps the globe; frame the world instead
|
||||
const WORLD_ZOOM = 1.45
|
||||
const FLY_DURATION_MS = 900
|
||||
let _lastFitKey = ''
|
||||
// ─── FIT THE MAP TO A SET OF PEOPLES ─────────────────────────────────────────
|
||||
// Its constants and the _lastFitKey guard are declared ABOVE the externalFilters watcher, not here
|
||||
// beside applyFitSlugs() — see the note there.
|
||||
|
||||
function prefersReducedMotion() {
|
||||
try {
|
||||
|
|
@ -2620,6 +2630,11 @@ let _containerObserver = null
|
|||
// the scrollport settles (a lazy-mounted host has no final height at any fixed timeout; see the
|
||||
// comment on _syncScrollZoomToHost above). Torn down in onBeforeUnmount.
|
||||
let _scrollZoomObserver = null
|
||||
// Its window-'resize' twin, declared out here for the same reason the observer is: both are created
|
||||
// after onMounted's `await initializeMap()`, which is past the point where a lifecycle hook can
|
||||
// still attach. The observer was already torn down below; the listener was not, because its
|
||||
// onBeforeUnmount sat beside the code that added it and was silently never registered.
|
||||
let _resyncScrollZoom = null
|
||||
function applyContainerScale(width) {
|
||||
const el = rmRoot.value
|
||||
if (!el) return
|
||||
|
|
@ -2729,7 +2744,7 @@ onMounted(async () => {
|
|||
})
|
||||
// See _syncScrollZoomToHost above (BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID, mobile pair).
|
||||
_syncScrollZoomToHost(map.value)
|
||||
const _resyncScrollZoom = () => _syncScrollZoomToHost(map.value)
|
||||
_resyncScrollZoom = () => _syncScrollZoomToHost(map.value)
|
||||
try {
|
||||
_scrollZoomObserver = new ResizeObserver(_resyncScrollZoom)
|
||||
const host = (map.value.getContainer && map.value.getContainer()) || null
|
||||
|
|
@ -2739,7 +2754,6 @@ onMounted(async () => {
|
|||
if (document.body) _scrollZoomObserver.observe(document.body)
|
||||
} catch (_) { /* no ResizeObserver: the initial call above still answers for a static page */ }
|
||||
window.addEventListener('resize', _resyncScrollZoom)
|
||||
onBeforeUnmount(() => window.removeEventListener('resize', _resyncScrollZoom))
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
|
@ -2747,6 +2761,7 @@ onBeforeUnmount(() => {
|
|||
window.removeEventListener('legend:highlight', onLegendHighlight)
|
||||
if (_containerObserver) { _containerObserver.disconnect(); _containerObserver = null }
|
||||
if (_scrollZoomObserver) { _scrollZoomObserver.disconnect(); _scrollZoomObserver = null }
|
||||
if (_resyncScrollZoom) { window.removeEventListener('resize', _resyncScrollZoom); _resyncScrollZoom = null }
|
||||
mapLayers.stopPrayerGlow()
|
||||
clustering.cleanup?.()
|
||||
selectedPin.cleanup?.()
|
||||
|
|
|
|||
|
|
@ -177,6 +177,13 @@ let added = false
|
|||
// container calls map.resize() on ANY container-size change, so the canvas always fills its cell.
|
||||
// General-correct (not table-specific): also covers window/rail/facet reflow. rAF-coalesced.
|
||||
let containerRO = null
|
||||
// The scroll-zoom resync observer and its window listener. Both are CREATED inside onMounted,
|
||||
// which is async — so they are declared out here and torn down by the one top-level
|
||||
// onBeforeUnmount below. A lifecycle hook registered after that callback's first `await` never
|
||||
// attaches to this instance (Vue has no active instance by then), so the teardown cannot live
|
||||
// next to the code that creates these; it has to live in a hook registered during setup.
|
||||
let scrollRO = null
|
||||
let scrollResync = null
|
||||
let offToggleTable = null
|
||||
let offLayoutResizing = null
|
||||
let _resizeRaf = 0
|
||||
|
|
@ -1219,21 +1226,16 @@ onMounted(async () => {
|
|||
// observer may again be useful if a future host reintroduces the unbounded-map-row precondition)
|
||||
// rather than torn out, but do not read `resync` as gating anything today.
|
||||
syncScrollZoomToHost(m)
|
||||
const resync = () => syncScrollZoomToHost(m)
|
||||
let ro = null
|
||||
scrollResync = () => syncScrollZoomToHost(m)
|
||||
try {
|
||||
ro = new ResizeObserver(resync)
|
||||
scrollRO = new ResizeObserver(scrollResync)
|
||||
const host = (m.getContainer && m.getContainer()) || null
|
||||
if (host) ro.observe(host)
|
||||
if (host) scrollRO.observe(host)
|
||||
const port = host ? findScrollportAncestor(host) : null
|
||||
if (port) ro.observe(port)
|
||||
if (document.body) ro.observe(document.body)
|
||||
if (port) scrollRO.observe(port)
|
||||
if (document.body) scrollRO.observe(document.body)
|
||||
} catch (_) { /* no ResizeObserver: the initial call above still answers for a static page */ }
|
||||
window.addEventListener('resize', resync)
|
||||
onBeforeUnmount(() => {
|
||||
try { ro && ro.disconnect() } catch (_) { /* already torn down */ }
|
||||
window.removeEventListener('resize', resync)
|
||||
})
|
||||
window.addEventListener('resize', scrollResync)
|
||||
// Keep the zoom buttons' enabled state in step with the live camera. 'zoom' fires only during
|
||||
// an actual zoom; 'zoomend' catches the settle. Both just set two booleans (no camera writes).
|
||||
m.on('zoom', syncZoomLimits)
|
||||
|
|
@ -1277,6 +1279,8 @@ onBeforeUnmount(() => {
|
|||
try { offToggleTable && offToggleTable() } catch (_) {} offToggleTable = null
|
||||
try { offLayoutResizing && offLayoutResizing() } catch (_) {} offLayoutResizing = null
|
||||
if (containerRO) { try { containerRO.disconnect() } catch (_) {} containerRO = null }
|
||||
if (scrollRO) { try { scrollRO.disconnect() } catch (_) {} scrollRO = null }
|
||||
if (scrollResync) { try { window.removeEventListener('resize', scrollResync) } catch (_) {} scrollResync = null }
|
||||
if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null
|
||||
if (_resizeRaf) { cancelAnimationFrame(_resizeRaf); _resizeRaf = 0 }
|
||||
// Detach the zoom-limit listeners before destroy() so they can never run on a dead map.
|
||||
|
|
|
|||
Loading…
Reference in a new issue