Every Studio gallery tab after the first threw from createI18n: create it in a detached scope

The Driver's console, P0: in Plugin Studio's gallery, switching tabs threw "SyntaxError: Unexpected error"
from createI18n in createAppI18n. vue-i18n's createGlobal() runs inside scope.run() and throws
UNEXPECTED_ERROR when that returns undefined; Vue 3.5 creates a new EffectScope INACTIVE when the current
one is stopped; and a custom element's connectedCallback -> configureApp -> createAppI18n ran while the
released tab's stopped scope was still current. Root cause traced by dx-shepherd.

The i18n instance and its locale watcher are now created in effectScope(true), which never inherits the
ambient scope. The install wrapper also makes a second provide('appI18n') of the same instance a no-op, so
the bundle entries that still provide it themselves stop warning "App already provides property".

check:i18n-stopped-scope (in gates:post): a bare createI18n() inside a stopped scope must throw (control),
createAppI18n() there must succeed, translate and keep its locale watcher live, and a second provide must
not warn. On the previous index.js it fails with the Driver's exact error.
This commit is contained in:
Doxa Maps 2026-09-27 02:32:07 +00:00
parent 3f6e29ebc9
commit 5ec50a8124
3 changed files with 83 additions and 5 deletions

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* check-i18n-stopped-scope.mjs — createAppI18n() must succeed while a STOPPED effect scope is current.
*
* WHY (P0, the Driver's console, 2026-09-27): in Plugin Studio's gallery every tab after the first threw
* "SyntaxError: Unexpected error" from createI18n inside createAppI18n. vue-i18n's createGlobal() runs in
* scope.run() and throws UNEXPECTED_ERROR when that returns undefined; Vue 3.5 creates a new EffectScope
* INACTIVE when the currently active one is stopped; and a custom element's connectedCallback ->
* configureApp -> createAppI18n can run while the released tab's stopped scope is still current.
*
* The CONTROL comes first: a bare createI18n() inside the same stopped scope must THROW, or this harness
* does not reproduce the failure and a green result would prove nothing. Then createAppI18n() inside it
* must succeed, translate, and keep reacting to a locale change.
*/
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
let bad = 0
const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — ' + got}`) }
const { createServer } = await import('vite')
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
server: { middlewareMode: true, hmr: false, ws: false, watch: null }, resolve: { alias: { '@map': join(ROOT, 'library') } } })
try {
globalThis.__INTLIFY_JIT_COMPILATION__ = true
const { effectScope } = await import('vue')
const { createI18n } = await import('vue-i18n')
const mod = await server.ssrLoadModule('/library/i18n/index.js')
// Set AFTER vue loads (runtime-dom probes `document` at import). These are the two page facts
// createAppI18n reads, the URL and <html lang>, as a plain English page would give them.
globalThis.window ??= { location: { search: '' } }
globalThis.document ??= { documentElement: { lang: 'en', dir: '' } }
// A scope that ran, was stopped, and is still the current one — the released tab's.
const stopped = effectScope()
stopped.run(() => {})
stopped.stop()
const inStopped = (fn) => { stopped.on(); try { return fn() } finally { stopped.off() } }
let controlThrew = false
try { inStopped(() => createI18n({ legacy: false, locale: 'en', messages: { en: { a: 'b' } } })) } catch { controlThrew = true }
check('CONTROL: a bare createI18n() inside a stopped scope throws (the harness reproduces the bug)', controlThrew, 'it did not throw')
let i18n = null, err = null
try { i18n = inStopped(() => mod.createAppI18n()) } catch (e) { err = e }
check('createAppI18n() succeeds inside a stopped scope', !!i18n && !err, err && String(err))
if (i18n) {
check('and it translates', i18n.global.t('buttons.close') !== 'buttons.close', i18n.global.t('buttons.close'))
i18n.global.locale.value = 'ar'
await new Promise((r) => setTimeout(r, 0))
check('and its locale watcher is live (the static reader follows a runtime switch)', mod.getRuntimeLocale() === 'ar', mod.getRuntimeLocale())
// The bundle entries that still provide appI18n themselves after app.use(i18n) must not make Vue warn.
const { createApp } = await import('vue')
const warns = []; const warn = console.warn; console.warn = (...a) => warns.push(a.join(' '))
try { const app = createApp({}); app.use(i18n); app.provide('appI18n', i18n) } finally { console.warn = warn }
check('a bundle providing appI18n again after app.use(i18n) raises no Vue warning', !warns.some((w) => /appI18n/.test(w)), warns.join(' | '))
}
} finally {
await server.close()
}
if (bad) { console.error(`\nI18N-STOPPED-SCOPE: FAIL — ${bad} assertion(s)`); process.exit(1) }
console.log('\nI18N-STOPPED-SCOPE: PASS — the app i18n is created live even while a stopped effect scope is current.')

