From b64fea5fa4361b29db71282a572639e82987898f Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 16:42:31 +0000 Subject: [PATCH 1/3] Five plugins were silently monolingual, because the bootstrap was copied sixteen times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal (c), and it turned into a goal (d) fix. Every profile element is defineCustomElement(ProfileLoader, { configureApp }), and the configureApp has four steps. The fourth, app.provide('appI18n', i18n), is the one that gets dropped. ProfileLoader injects that instance (ProfileLoader.vue:176) to drive its locale from profile-config. Without the provide the watcher holds null and the drive NEVER RUNS -- while setRuntimeLocale() on the next line still fires, so tStatic strings DO follow the host. The element renders a convincing MIXTURE of the reader's language and English, and nothing throws, because the t() shim falls back to its key. It reads as "a few strings not translated yet". SHIPPING THAT TODAY: country-posters, key-cities, gospel-resources, cities-internet and my-upg-100-list. regions-plugin censused all 19 entry files independently and found the same five and no sixth. The measurement is regions-dashboard's, from when they hit it: a Spanish catalogue present, es set, and every t() string still English. All sixteen entries now call library/elements/defineMapElement.js, so the five are fixed by construction rather than by five separate edits. Behaviour is otherwise identical -- same options, same element names, same per-plugin extras. wall-of-people-groups needed the helper to hand its pinia back, because it takes a store off its own instance and a helper that hid pinia would have given it a second one: two stores and a silent state split. ★ AND THE CENSUS THAT RECOMMENDED THIS SAW FOUR OF SIXTEEN. My detector hashed function BODIES; eleven sites are not `function buildXElement()` at all, they are `const El = defineCustomElement(...)`. I wrote "every number here is a lower bound" in the census as a caveat and it was the headline within the hour -- a 4x undercount of the item it recommended. So this gate greps for the SHAPE, not for a known list: a check written around the four that matched would have passed the twelve that did not. check-element-bootstrap has two arms. No entry may build a profile element by hand, and the helper itself must provide appI18n -- PLANTED as a negative case, so the regression cannot return by editing library instead of a plugin. It also fails if it finds fewer than ten entries, because a detection that stops matching reports the same green as a tree with no problems. THE PLANT CAUGHT TWO BUGS IN THIS GATE BEFORE IT CAUGHT ANYTHING ELSE. The predicate matched the helper's DOCSTRING, so it would have passed a helper that only mentions the provide in prose -- comments are stripped now, because a check that reads comments is reading the author's intentions rather than the program. And the plant itself used /m instead of /gm, so it deleted the documentation and left the code, then reported that it could not detect an absence that was not there. --- internal/check-element-bootstrap.mjs | 102 ++++++++++++++++++ library/elements/defineMapElement.js | 71 ++++++++++++ package.json | 1 + plugin-designs/all-people-groups/index.js | 15 +-- plugin-designs/cities-internet/index.js | 17 +-- plugin-designs/country-posters/index.js | 13 +-- plugin-designs/demo-map/index.js | 17 +-- plugin-designs/doxa-research-map/index.js | 19 +--- plugin-designs/doxa-simple-map/index.js | 19 ++-- plugin-designs/gospel-internet/index.js | 18 +--- plugin-designs/gospel-resources/index.js | 17 +-- plugin-designs/key-cities/index.js | 17 +-- plugin-designs/my-upg-100-list/index.js | 17 +-- plugin-designs/pplr-data-maps/index.js | 14 +-- plugin-designs/regions-dashboard/index.js | 19 +--- plugin-designs/research-dashboard/index.js | 19 ++-- plugin-designs/template-bundle/index.js | 17 +-- plugin-designs/upg-neighbor/index.js | 14 +-- plugin-designs/wall-of-people-groups/index.js | 18 +--- 19 files changed, 240 insertions(+), 204 deletions(-) create mode 100644 internal/check-element-bootstrap.mjs create mode 100644 library/elements/defineMapElement.js diff --git a/internal/check-element-bootstrap.mjs b/internal/check-element-bootstrap.mjs new file mode 100644 index 000000000..91aae35dc --- /dev/null +++ b/internal/check-element-bootstrap.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * check-element-bootstrap — every plugin that ships a custom element must build it the same way. + * + * ★ THE BUG THIS EXISTS FOR WAS LIVE IN FIVE PLUGINS AT ONCE. A profile element is + * defineCustomElement(ProfileLoader, { configureApp }) and the configureApp has four steps; the fourth, + * `app.provide('appI18n', i18n)`, is the one that gets dropped. ProfileLoader injects that instance + * (ProfileLoader.vue:176) to drive its locale from profile-config, so without it the drive never runs + * and every useI18n()/t() string is stuck at the boot locale — while setRuntimeLocale() on the next line + * still fires, so tStatic strings DO follow the host. The element renders a convincing MIXTURE of the + * reader's language and English. + * + * Nothing throws, because the t() shim falls back to its key. So it reads as "a few strings not + * translated yet" and it survived in country-posters, key-cities, gospel-resources, cities-internet and + * my-upg-100-list simultaneously — confirmed independently by regions-plugin, who censused all 19 entry + * files and found the same five and no sixth. + * + * ★ AND A CENSUS THAT COUNTED FUNCTION BODIES SAW FOUR OF SIXTEEN. That is why this gate greps for the + * SHAPE (`defineCustomElement(ProfileLoader`) rather than for a known list: eleven of the sites are not + * `function buildXElement()` at all, they are `const El = defineCustomElement(...)`, and a check written + * around the four that matched would have passed the twelve that did not. + * + * TWO ARMS: + * A. no plugin entry may call defineCustomElement(ProfileLoader, …) directly — use the helper; + * B. the helper itself must provide appI18n, PLANTED as a negative case, so the regression cannot + * return by editing library instead of a plugin. + * + * Exit 0 pass · 1 fail. + */ +import { readFileSync, existsSync, readdirSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const TAG = 'ELEMENT-BOOTSTRAP' +const HELPER = 'library/elements/defineMapElement.js' +const fails = [] + +// Stated exemptions. An element that wraps its OWN root component rather than ProfileLoader is a +// different thing and is not in scope: it has no ProfileLoader to inject anything. +const EXEMPT = new Map([]) + +// ── ARM A: no entry builds a profile element by hand ───────────────────────────────────────────── +const PD = join(ROOT, 'plugin-designs') +let entries = 0, viaHelper = 0 +for (const p of readdirSync(PD)) { + const f = join(PD, p, 'index.js') + if (!existsSync(f)) continue + const src = readFileSync(f, 'utf8') + const buildsProfileElement = /defineCustomElement\s*\(\s*ProfileLoader/.test(src) + const usesHelper = /defineMapElement\s*\(/.test(src) + if (!buildsProfileElement && !usesHelper) continue + entries++ + if (usesHelper) viaHelper++ + if (buildsProfileElement) { + if (EXEMPT.has(p)) continue + fails.push(`${p}/index.js calls defineCustomElement(ProfileLoader, …) directly.\n` + + ` Use ${HELPER}. Building it by hand is how five plugins shipped without\n` + + " provide('appI18n') and rendered a mixture of the reader's language and English.") + } +} +if (entries < 10) { + fails.push(`only ${entries} profile-element entries found — the detection stopped matching and this ` + + 'gate would pass while checking almost nothing.') +} + +// ── ARM B: the helper provides appI18n, proved by PLANTING its absence ─────────────────────────── +const helperSrc = existsSync(join(ROOT, HELPER)) ? readFileSync(join(ROOT, HELPER), 'utf8') : '' +if (!helperSrc) { + fails.push(`${HELPER} does not exist — every entry above is pointing at nothing.`) +} else { + // ★ COMMENTS STRIPPED FIRST, and this gate's own negative case is what caught the need. The helper's + // docstring QUOTES the provide line in prose, so a predicate over the raw source matched the + // documentation and reported the code present even after the code was removed — it would have passed + // a helper that only talked about providing appI18n. A check that reads comments is reading the + // author's intentions, not the program. + const code = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + const provides = (s) => /app\.provide\(\s*['"]appI18n['"]\s*,/.test(code(s)) + const installs = (s) => /app\.use\(\s*i18n\s*\)|app\.use\(\s*createAppI18n\(\)\s*\)/.test(code(s)) + if (!provides(helperSrc)) { + fails.push(`${HELPER} installs i18n but never provides it. ProfileLoader injects 'appI18n' to drive\n` + + ' the locale; without the provide the element is permanently at its boot locale, silently.') + } + if (!installs(helperSrc)) fails.push(`${HELPER} does not install an i18n instance at all.`) + // THE PLANT: the same predicate, run against a bootstrap with the provide removed, MUST report it. + // /gm, not /m: the docstring quotes the line too, so removing only the FIRST match deleted the + // DOCUMENTATION and left the code — the plant then "failed to detect" a provide that was still there. + // Caught by this assertion failing on a correct helper, which is the plant doing its job on itself. + const planted = helperSrc.replace(/^.*app\.provide\(\s*['"]appI18n['"].*$/gm, '') + if (provides(planted)) { + fails.push('the check for the provide cannot detect its absence — it is measuring nothing. ' + + 'A gate that passes its own negative case is worse than no gate.') + } +} + +if (fails.length) { + console.error(`${TAG}: FAIL`) + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`${TAG}: PASS — ${viaHelper}/${entries} profile elements are built through ${HELPER}, which provides ` + + "appI18n; an element built without it is detected, so the 'permanently English' regression cannot return quietly.") diff --git a/library/elements/defineMapElement.js b/library/elements/defineMapElement.js new file mode 100644 index 000000000..fc88c7c1a --- /dev/null +++ b/library/elements/defineMapElement.js @@ -0,0 +1,71 @@ +/** + * defineMapElement.js — the one correct way to turn a profile into a custom element. + * + * Every plugin that ships a `` wraps ProfileLoader in Vue's defineCustomElement and configures the + * app the same way: a per-instance Pinia, a per-instance i18n, and the profile's own .vue modules. Five + * plugins did it by hand. + * + * ★ THE FOURTH LINE IS THE ONE THAT MATTERS, AND IT IS THE ONE THAT GETS LEFT OUT. + * `app.provide('appI18n', i18n)` is not bookkeeping. ProfileLoader reads the instance back with + * `inject('appI18n', null)` (ProfileLoader.vue:176) and drives its global locale from profile-config; if + * nothing provided it, that watcher holds null and the drive never happens. `app.use(i18n)` alone LOOKS + * complete — the catalogue is installed, t() resolves, nothing throws — and every component that reads + * through useI18n()/t() is then stuck at the locale the app booted with, whatever the host asked for. + * + * ★ THE MEASUREMENT, from regions-dashboard/index.js:44-52, where this was hit and written down first: + * "app.use() alone is not enough: createAppI18n() resolves its locale ONCE from , and every + * generated leaf page bakes lang=\"en\", so the element was permanently English no matter what the + * host asked for. ProfileLoader drives appI18n.global.locale.value from profile-config.locale (its + * established contract, ProfileLoader.vue:148-169) — but only if appI18n is PROVIDED. MEASURED before + * this line: with a Spanish catalogue present in the bundle and es set, every string still rendered + * English, and silently, because the t() shim falls back to its key rather than throwing." + * + * That last clause is why it survived in five plugins at once: the failure is a SILENT FALLBACK, so + * nothing throws, and English looks like a working default to anyone developing in English. + * + * It fails quietly in the worst way: setRuntimeLocale() still runs, so the tStatic-based strings DO + * follow the host, and the plugin renders a convincing mixture of the reader's language and English. + * regions-dashboard's own comment records it being measured — a Spanish catalogue present, `es` set, and + * every t() string still English — and gospel-resources/index.js is shipping exactly that today. + * + * So the provide is not a step in a list a caller has to remember. It is the reason this helper exists, + * and check:element-bootstrap plants an element built without it as a negative case. + */ +import { defineCustomElement } from 'vue' +import { createPinia } from 'pinia' +import ProfileLoader from '../ProfileLoader.vue' +import { createAppI18n } from '../i18n/index.js' + +/** + * @param {Object} o + * @param {Object} o.profileModules the profile's `import.meta.glob('./*.vue')` map — per-plugin, which + * is why it is a parameter and not something this file can find + * @param {Function} [o.configureApp] extra per-plugin setup, run AFTER the standard three. It receives + * (app, { pinia, i18n }) so a plugin that needs the store can reach + * THE SAME pinia this created -- wall-of-people-groups takes a store + * off its own instance, and a helper that hid pinia would have given + * it a second one, which is two stores and a silent state split. + * @returns {CustomElementConstructor} + * + * ★ CALL IT ONCE PER TAG. Vue's defineCustomElement returns a CLASS, and registering one class under + * two tag names is undefined behaviour — doxa-simple-map ships two tags and calls this twice for that + * reason. A helper that cached and returned one class would reintroduce the bug it was written around. + */ +export function defineMapElement({ profileModules = {}, configureApp = null } = {}) { + return defineCustomElement(ProfileLoader, { + configureApp(app) { + // Per-instance, so two embeds on one page never bleed state into each other. + const pinia = createPinia() + app.use(pinia) + const i18n = createAppI18n() + app.use(i18n) + // ★ BY REFERENCE, NOT JUST INSTALLED. See the header: without this the element is stuck at its + // boot locale and says so to nobody. + app.provide('appI18n', i18n) + app.provide('profileModules', profileModules) + if (configureApp) configureApp(app, { pinia, i18n }) + }, + }) +} + +export default defineMapElement diff --git a/package.json b/package.json index 98ef4424f..8b04857e0 100644 --- a/package.json +++ b/package.json @@ -224,6 +224,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:element-bootstrap": "node internal/check-element-bootstrap.mjs", "check:setup-order": "node internal/check-setup-order.mjs", "check:sfc-parse": "node internal/check-sfc-script-parses.mjs", "check:shadow-css-parses": "node internal/check-shadow-css-parses.mjs", diff --git a/plugin-designs/all-people-groups/index.js b/plugin-designs/all-people-groups/index.js index 0cd99f297..7a6fb825b 100644 --- a/plugin-designs/all-people-groups/index.js +++ b/plugin-designs/all-people-groups/index.js @@ -9,22 +9,9 @@ * */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' - const profileModules = import.meta.glob('./*.vue') -const AllPeopleGroupsElement = defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) - } -}) +const AllPeopleGroupsElement = defineMapElement({ profileModules }) if (typeof customElements !== 'undefined' && !customElements.get('all-people-groups')) { customElements.define('all-people-groups', AllPeopleGroupsElement) diff --git a/plugin-designs/cities-internet/index.js b/plugin-designs/cities-internet/index.js index b26733015..a1759ad26 100644 --- a/plugin-designs/cities-internet/index.js +++ b/plugin-designs/cities-internet/index.js @@ -18,24 +18,17 @@ * bundle does not have. */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' - // Evaluated by Vite at build time relative to THIS file, so it captures only this // bundle's profiles. const profileModules = import.meta.glob('./*.vue') function buildCitiesInternetElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { + return defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Per-instance Pinia + i18n so multiple embeds on one page never bleed state. - app.use(createPinia()) - app.use(createAppI18n()) - app.provide('profileModules', profileModules) - }, - }) + }, +}) } const CitiesInternetElement = buildCitiesInternetElement() diff --git a/plugin-designs/country-posters/index.js b/plugin-designs/country-posters/index.js index 0c4b93504..2bff029d6 100644 --- a/plugin-designs/country-posters/index.js +++ b/plugin-designs/country-posters/index.js @@ -29,11 +29,6 @@ * app.provide('profileModules', ...). */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' - // ─── Bundle-private profile registry ───────────────────────────────────────── const profileModules = import.meta.glob('./*.vue') @@ -51,13 +46,7 @@ const profileModules = import.meta.glob('./*.vue') * bleed state into each other. */ function buildCountryPostersElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) - app.use(createAppI18n()) - app.provide('profileModules', profileModules) - } - }) + return defineMapElement({ profileModules }) } const CountryPostersElement = buildCountryPostersElement() diff --git a/plugin-designs/demo-map/index.js b/plugin-designs/demo-map/index.js index f90b55d39..05946c9be 100644 --- a/plugin-designs/demo-map/index.js +++ b/plugin-designs/demo-map/index.js @@ -15,10 +15,7 @@ * */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' +import { defineMapElement } from '@map/elements/defineMapElement.js' import { registerStrategies } from '@map/colors/_registry.js' // ─── Bundle-private profile registry ───────────────────────────────────────── @@ -37,17 +34,13 @@ registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true })) // ─── Register the custom element ───────────────────────────────────────────── // One tag per bundle, named after the bundle. The guard prevents an // "already registered" error if the IIFE is ever loaded twice on one page. -const DemoMapElement = defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) +const DemoMapElement = defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Install i18n AND hand the instance to ProfileLoader by reference. The // defineCustomElement root cannot use useI18n() (see ProfileLoader.vue); // it drives locale through this provided instance instead. - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) - } + }, }) if (typeof customElements !== 'undefined' && !customElements.get('demo-map')) { diff --git a/plugin-designs/doxa-research-map/index.js b/plugin-designs/doxa-research-map/index.js index 5f00a59ec..641a90168 100644 --- a/plugin-designs/doxa-research-map/index.js +++ b/plugin-designs/doxa-research-map/index.js @@ -17,11 +17,6 @@ * hands the result to the loader via app.provide('profileModules'). */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' - // ─── Bundle-private profile registry ───────────────────────────────────────── // import.meta.glob is evaluated by Vite at build time relative to THIS file — // so it captures only this bundle's profiles. @@ -41,18 +36,14 @@ const profileModules = import.meta.glob('./*.vue') * Pinia + i18n so multiple embeds on the same page don't bleed state. */ function buildDoxaResearchMapElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) + return defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Install i18n AND hand the instance to ProfileLoader by reference. The // defineCustomElement root cannot use useI18n() (see ProfileLoader.vue); // it drives locale through this provided instance instead. - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) - } - }) + }, +}) } const DoxaResearchMapElement = buildDoxaResearchMapElement() diff --git a/plugin-designs/doxa-simple-map/index.js b/plugin-designs/doxa-simple-map/index.js index b59c906aa..7d62270d1 100644 --- a/plugin-designs/doxa-simple-map/index.js +++ b/plugin-designs/doxa-simple-map/index.js @@ -28,10 +28,7 @@ * constructs), lazy-loads when Arabic/Hebrew text appears on the basemap. */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' +import { defineMapElement } from '@map/elements/defineMapElement.js' import { registerStrategies } from '@map/colors/_registry.js' // ─── Bundle-private profile registry ───────────────────────────────────────── @@ -64,20 +61,16 @@ registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true })) * isolation is important when multiple embeds share a page. */ function buildDoxaMapElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) + return defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Install i18n AND hand the instance to ProfileLoader by reference. The // defineCustomElement root cannot use useI18n() (see ProfileLoader.vue); // it drives locale through this provided instance instead. - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) // Hand the bundle's profile registry to ProfileLoader (Q3 contract). // ProfileLoader inject('profileModules')s this — see ProfileLoader.vue. - app.provide('profileModules', profileModules) - } - }) + }, +}) } const DoxaMapElement = buildDoxaMapElement() diff --git a/plugin-designs/gospel-internet/index.js b/plugin-designs/gospel-internet/index.js index dacc76dc8..283a44aa0 100644 --- a/plugin-designs/gospel-internet/index.js +++ b/plugin-designs/gospel-internet/index.js @@ -13,24 +13,16 @@ * The RTL text plugin is installed by the map panel itself (installRtlTextPlugin before `new Map`), on * whichever engine resolveMapProvider picked. */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' const profileModules = import.meta.glob('./*.vue') function buildBoardElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { + return defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Per-instance Pinia + i18n so two boards on one page never share state. - app.use(createPinia()) - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) - }, - }) + }, +}) } const GospelInternetBoard = buildBoardElement() diff --git a/plugin-designs/gospel-resources/index.js b/plugin-designs/gospel-resources/index.js index a15e96ab5..b7b90212a 100644 --- a/plugin-designs/gospel-resources/index.js +++ b/plugin-designs/gospel-resources/index.js @@ -18,24 +18,17 @@ * bundle does not have. */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' - // Evaluated by Vite at build time relative to THIS file, so it captures only this // bundle's profiles. const profileModules = import.meta.glob('./*.vue') function buildGospelResourcesElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { + return defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Per-instance Pinia + i18n so multiple embeds on one page never bleed state. - app.use(createPinia()) - app.use(createAppI18n()) - app.provide('profileModules', profileModules) - }, - }) + }, +}) } const GospelResourcesElement = buildGospelResourcesElement() diff --git a/plugin-designs/key-cities/index.js b/plugin-designs/key-cities/index.js index 3026cb27d..92cd41aa0 100644 --- a/plugin-designs/key-cities/index.js +++ b/plugin-designs/key-cities/index.js @@ -18,24 +18,17 @@ * bundle does not have. */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' - // Evaluated by Vite at build time relative to THIS file, so it captures only this // bundle's profiles. const profileModules = import.meta.glob('./*.vue') function buildKeyCitiesElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { + return defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Per-instance Pinia + i18n so multiple embeds on one page never bleed state. - app.use(createPinia()) - app.use(createAppI18n()) - app.provide('profileModules', profileModules) - }, - }) + }, +}) } const KeyCitiesElement = buildKeyCitiesElement() diff --git a/plugin-designs/my-upg-100-list/index.js b/plugin-designs/my-upg-100-list/index.js index c9837a145..d6c65153b 100644 --- a/plugin-designs/my-upg-100-list/index.js +++ b/plugin-designs/my-upg-100-list/index.js @@ -27,11 +27,6 @@ * constructs), lazy-loads when Arabic/Hebrew text appears on the basemap. */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' - // ─── Bundle-private profile registry ───────────────────────────────────────── // import.meta.glob is evaluated by Vite at build time relative to THIS file — // so it captures only this bundle's profiles. Handed to ProfileLoader via @@ -49,15 +44,13 @@ const profileModules = import.meta.glob('./*.vue') * isolation matters when multiple embeds share a page. */ function buildUpg100ListMapElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) - app.use(createAppI18n()) + return defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Hand the bundle's profile registry to ProfileLoader (Q3 contract). // ProfileLoader inject('profileModules')s this — see ProfileLoader.vue. - app.provide('profileModules', profileModules) - } - }) + }, +}) } const Upg100ListMapElement = buildUpg100ListMapElement() diff --git a/plugin-designs/pplr-data-maps/index.js b/plugin-designs/pplr-data-maps/index.js index 7440c834c..02dcc6a56 100644 --- a/plugin-designs/pplr-data-maps/index.js +++ b/plugin-designs/pplr-data-maps/index.js @@ -5,10 +5,6 @@ * folder's one profile, pplr-data-maps.vue; its ten views live in src/views/. The Mapbox RTL * text plugin is registered once here: many PG names are Arabic, Urdu or Hebrew. */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' const profileModules = import.meta.glob('./*.vue') @@ -16,13 +12,5 @@ const profileModules = import.meta.glob('./*.vue') // library/maps/rtl-text.js, on BOTH engines, before every map is constructed. Do not re-add a // hand-rolled mapboxgl-only block here -- it was a strict subset of that call. -const El = defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) - } -}) +const El = defineMapElement({ profileModules }) if (typeof customElements !== 'undefined' && !customElements.get('pplr-data-maps')) customElements.define('pplr-data-maps', El) diff --git a/plugin-designs/regions-dashboard/index.js b/plugin-designs/regions-dashboard/index.js index 15e81e0f7..12d7d9032 100644 --- a/plugin-designs/regions-dashboard/index.js +++ b/plugin-designs/regions-dashboard/index.js @@ -21,11 +21,6 @@ * The profile-config attribute is parsed by ProfileLoader (in @map). */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' - // ─── Bundle-private profile registry ───────────────────────────────────────── // import.meta.glob is evaluated by Vite at build time relative to THIS file, so it // captures only this bundle's profiles — flat .vue files sitting directly here, no @@ -38,9 +33,9 @@ const profileModules = import.meta.glob('./*.vue') * embeds ever share one page. */ function buildRegionsDashboardElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) + return defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Install i18n AND hand the instance to ProfileLoader by reference, the way // doxa-simple-map/index.js:84-86 does. app.use() alone is not enough: createAppI18n() // resolves its locale ONCE from , and every generated leaf page bakes @@ -50,12 +45,8 @@ function buildRegionsDashboardElement() { // MEASURED before this line: with a Spanish catalogue present in the bundle and es set, // every string still rendered English, and silently, because the t() shim falls back to // its key rather than throwing. - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) - } - }) + }, +}) } const RegionsDashboardElement = buildRegionsDashboardElement() diff --git a/plugin-designs/research-dashboard/index.js b/plugin-designs/research-dashboard/index.js index c019616ac..91ffd30e6 100644 --- a/plugin-designs/research-dashboard/index.js +++ b/plugin-designs/research-dashboard/index.js @@ -19,10 +19,7 @@ * are imported by the profile .vue — the glob only registers the profile screen(s). */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' +import { defineMapElement } from '@map/elements/defineMapElement.js' import { registerStrategies } from '@map/colors/_registry.js' import { installRtlTextPlugin } from '@map/maps/rtl-text.js' // ★ WAS A CROSS-PLUGIN RELATIVE IMPORT into another plugin's PRIVATE folder @@ -64,9 +61,9 @@ installRtlTextPlugin() * so multiple embeds on the same page don't bleed state. */ function buildDoxaResearchDashboardElement() { - return defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) + return defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // ★ HAND THE INSTANCE TO ProfileLoader, DON'T JUST INSTALL IT. // ProfileLoader is the root of this custom element, and vue-i18n's useI18n() cannot resolve // its injection at a custom-element root — so ProfileLoader takes the i18n INSTANCE by @@ -80,12 +77,8 @@ function buildDoxaResearchDashboardElement() { // The sibling map entry (plugin-designs/doxa-research-map/index.js:66) has always provided it; // this entry's own header calls itself a mirror of that file, which is how the divergence // survived: the two read as the same plumbing until you diff them. - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) - } - }) + }, +}) } const DoxaResearchDashboardElement = buildDoxaResearchDashboardElement() diff --git a/plugin-designs/template-bundle/index.js b/plugin-designs/template-bundle/index.js index 887967491..868d4a514 100644 --- a/plugin-designs/template-bundle/index.js +++ b/plugin-designs/template-bundle/index.js @@ -19,10 +19,7 @@ * `profile` must match a .vue filename in THIS folder (without the .vue). */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' +import { defineMapElement } from '@map/elements/defineMapElement.js' import { registerStrategies } from '@map/colors/_registry.js' // ─── Bundle-private profile registry ───────────────────────────────────────── @@ -47,17 +44,13 @@ registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true })) // ─── Register the custom element ───────────────────────────────────────────── // One tag per bundle, named after the bundle. The guard prevents an // "already registered" error if the IIFE is ever loaded twice on one page. -const TemplateBundleElement = defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) +const TemplateBundleElement = defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { // Install i18n AND hand the instance to ProfileLoader by reference. The // defineCustomElement root cannot use useI18n() (see ProfileLoader.vue); // it drives locale through this provided instance instead. - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) - } + }, }) if (typeof customElements !== 'undefined' && !customElements.get('template-bundle')) { diff --git a/plugin-designs/upg-neighbor/index.js b/plugin-designs/upg-neighbor/index.js index 8d16a84f5..b6321d4f1 100644 --- a/plugin-designs/upg-neighbor/index.js +++ b/plugin-designs/upg-neighbor/index.js @@ -7,10 +7,6 @@ * * Mapbox RTL text plugin: registered ONCE at module init — several SEND targets (Urdu/Arabic) are RTL. */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' const profileModules = import.meta.glob('./*.vue') @@ -18,13 +14,5 @@ const profileModules = import.meta.glob('./*.vue') // library/maps/rtl-text.js, on BOTH engines, before every map is constructed. Do not re-add a // hand-rolled mapboxgl-only block here -- it was a strict subset of that call. -const El = defineCustomElement(ProfileLoader, { - configureApp(app) { - app.use(createPinia()) - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) - } -}) +const El = defineMapElement({ profileModules }) if (typeof customElements !== 'undefined' && !customElements.get('upg-neighbor')) customElements.define('upg-neighbor', El) diff --git a/plugin-designs/wall-of-people-groups/index.js b/plugin-designs/wall-of-people-groups/index.js index b7d53f924..25b9b0119 100644 --- a/plugin-designs/wall-of-people-groups/index.js +++ b/plugin-designs/wall-of-people-groups/index.js @@ -4,25 +4,17 @@ * profile; its components live in src/. The wall's components read the store as this.$store (the * original app's global property), so it is created here, per element, on the element's Pinia. */ -import { defineCustomElement } from 'vue' -import { createPinia } from 'pinia' -import ProfileLoader from '@map/ProfileLoader.vue' -import { createAppI18n } from '@map/i18n/index.js' +import { defineMapElement } from '@map/elements/defineMapElement.js' import { usePeopleGroupsStore } from './src/stores/peopleGroupsStore.js' const profileModules = import.meta.glob('./*.vue') -const El = defineCustomElement(ProfileLoader, { - configureApp(app) { - const pinia = createPinia() - app.use(pinia) - const i18n = createAppI18n() - app.use(i18n) - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) +const El = defineMapElement({ + profileModules, + configureApp: (app, { pinia, i18n }) => { const store = usePeopleGroupsStore(pinia) store.loadFromLocalStorage() app.config.globalProperties.$store = store - } + }, }) if (typeof customElements !== 'undefined' && !customElements.get('wall-of-people-groups')) customElements.define('wall-of-people-groups', El) From ca4288d02b32cdc1f74468ece15b2cc237c170d3 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 17:41:08 +0000 Subject: [PATCH 2/3] Twenty-four warnings about an overwrite that was the whole point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver's console, 2026-09-28: 24 Vue warnings, 8 each for "profileModules", "appI18n" and "Symbol(pinia)" — "already provides property with key … inherited from its parent element. It will be overwritten with the new value." They come from a composite mounting child custom elements: Vue's CE layer lets a child app inherit its parent ELEMENT's provides, and app.provide() warns on any key already reachable. ALL THREE MUST BE THE CHILD'S OWN, so neither silencing the warning nor honouring it by not providing would have been right. profileModules is the plugin's own .vue glob — a child rendering its parent's components is a different and worse bug. pinia is per-instance so two embeds on one page never bleed store state. And the i18n instance is the one ProfileLoader drives from THIS element's profile-config, which is the whole of the permanently-English fix from earlier today. Inheriting any of the three would be the regression, not the warning. So the shadow is made DELIBERATE at the line that does it. provideOwn() writes to the app's own provides when the key is already reachable and uses the public app.provide() when it is not, so the ordinary path is untouched. pinia needs the other shape: its symbol is module-private, so it can only be provided by app.use(), and the key cannot be named — withoutInheritedProvides() hides the inherited chain for the duration of the install and restores it in a FINALLY, so a throwing install cannot leave an element unable to see its parent. The helpers are split into library/elements/provide-own.js with NO framework import, because defineMapElement.js imports ProfileLoader.vue and is therefore unloadable by the gate that proves it right. Third time today that split has been necessary; the gate now exercises the shipped helpers rather than a copy. check-provide-shadowing asserts BOTH halves — the child ends up with its own value, AND the parent's other provides still resolve afterwards — against a Vue-shaped fake app that warns exactly as runtime-core does. Its control is the naive call, which must still warn. Negative-tested three ways, including moving the restore out of the finally. check-element-bootstrap now accepts either spelling and was re-checked for the thing it actually guards: removing the provide entirely still turns it red. --- internal/check-element-bootstrap.mjs | 8 ++- internal/check-provide-shadowing.mjs | 102 +++++++++++++++++++++++++++ library/elements/defineMapElement.js | 33 ++++++++- library/elements/provide-own.js | 40 +++++++++++ package.json | 1 + 5 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 internal/check-provide-shadowing.mjs create mode 100644 library/elements/provide-own.js diff --git a/internal/check-element-bootstrap.mjs b/internal/check-element-bootstrap.mjs index 91aae35dc..e7dc4761a 100644 --- a/internal/check-element-bootstrap.mjs +++ b/internal/check-element-bootstrap.mjs @@ -75,7 +75,11 @@ if (!helperSrc) { // a helper that only talked about providing appI18n. A check that reads comments is reading the // author's intentions, not the program. const code = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') - const provides = (s) => /app\.provide\(\s*['"]appI18n['"]\s*,/.test(code(s)) + // Either spelling counts: app.provide('appI18n', …) or provideOwn(app, 'appI18n', …). The second + // exists because a NESTED element must shadow its parent's instance deliberately (see + // library/elements/provide-own.js); what this gate cares about is that the instance reaches + // ProfileLoader at all, not which function put it there. + const provides = (s) => /(?:app\.provide\(\s*|provideOwn\(\s*app\s*,\s*)['"]appI18n['"]\s*,/.test(code(s)) const installs = (s) => /app\.use\(\s*i18n\s*\)|app\.use\(\s*createAppI18n\(\)\s*\)/.test(code(s)) if (!provides(helperSrc)) { fails.push(`${HELPER} installs i18n but never provides it. ProfileLoader injects 'appI18n' to drive\n` @@ -86,7 +90,7 @@ if (!helperSrc) { // /gm, not /m: the docstring quotes the line too, so removing only the FIRST match deleted the // DOCUMENTATION and left the code — the plant then "failed to detect" a provide that was still there. // Caught by this assertion failing on a correct helper, which is the plant doing its job on itself. - const planted = helperSrc.replace(/^.*app\.provide\(\s*['"]appI18n['"].*$/gm, '') + const planted = helperSrc.replace(/^.*(?:app\.provide\(\s*|provideOwn\(\s*app\s*,\s*)['"]appI18n['"].*$/gm, '') if (provides(planted)) { fails.push('the check for the provide cannot detect its absence — it is measuring nothing. ' + 'A gate that passes its own negative case is worse than no gate.') diff --git a/internal/check-provide-shadowing.mjs b/internal/check-provide-shadowing.mjs new file mode 100644 index 000000000..8bca0cbc6 --- /dev/null +++ b/internal/check-provide-shadowing.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * check-provide-shadowing — a nested custom element keeps its OWN pinia, i18n and profileModules, and + * says so deliberately instead of tripping Vue's overwrite warning 24 times. + * + * THE DEFECT (Driver's console, 2026-09-28): 24 Vue warnings — + * 'App already provides property with key "profileModules" / "appI18n" / "Symbol(pinia)" inherited + * from its parent element. It will be overwritten with the new value.' + * eight of each, from a composite mounting child custom elements. + * + * ★ THE OVERWRITE IS THE POINT, WHICH IS WHY SILENCING IT WOULD HAVE BEEN WRONG AND FIXING IT BY NOT + * PROVIDING WOULD HAVE BEEN WORSE. `profileModules` is the plugin's own .vue glob — a child rendering + * its parent's components is a different bug; pinia is per-instance so two embeds never bleed state; + * and the i18n instance is the one ProfileLoader drives from THIS element's profile-config, which is + * the whole of the permanently-English fix landed the same day. Inheriting any of the three would be + * the regression. + * + * So this asserts BOTH halves: the child still ends up with its OWN value (the fix is not undone), and + * the inherited chain is left intact afterwards (the parent's OTHER values still resolve). + * + * Exercised against the REAL exported helpers, not a copy — a copied helper would pass while the + * shipped one rotted. Exit 0 pass · 1 fail. + */ +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const { provideOwn, withoutInheritedProvides } = + await import(resolve(ROOT, 'library/elements/provide-own.js')) + +const fails = [] +const ok = (what, cond, d = '') => { if (!cond) fails.push(`${what}${d ? `\n ${d}` : ''}`) } + +// A Vue-shaped app: provides inherits from the parent ELEMENT's provides, and app.provide() warns +// exactly as runtime-core does when the key is already reachable. +const makeApp = (parentProvides = {}) => { + const provides = Object.create(parentProvides) + const warnings = [] + return { + warnings, + _context: { provides }, + provide(key, value) { + if (key in provides) warnings.push(String(key)) + provides[key] = value + }, + } +} + +// ── provideOwn: an inherited key is shadowed WITHOUT a warning ─────────────────────────────────── +{ + const parent = { appI18n: 'PARENT-I18N', somethingElse: 'keep-me' } + const app = makeApp(parent) + provideOwn(app, 'appI18n', 'MY-I18N') + ok('the child ends up with its OWN i18n', app._context.provides.appI18n === 'MY-I18N') + ok('...as an OWN property, not the parent\'s', Object.hasOwn(app._context.provides, 'appI18n')) + ok('...without a warning, because the shadow is deliberate', app.warnings.length === 0, + `warned about: ${app.warnings.join(', ')}`) + ok('the parent\'s OTHER values still resolve', app._context.provides.somethingElse === 'keep-me') + ok('and the parent is NOT mutated', parent.appI18n === 'PARENT-I18N') +} +// ── provideOwn: an ABSENT key takes the ordinary public path ───────────────────────────────────── +{ + const app = makeApp({}) + provideOwn(app, 'profileModules', { a: 1 }) + ok('a key nobody provided is set through the public API', app._context.provides.profileModules.a === 1) + ok('...and still does not warn, because there was nothing to overwrite', app.warnings.length === 0) +} +// ── withoutInheritedProvides: for a key we cannot name (pinia's symbol) ────────────────────────── +{ + const SYM = Symbol('pinia') + const parent = { [SYM]: 'PARENT-PINIA', other: 'x' } + const app = makeApp(parent) + withoutInheritedProvides(app, () => app.provide(SYM, 'MY-PINIA')) + ok('the child ends up with its own pinia', app._context.provides[SYM] === 'MY-PINIA') + ok('...with no warning', app.warnings.length === 0, `warned about: ${app.warnings.map(String).join(', ')}`) + ok('★ the inherited chain is RESTORED — the parent\'s other values resolve again', + app._context.provides.other === 'x') + ok('the parent is not mutated', parent[SYM] === 'PARENT-PINIA') +} +// ── and it restores the chain even when the callback throws ────────────────────────────────────── +{ + const parent = { other: 'x' } + const app = makeApp(parent) + try { withoutInheritedProvides(app, () => { throw new Error('install blew up') }) } catch { /* expected */ } + ok('a throwing install still leaves the element able to see its parent', + app._context.provides.other === 'x') +} +// ── the control: the naive call MUST warn, or these assertions prove nothing ───────────────────── +{ + const app = makeApp({ appI18n: 'PARENT' }) + app.provide('appI18n', 'MINE') + ok('a plain app.provide() over an inherited key DOES warn (the behaviour being avoided)', + app.warnings.length === 1) +} + +if (fails.length) { + console.error('PROVIDE-SHADOWING: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log('PROVIDE-SHADOWING: PASS — a nested element keeps its own pinia, i18n and profileModules with no ' + + 'overwrite warning, the parent\'s other provides still resolve, and the chain is restored even if install throws.') diff --git a/library/elements/defineMapElement.js b/library/elements/defineMapElement.js index fc88c7c1a..26aefe2a2 100644 --- a/library/elements/defineMapElement.js +++ b/library/elements/defineMapElement.js @@ -35,6 +35,7 @@ import { defineCustomElement } from 'vue' import { createPinia } from 'pinia' import ProfileLoader from '../ProfileLoader.vue' import { createAppI18n } from '../i18n/index.js' +import { provideOwn, withoutInheritedProvides } from './provide-own.js' /** * @param {Object} o @@ -47,22 +48,48 @@ import { createAppI18n } from '../i18n/index.js' * it a second one, which is two stores and a silent state split. * @returns {CustomElementConstructor} * + * ★ AND EACH ELEMENT'S VALUES ARE ITS OWN, WHICH VUE WARNS ABOUT WHEN THEY NEST. A composite mounts + * child custom elements, and Vue's CE layer lets a child app inherit its parent element's provides. + * runtime-core's app.provide() then warns on every key that is already reachable — + * 'App already provides property with key "profileModules" inherited from its parent element. + * It will be overwritten with the new value.' + * — 8 of those per key, 24 in the Driver's console (2026-09-28), for three keys where overwriting is + * the POINT. `profileModules` is the plugin's own .vue glob and a child must not render its parent's + * components; pinia is per-instance so two embeds never bleed state; and the i18n instance is what + * ProfileLoader drives from THIS element's profile-config, which is the whole of the permanently- + * English fix. Inheriting any of the three would be the bug, not the warning. + * + * So the shadow is made DELIBERATE rather than silenced: provideOwn() writes straight to the app's own + * provides when the key is already reachable, and uses the public app.provide() when it is not. Vue's + * warning exists to catch an ACCIDENTAL overwrite; this says, at the one line that does it, that this + * one is meant. Nothing about the resolved values changes — a child gets exactly what it got before. + * * ★ CALL IT ONCE PER TAG. Vue's defineCustomElement returns a CLASS, and registering one class under * two tag names is undefined behaviour — doxa-simple-map ships two tags and calls this twice for that * reason. A helper that cached and returned one class would reintroduce the bug it was written around. */ +/** + * provide(), minus the dev warning, for a value that is MEANT to shadow a parent element's. + * Falls through to the public API whenever the key is absent, so the ordinary path is unchanged and + * only the intentional shadow touches internals. + */ export function defineMapElement({ profileModules = {}, configureApp = null } = {}) { return defineCustomElement(ProfileLoader, { configureApp(app) { // Per-instance, so two embeds on one page never bleed state into each other. const pinia = createPinia() - app.use(pinia) + // ★ pinia PROVIDES ITS OWN KEY DURING install(), and its symbol is not exported, so it cannot be + // shadowed by name the way the two below are. Hiding the INHERITED provides for the duration of + // the install makes the key genuinely absent, so app.use takes its ordinary path with no warning, + // and the chain is restored on the next line — every other inherited value still resolves. + // A per-element pinia is deliberate: two embeds on one page must never bleed store state. + withoutInheritedProvides(app, () => app.use(pinia)) const i18n = createAppI18n() app.use(i18n) // ★ BY REFERENCE, NOT JUST INSTALLED. See the header: without this the element is stuck at its // boot locale and says so to nobody. - app.provide('appI18n', i18n) - app.provide('profileModules', profileModules) + provideOwn(app, 'appI18n', i18n) + provideOwn(app, 'profileModules', profileModules) if (configureApp) configureApp(app, { pinia, i18n }) }, }) diff --git a/library/elements/provide-own.js b/library/elements/provide-own.js new file mode 100644 index 000000000..0a4e5bf5c --- /dev/null +++ b/library/elements/provide-own.js @@ -0,0 +1,40 @@ +/** + * provide-own.js — provide a value that is MEANT to shadow a parent element's, with NO framework import. + * + * Split from defineMapElement.js deliberately: that module imports ProfileLoader.vue, so it cannot be + * loaded by a node gate. The DECISION lives where it can be executed; the WIRING lives where it needs + * Vue. Same split as library/composables/host-token-url.js and library/packets/describeGroup.js. + * + * ★ WHY SHADOWING IS CORRECT HERE. Vue's CE layer lets a nested custom element inherit its parent + * element's provides, and app.provide() warns on any key already reachable — 24 of those in the + * Driver's console (2026-09-28), 8 each for profileModules, appI18n and pinia's symbol. All three must + * be the child's OWN: profileModules is the plugin's own .vue glob, pinia is per-instance so two embeds + * never bleed state, and the i18n instance is what ProfileLoader drives from THIS element's + * profile-config. Inheriting any of them would be the bug. The warning is Vue reporting an intentional + * act, so the act is made explicit rather than the warning silenced. + */ + +/** + * app.provide(), minus the overwrite warning, for a key we can name. + * Falls through to the public API when the key is absent, so the ordinary path is untouched and only + * the intentional shadow reaches into the app's own provides. + */ +export function provideOwn(app, key, value) { + const provides = app?._context?.provides + if (provides && key in provides) { provides[key] = value; return } + app.provide(key, value) +} + +/** + * Run `fn` with the app's INHERITED provides hidden, then restore them. + * For a value whose key cannot be named — pinia's symbol is module-private, so it can only be provided + * by calling app.use() — making the key genuinely absent is the only way to take the public path + * without the warning. Restored in a `finally`, so a throwing install cannot leave an element unable to + * see its parent. + */ +export function withoutInheritedProvides(app, fn) { + const provides = app?._context?.provides + const inherited = provides ? Object.getPrototypeOf(provides) : null + if (inherited) Object.setPrototypeOf(provides, null) + try { return fn() } finally { if (inherited) Object.setPrototypeOf(provides, inherited) } +} diff --git a/package.json b/package.json index 8b04857e0..407148442 100644 --- a/package.json +++ b/package.json @@ -224,6 +224,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:provide-shadowing": "node internal/check-provide-shadowing.mjs", "check:element-bootstrap": "node internal/check-element-bootstrap.mjs", "check:setup-order": "node internal/check-setup-order.mjs", "check:sfc-parse": "node internal/check-sfc-script-parses.mjs", From 51a3cadd6298ef5f2f79bf98ad84cf201e91bae2 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 17:42:31 +0000 Subject: [PATCH 3/3] Eleven entries called the helper without importing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My scripted migration inserted the import behind a guard that silently did not fire: `if 'defineMapElement' not in s.split('function')[0]` became false as soon as the inserted CALL appeared before the first `function` keyword — so the files that most needed the import were exactly the ones that did not get it. 11 of 16. Every one of those elements would have thrown "defineMapElement is not defined" on load. A bundler does not error on a free identifier; only loading does. studio caught it in L64 via landing-preconditions, which imports each profile, and fixed it there (37f75601) plus added an arm to check:element-bootstrap. This repairs the same 11 on this branch so it is not a landmine if it lands after L64, and it is byte-equivalent to studio's fix in effect. Verified by PARSING each entry and resolving the identifier against its import bindings — 16 of 16 bound — rather than by re-running the kind of regex that wrote the bug. --- plugin-designs/all-people-groups/index.js | 1 + plugin-designs/cities-internet/index.js | 1 + plugin-designs/country-posters/index.js | 1 + plugin-designs/doxa-research-map/index.js | 1 + plugin-designs/gospel-internet/index.js | 1 + plugin-designs/gospel-resources/index.js | 1 + plugin-designs/key-cities/index.js | 1 + plugin-designs/my-upg-100-list/index.js | 1 + plugin-designs/pplr-data-maps/index.js | 1 + plugin-designs/regions-dashboard/index.js | 1 + plugin-designs/upg-neighbor/index.js | 1 + 11 files changed, 11 insertions(+) diff --git a/plugin-designs/all-people-groups/index.js b/plugin-designs/all-people-groups/index.js index 7a6fb825b..a12be83ee 100644 --- a/plugin-designs/all-people-groups/index.js +++ b/plugin-designs/all-people-groups/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * plugin-designs/all-people-groups/index.js * diff --git a/plugin-designs/cities-internet/index.js b/plugin-designs/cities-internet/index.js index a1759ad26..883a005f5 100644 --- a/plugin-designs/cities-internet/index.js +++ b/plugin-designs/cities-internet/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * plugin-designs/cities-internet/index.js — bundle entry for CITIES WITH INTERNET. * diff --git a/plugin-designs/country-posters/index.js b/plugin-designs/country-posters/index.js index 2bff029d6..adb27950b 100644 --- a/plugin-designs/country-posters/index.js +++ b/plugin-designs/country-posters/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * plugin-designs/country-posters/index.js * diff --git a/plugin-designs/doxa-research-map/index.js b/plugin-designs/doxa-research-map/index.js index 641a90168..77196e6d1 100644 --- a/plugin-designs/doxa-research-map/index.js +++ b/plugin-designs/doxa-research-map/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * doxa-research-map / index.js — Bundle entry * diff --git a/plugin-designs/gospel-internet/index.js b/plugin-designs/gospel-internet/index.js index 283a44aa0..814fb8019 100644 --- a/plugin-designs/gospel-internet/index.js +++ b/plugin-designs/gospel-internet/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * plugin-designs/gospel-internet/index.js — bundle entry for the GOSPEL x INTERNET BOARD. * diff --git a/plugin-designs/gospel-resources/index.js b/plugin-designs/gospel-resources/index.js index b7b90212a..99c844321 100644 --- a/plugin-designs/gospel-resources/index.js +++ b/plugin-designs/gospel-resources/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * plugin-designs/gospel-resources/index.js — bundle entry for GOSPEL RESOURCES. * diff --git a/plugin-designs/key-cities/index.js b/plugin-designs/key-cities/index.js index 92cd41aa0..65726ddac 100644 --- a/plugin-designs/key-cities/index.js +++ b/plugin-designs/key-cities/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * plugin-designs/key-cities/index.js — bundle entry for KEY CITIES. * diff --git a/plugin-designs/my-upg-100-list/index.js b/plugin-designs/my-upg-100-list/index.js index d6c65153b..d372ce1de 100644 --- a/plugin-designs/my-upg-100-list/index.js +++ b/plugin-designs/my-upg-100-list/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * plugin-designs/my-upg-100-list/index.js * diff --git a/plugin-designs/pplr-data-maps/index.js b/plugin-designs/pplr-data-maps/index.js index 02dcc6a56..920f5d5c3 100644 --- a/plugin-designs/pplr-data-maps/index.js +++ b/plugin-designs/pplr-data-maps/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * pplr-data-maps / index.js — bundle entry (emits pplr-data-maps.js), defines . * diff --git a/plugin-designs/regions-dashboard/index.js b/plugin-designs/regions-dashboard/index.js index 12d7d9032..f4c68db8e 100644 --- a/plugin-designs/regions-dashboard/index.js +++ b/plugin-designs/regions-dashboard/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * plugin-designs/regions-dashboard/index.js * diff --git a/plugin-designs/upg-neighbor/index.js b/plugin-designs/upg-neighbor/index.js index b6321d4f1..ef2d5c8fd 100644 --- a/plugin-designs/upg-neighbor/index.js +++ b/plugin-designs/upg-neighbor/index.js @@ -1,3 +1,4 @@ +import { defineMapElement } from '@map/elements/defineMapElement.js' /** * upg-neighbor / index.js — bundle entry (emits upg-neighbor.js), defines . *