diff --git a/internal/check-i18n-stopped-scope.mjs b/internal/check-i18n-stopped-scope.mjs new file mode 100644 index 000000000..0e7bc7788 --- /dev/null +++ b/internal/check-i18n-stopped-scope.mjs @@ -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 , 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.') diff --git a/library/i18n/index.js b/library/i18n/index.js index e8009ac40..66576b9db 100644 --- a/library/i18n/index.js +++ b/library/i18n/index.js @@ -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 diff --git a/package.json b/package.json index fb35fb396..07ddf661c 100644 --- a/package.json +++ b/package.json @@ -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 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 .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.",