diff --git a/internal/check-element-bootstrap.mjs b/internal/check-element-bootstrap.mjs index f6ae2c47a..0880c25f4 100644 --- a/internal/check-element-bootstrap.mjs +++ b/internal/check-element-bootstrap.mjs @@ -83,7 +83,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` @@ -94,7 +98,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 98c82c19a..af32676e7 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,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:map-menu-anchor": "node internal/check-map-menu-anchor.mjs", "check:mercator-slice": "node internal/check-mercator-slice.mjs",