View file

@ -45,7 +45,7 @@
* See documentation/feedback.md → Section A for the design rationale.
*/
import { watch } from 'vue'
import { watch, effectScope } from 'vue'
import { createI18n } from 'vue-i18n'
// Eager glob — all locale subfolders inlined at build time
@ -154,7 +154,14 @@ export function createAppI18n() {
document.documentElement.dir = 'rtl'
}
} catch { /* no document (SSR/tests) — nothing to reflect */ }
const i18n = createI18n({
// ★ A DETACHED EFFECT SCOPE (P0, the Driver's console, 2026-09-27). vue-i18n's createGlobal() runs
// inside scope.run() and throws UNEXPECTED_ERROR ("SyntaxError: Unexpected error") when that returns
// undefined; Vue 3.5 creates a new EffectScope INACTIVE when the currently active scope is STOPPED.
// A custom element's connectedCallback -> configureApp -> here can run while a released tab's stopped
// scope is still current (Plugin Studio's gallery, every tab after the first). effectScope(true) never
// inherits the ambient scope, so this instance and its locale watcher are always created live.
const i18nScope = effectScope(true)
const i18n = i18nScope.run(() => createI18n({
legacy: false,
globalInjection: true,
locale,
@ -162,7 +169,7 @@ export function createAppI18n() {
messages,
missingWarn: false,
fallbackWarn: false,
})
}))
// ★ INSTALLING i18n ALSO PROVIDES IT, so a bundle CANNOT forget. This is the durable half of a
// bug that is invisible by construction.
@ -195,12 +202,17 @@ export function createAppI18n() {
// Watching the instance's own locale ref is the single choke point: ProfileLoader, the SPA's
// picker and the studio's picker all move THIS ref, so none of them has to remember to also tell
// the static reader.
watch(i18n.global.locale, (l) => setRuntimeLocale(l), { immediate: true })
i18nScope.run(() => watch(i18n.global.locale, (l) => setRuntimeLocale(l), { immediate: true }))
const install = i18n.install.bind(i18n)
i18n.install = (app, ...rest) => {
const out = install(app, ...rest)
app.provide('appI18n', i18n)
// Several bundle entries still call app.provide('appI18n', i18n) themselves after app.use(i18n). The
// value is the same instance, but Vue warns "App already provides property with key appI18n" on every
// mount. A second provide of THIS instance under that key is made a no-op; anything else passes through.
const provide = app.provide.bind(app)
app.provide = (key, value) => (key === 'appI18n' && value === i18n ? app : provide(key, value))
return out
}
return i18n

View file

@ -107,6 +107,7 @@
"check:i18n-keys-exist": "node plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs",
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
"check:i18n-stopped-scope": "node internal/check-i18n-stopped-scope.mjs",
"check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs",
"check:kit-options-reachable": "node plugin-designs/research-dashboard/scripts/check-kit-options-reachable.mjs",
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
@ -231,7 +232,7 @@
"dev": "bun x vite",
"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",
"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",
"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",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -337,6 +338,7 @@
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.",
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
"check:i18n-stopped-scope": "createAppI18n() must succeed while a STOPPED effect scope is current: the Driver's P0 (Plugin Studio gallery, every tab after the first threw 'SyntaxError: Unexpected error' from createI18n, because Vue 3.5 makes a new scope inactive under a stopped one). A bare createI18n() in the same scope must throw first, or the harness proves nothing. Also asserts a bundle's second provide('appI18n') raises no warning.",
"check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
"check:kit-options-reachable": "every option a shared kit composable ACCEPTS must be honoured somewhere in its body. Written because I nearly shipped one: kit landing 3 gave useSelectionCamera a `coords` option so a dashboard whose rows spell their coordinate differently could say so, and it was accepted in the signature and never passed to selectionBounds - a caller could set it, type-check clean, build clean and watch it do nothing. That is the worst shape a missing feature takes, because it READS as supported and nobody files a bug against a knob they assume they misused; this tree already paid for it once with a defineProps on a component the custom element never binds. It matters more for the kit than for one dashboard: the whole promise of SPEC-DASHBOARD-KIT is that a config produces a working dashboard, and a parameter that is merely present turns that promise into a guess. Strips comments from BOTH the signature and the body before searching - the first version was satisfied by the comment beside the fixed call site, and the second read comment prose as option names. Not a type checker: it catches a completely unused option, which is the failure invisible from outside.",
"check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.",