`extend()` overwrites `[own]` getters that `defineOwnProperty` is supposed to protect
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 1
- Forks
- 0
- PR merge metrics
- No merged PRs in 30d
Description
Summary
extend() overwrites property descriptors that defineOwnProperty is supposed to protect — namely, getters carrying the [own] flag. The two utilities encode the same invariant ("once a slot is owned, don't redefine it") inconsistently, which lets addPlugin silently destroy state belonging to an earlier-installed plugin.
This is the root cause of nudeui/element#136 (where static plugins = [...] silently fails because the api plugin's setup hook is lost when $hook is added after api). The nude-element side can dodge it by reordering, but the invariant violation lives here.
What's happening
defineOwnProperty marks the getter it installs with an [own] symbol and short-circuits if you try to define the same property twice:
That's the contract: an [own] getter is claimed by the framework; nobody else should redefine it.
extend() ignores that contract and unconditionally calls Object.defineProperty, overwriting whatever was there — including [own] getters:
How this misfires in practice — the chain through addPlugin:
- Plugin A's
addPluginrunsdefineOwnProperty(Class, hooks, () => new Hooks(this)). That installs an[own]getter capturing internal name_name1.Class[hooks]now lazily materializes aHooksinstance at_name1. A's hooks go in. - Plugin B has a
provides.constructorthat also has a[Symbol(hooks)]getter (defined via its owndefineOwnPropertyat module-load time, capturing a different_name2). addPlugin(B)callsextend(Class.prototype, B.provides, { deep: ["constructor"] }).extendcopies B's descriptor over A's —Object.defineProperty(Class, hooks, B_descriptor). The[own]guard never fires.- From now on
Class[hooks]is the_name2slot; A'sHooksis orphaned at_name1, still on the class but unreachable through the publichookssymbol. Anything A registered there is silently dead.
Minimal repro (xtensible alone, no nude-element)
import { addPlugin, symbols } from "xtensible";
import { defineOwnProperty } from "xtensible/util";
const slot = symbols.foo;
const A = {
setup (Class) {
defineOwnProperty(Class, slot, () => ({ source: "A" }));
Class[slot]; // materialize
},
};
const B = {
provides: {
constructor: {},
},
};
defineOwnProperty(B.provides.constructor, slot, () => ({ source: "B" }));
class Target {}
A.setup(Target);
console.log("after A:", Target[slot]); // { source: "A" } ✓
addPlugin(Target, B);
console.log("after B:", Target[slot]); // { source: "B" } ✗ — A's slot is gone
Expected: extend() respects the [own] claim and leaves Target[slot] as A's value (or warns, or errors). Actual: it silently overwrites.
Suggested fix
Have extend() honor the same invariant defineOwnProperty declares — skip a property whose existing getter is marked [own]:
--- a/src/util/objects.js
+++ b/src/util/objects.js
@@ -1,3 +1,5 @@
+import { own } from "./own.js"; // export `own` from own.js for this
export function extend (base, plugin, { deep, ignore = ["constructor", "prototype"] } = {}) {
@@ -25,6 +27,11 @@ export function extend (base, plugin, { deep, ignore = ["constructor", "prototype"] } = {}) {
else if (ignore?.has(property)) {
continue;
}
+ // A previously-installed `[own]` getter has claimed this slot — don't trample it.
+ let existing = Object.getOwnPropertyDescriptor(base, property);
+ if (existing?.get?.[own]) {
+ continue;
+ }
+
// TODO how to handle conflicts?
// TODO handle data properties separately?
Object.defineProperty(base, property, descriptors[property]);
own is currently file-local in src/util/own.js; this fix needs it exported. (Alternative: stash an identifying flag on the getter via a public-ish brand the way [own] is already used.)
Either way the principle is the same: [own] is an opt-in claim, set only by defineOwnProperty, so the blast radius of skipping is narrow — data properties, plain getters, methods are all unaffected. If someone deliberately wants to override an [own] getter via extend, that's almost certainly a bug.
A reorder in nude-element (nudeui/element#136) fixes the immediate symptom, but only by accident — any future plugin landing in the wrong order, or any new extend() over a slot previously claimed by defineOwnProperty, re-introduces this class of failure silently. Fixing it here makes the invariant load-bearing.
Happy to send a PR.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with src/util/objects.js and src/util/own.js, then trace the addPlugin path in src/plugins.js. Use the minimal reproduction from the issue to verify that an existing [own] getter survives plugin extension; done means the claimed slot remains intact while ordinary properties continue to be copied.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100