LeaVerou / LeaVerou/xtensible

`extend()` overwrites `[own]` getters that `defineOwnProperty` is supposed to protect

Open
#4 1 comment 0 reactions 0 assignees View on GitHub

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:

https://github.com/LeaVerou/xtensible/blob/ee82f3b42a2bb6e5170d2c60840ecfd808d2eafb/src/util/own.js#L17-L22

https://github.com/LeaVerou/xtensible/blob/ee82f3b42a2bb6e5170d2c60840ecfd808d2eafb/src/util/own.js#L65-L69

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:

https://github.com/LeaVerou/xtensible/blob/ee82f3b42a2bb6e5170d2c60840ecfd808d2eafb/src/util/objects.js#L28-L31

How this misfires in practice — the chain through addPlugin:

https://github.com/LeaVerou/xtensible/blob/ee82f3b42a2bb6e5170d2c60840ecfd808d2eafb/src/plugins.js#L76-L86

  1. Plugin A's addPlugin runs defineOwnProperty(Class, hooks, () => new Hooks(this)). That installs an [own] getter capturing internal name _name1. Class[hooks] now lazily materializes a Hooks instance at _name1. A's hooks go in.
  2. Plugin B has a provides.constructor that also has a [Symbol(hooks)] getter (defined via its own defineOwnProperty at module-load time, capturing a different _name2).
  3. addPlugin(B) calls extend(Class.prototype, B.provides, { deep: ["constructor"] }). extend copies B's descriptor over A's — Object.defineProperty(Class, hooks, B_descriptor). The [own] guard never fires.
  4. From now on Class[hooks] is the _name2 slot; A's Hooks is orphaned at _name1, still on the class but unreachable through the public hooks symbol. 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.