Why doesn't defining the accessor descriptor of a property of computedStyle work?

Viewed 148

I wanted to detect value changes on the display CSS property by changes on the class attribute, and I came up with the below snippet.

I know getComputedStyle returns a read-only live CSSStyleDeclaration object, but as the object updates automatically when the element's styles are changed, I've assumed it assigns its properties somehow.

But it didn't call the getter and the setter. Why does this happen and then how does it assign its properties when it's read-only?

let parent = document.querySelector(".parent");
let child = parent.querySelector(".child");
let style = getComputedStyle(child);

let display = Symbol("display");
style[display] = style.display;

Object.defineProperty(style, "display", {
  get() {
    console.log("getter");
    return style[display];
  },
  set(value) {
    console.log("setter", value);
    style[display] = value;
  }
});

let button = document.querySelector("button");
button.addEventListener("click", () => {
  child.classList.toggle("hide");
});
.child {
  height: 100px;
  background-color: #80a0c0;
}

.hide {
  display: none;
}
<button>Toggle</button>
<div class="parent">
  <div class="child"></div>
</div>

2 Answers

Host-provided objects are not required to play nice. :-) (Well, there are some requirements even on host-provided objects.)

Although on Chrome and related the object claims that the display property is a simple data property (on Firefox and Legacy Edge, it's much more reasonable, an accessor property on the prototype):

let style = getComputedStyle(document.querySelector(".child"));
let kind = "own";
do {
    const descr = Object.getOwnPropertyDescriptor(style, "display");
    if (descr) {
        console.log(kind + " property:", descr);
        break;
    }
    if (kind === "own") {
        kind = "prototype";
    } else {
        kind += "'s prototype";
    }
    style = Object.getPrototypeOf(style);
} while (style);
.child {
  height: 100px;
  background-color: #80a0c0;
}

.hide {
  display: none;
}
<button>Toggle</button>
<div class="parent">
  <div class="child"></div>
</div>

...it's lying. :-) (On Chrome.) Your is successfully creating an accessor property on the object, and then the object stops reflecting the changes to the underlying display property of the element (since it's just updating the property you've named with a symbol):

let parent = document.querySelector(".parent");
let child = parent.querySelector(".child");
let style = getComputedStyle(child);

let display = Symbol("display");
style[display] = style.display;

Object.defineProperty(style, "display", {
  get() {
    console.log("getter");
    return style[display];
  },
  set(value) {
    console.log("setter", value);
    style[display] = value;
  }
});

let button = document.querySelector("button");
button.addEventListener("click", () => {
  child.classList.toggle("hide");
  console.log("style.display: " + style.display);
});
.child {
  height: 100px;
  background-color: #80a0c0;
}

.hide {
  display: none;
}
<button>Toggle</button>
<div class="parent">
  <div class="child"></div>
</div>

You can replicate that same behavior with a Proxy:

const realStyle = {
    display: "block"
};
const style = new Proxy(realStyle, {
    get(target, propName) {
        return target[propName];
    },
    set(target, propName, value, receiver) {
        if (propName in target) {
            return;
        }
        return Reflect.set(target, propName, value, receiver);
    }
});

console.log("descriptor for `display`:");
console.log(Object.getOwnPropertyDescriptor(style, "display"));

console.log("style.display = " + style.display);
console.log("Setting it to 'none'");
style.display = "none";
console.log("style.display = " + style.display);

const d = Symbol("display");
style[d] = style.display;
Object.defineProperty(style, "display", {
    get() {
        console.log("getter");
        return style[d];
    },
    set(value) {
        console.log("setter");
        style[d] = value;
    }
});

console.log("style.display = " + style.display);
console.log("Setting it to 'none'");
style.display = "none";
console.log("style.display = " + style.display);
.as-console-wrapper {
    max-height: 100% !important;
}

I might miss something, but I don't see why you do assume it has to assign the value.

It's just a getter, which will every time call the internal algorithm to check if the value has been changed, if it did it will trigger a recalc so that all the dirty CSS properties are now up to date, and finally return the current value.

Changing the CSS value in itself won't set the property of the object, it will only change the internal state, that will get fetched by the getter the next time.

A basic implementation of that could be

let internal_state = 'foo';

const computedStyle = {
  get display() { return internal_state; },
  set display( value ) { console.log( 'setting' ); } // never called
};

console.log( computedStyle.display ); // "foo"

// something triggers change, e.g toggle class
internal_state = 'bar';

console.log( computedStyle.display ); // bar""



Ps: regarding your original problem of detecting this change, if the one property change you want to detect is indeed on display, and between "block" and "none", then a ResizeObserver will be able to let you know about that.

Related