From 01a1a7f2addf8a5db4b352eb0aac2050f8b06c70 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 17:41:08 +0000 Subject: [PATCH] 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 The console showed 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 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",