Object.unfreeze, or similar API

# /#!/JoePea (10 months ago)

​ I think the ability to unfreeze an object can be useful.

For example, suppose we have a library that emits events:

this.emit('some:event', this.obj)

Maybe we want to try to prevent external listeners from modifying the event payload without having to clone this.obj, otherwise cloning can cause "jank" from GC during animations that should be smooth.

So, what if there was a way to unfreeze an object in the scope in which the object was frozen?

Object.freeze(obj) // instead of cloning
this.emit('some:event', this.obj) // recommend all listeners to be
synchronous, and not modify the payload later
Object.unfreeze(this.obj) // in the same scope

or maybe it return an unfreeze function:

const unfreeze = Object.freeze(obj) // instead of cloning
this.emit('some:event', this.obj) // recommend all listeners to be
synchronous, and not modify the payload later
unfreeze(this.obj) // only code that is given the unfreeze function can
call it, similar to resolve/reject functions with Promises

or maybe it is more similar to setTimeout and setInterval:

const frozenKey = Object.freeze(obj) // instead of cloning
this.emit('some:event', this.obj) // recommend all listeners to be
synchronous, and not modify the payload later
Object.unfreeze(frozenKey) // only code that is given the key can unfreeze
the object associated with the key

It seems like there can be opportunity for this to be optimized, so that it can be faster than cloning. For example, this uses more memory and causes GC:

this.emit('some:event', { ...this.obj }) // creates a new object

Could the performance benefit make it worth it to have a way to unfreeze objects?

Another use case is unlocking things only when used in certain places, as a "guard". In the following example, the only way to set the X rotation value is with the setter, which enforces that certain logic will fire, rather than someone from the outside modifying the readable state not using the setter:


import Privates from 'somewhere'

// a private cache, using WeakMap internally
const _ = new Privates

class ThingThatRotates {
    constructor() {
        this.rotation = { x: 0, y: 0, z: 0 }
        _(this).frozen = Object.freeze(this.rotation)
        // ...
    }

    get rotateX() { return this.rotation.x }
    set rotateX(val) {
        this.doSomethingImportantWithTheValue(val)

        // unlock here, so that the value can only be set with this setter.
        Object.unfreeze(_(this).frozen)
        this.rotation.x = val
        _(this).frozen = Object.freeze(this.rotation)

        this.emitAnEventWithTheValue(val)
        this.etc(val)
    }

    // ...
}

I know, I can probably just keep rotation in the private cache, but then it isn't readable. Maybe I want that to be readable as a convenient shortcut. F.e.

// outside code
const r = new ThingThatRotates

// X rotation might be animated by some other tool

const {x, y, z} = r.rotation // for convenience, no cloning involved.

This is a completely contrived example I just made up right now. I can probably think of more. It seems like it could be good for performance in cases where we don't want modification from the outside...

/#!/JoePea

# Michał Wadas (10 months ago)

You can just do:

const proxy = new Proxy(obj, { set() { throw new Error(); }, defineProperty() { throw new Error();}, deleteProperty() { throw new Error(); } }) this.emit('some:event', proxy)

Though, it seems like an exotic use case.

# /#!/JoePea (10 months ago)

That's actually very useful, thanks! I have plenty of cases where I wished I could pass an object without cloning it to ensure it isn't mutated from outside. And instead of creating the Proxy right when I will emit an event (for example), I could just store the proxy somewhere so it is long-lived and to avoid GC.

# Oriol _ (10 months ago)

So, what if there was a way to unfreeze an object in the scope in which the object was frozen?

I don't think the behavior of object operations should depend on the scope in which they are used.

And I could understand undoing [[PreventExtensions]], just switch [[Extensible]] back to true (for ordinary objects). But unfreezing makes no sense. How is ES supposed to know which properties became non-configurable because of Object.freeze and which ones were manually defined as non-configurable? Do you want all non-configurable properties to become configurable when "unfreezing"? I think that would be bad.

// recommend all listeners to be synchronous, and not modify the payload later

You can recommend, but what if they are not synchronous? They will have a reference to the unfrozen object! If you trust them, you can avoid freezing in the first place. If you can't trust them, unfreezing is a big problem!

Frankly I don't see the point, if you don't want to clone just use a proxy that only allows restricted access.

let error = () => { throw new Error(); };

let allowed = ["get", "ownKeys", "has", "getOwnPropertyDescriptor"];
let handler = new Proxy(Object.create(null), {
  get(_, trap, receiver) {
    if (!allowed.includes(trap)) return error;
  }
});
this.emit('some:event', new Proxy(obj, handler));
# Raul-Sebastian Mihăilă (10 months ago)

Note that if you want to use the Proxy that way you will probably want to implement the setPrototypeOf and preventExtensions traps as well since, together with set, defineProperty and deleteProperty, they are the traps whose associated internal methods can have side effects on the target when the traps are not implemented.

# Jordan Harband (10 months ago)

The entire purpose of Object.freeze is that the frozen object can never be altered again (in the ways that freeze restricts, at least). Allowing an object to be unfrozen would violate that very critical security property.

The same is true with seal and preventExtensions. Once locked down, an object must not be unlockable.

# /#!/JoePea (9 months ago)

Hello Oriol, why did you make two Proxies there? Does it serve some purpose not achieved with only one Proxy?

# Oriol _ (9 months ago)

I used a 2nd proxy as the handler in order to only allow the desired traps.

Sure, I could have defined all blacklisted traps in an ordinary object, and make them throw an error.

But this wouldn't be future-proof in case new traps are eventually added. Whitelisting is safer.

# /#!/JoePea (9 months ago)

I see, so you're relying on the engine reading the handler object during the moment that the user of my obj will try to read obj, so if the engine tries to read something in the handler (that we haven't whitelisted) due to what the user is doing with obj then we throw the error. If the engine were to read these all in advance, this would cause a problem. Are we sure that all engines don't read the handler spec in advance?

# Oriol _ (9 months ago)

If the engine were to read these all in advance, this would cause a problem.

No, my handler always provides the same function for a given trap name, so even if the traps were cached when the proxy is constructed, the code would still work.

Are we sure that all engines don't read the handler spec in advance?

Yes, the spec does not allow this. But there was a proposal about doing that in some cases, in order to improve performance.