Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Returning a Proxy from a Custom Element constructor #857

Closed
trusktr opened this issue Nov 30, 2019 · 18 comments
Closed

Returning a Proxy from a Custom Element constructor #857

trusktr opened this issue Nov 30, 2019 · 18 comments

Comments

@trusktr
Copy link

trusktr commented Nov 30, 2019

Unfortunately, the following causes an error:

class A extends HTMLElement {
  constructor() {
    super()
    return new Proxy(this, {
      get(target, key) {
        console.log('get property:' key)
        return target[key]
      }
    })
  }
}

window.customElements.define('a-element', A)
<a-element></a-element>
Uncaught DOMException: custom element constructors must call super() first and must not return a different object

Can Custom Elements API be upgraded to support Proxied instances?

@domenic
Copy link
Collaborator

domenic commented Nov 30, 2019

No. Custom element constructors need to operate on the this provided, since that is what allows them to be an element in a sense the browser understands.

@domenic domenic closed this as completed Nov 30, 2019
@bathos
Copy link

bathos commented Nov 30, 2019

@trusktr I used to find this (both in HTML and in regular ES) really frustrating. It took me a long time — and getting into implementation work — to really understand the “why” part of this (and relatedly, why some people say the behavior of isArray is broken rather than helpful, which used to baffle me).

I recommend really digging in if you’re curious, both because it’s a super interesting subject and because one will learn why the denial of this request and similar ones isn’t a matter of people not wanting to help us, but rather that the concept we’re after is fundamentally at odds with the “physics” of JS, like asking for 2 + 2 to equal 7.

That said, you might be interested in the fact that you can define a prototype which is a Proxy exotic object that implements get like this, and you could associate that prototype with new instances in your constructor. You would want your special prototype to inherit from HTMLElement.prototype in turn. I’m not sure I would recommend such an API, but it may be able to provide what you’re after because get/set will delegate to the prototype when the properties aren’t own.

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

the “why” part of this

Would you mind summarizing some bullet points?


This totally works:

class MyEl extends HTMLElement {
  constructor() {
    super();
    Object.setPrototypeOf(
      this,
      new Proxy(Object.create(HTMLElement.prototype), {
        get(...args) {
          console.log("Muahaha.");
          return undefined;
        },
        set(...args) {
          console.log("Muahaha.");
          return true;
        }
      })
    );
  }
}

customElements.define("my-el", MyEl);
const el = new MyEl();
document.body.appendChild(el);

@bathos
Copy link

bathos commented Nov 30, 2019

The element object is a special object created by browser internals. It has various kinds of state associated with it, e.g. via internal slots. The proxy object doesn’t have these slots. They’re not like properties; there is no object internal method for e.g. ‘getSlotValue’. They’re more like values in a WeakMap which is keyed by the object’s identity (the same model as private fields). There are various reasons why they could not be made to ‘pass through’ (I could try to explain this further, but I’m not sure I’m the right person to explain it effectively, as I barely understand certain issues there). But in any case, from the browser’s perspective, a proxy exotic object is an entirely different object (and it is, literally). It’s not a platform object or an element.

In userland classes, we can associate private state with proxies freely with various mechanisms. But to do this, it has to be done after the fact — you need to actually have access to the new overridden object somewhere. The construction algorithm doesn’t operate in the right ‘direction’ to permit this: each derived constructor has an opportunity to perform a return override, but only more derived constructors will receive the new object as their this; the previous constructors and the base constructor have already completed at this point, and any state they established was associated with the object they had, not the new object created after that.

Element construction is somewhat unusual when performed by the browser when processing HTML or on upgrade (as you were talking about in the other thread). In these cases, the agent does have a chance to see what ‘came out’ of the user constructor. But this isn’t the only path — custom elements are also independently constructable, in which case the new object doesn’t get back to the agent during the process; also, because an unupgraded instance is potentially out there, you’d now have to do something to replace that object, and you’d have leaked the proxy target at best — things could get pretty weird.

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

also, because an unupgraded instance is potentially out there, you’d now have to do something to replace that object, and you’d have leaked the proxy target at best — things could get pretty weird.

I don't get that part. I always thought the unupgraded instance would be the same instance always used by user or engine, regardless if it is a target for a Proxy, or an item in an Array, or a key in a WeakMap.

Can't the engine be made to read the internal [[Target]] slot of the Proxy, and if that's also a Proxy, keep reading [[Target]] until it finds the underlying non-proxy object, and only error if that underlying object is not the same this that the engine has?

@bathos
Copy link

bathos commented Nov 30, 2019

I’m unsure I understand the first part of what you said there. The object which was in the DOM in this scenario was this — the value which got used as the target of the proxy. If that target were still the object users dealt with, your proxy would just be disappearing into the ether.

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

an element in a sense the browser understands

Can we change that part?

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

If that target were still the object users dealt with, your proxy would just be disappearing into the ether.

What I meant is, the underlying target of the Proxy my custom element users have is contains the same this that the engine has, and that instance isn't going anywhere.

Can't the engine be made to access the target, or just ignore the Proxy, in the cases where the Proxy target is the same this that the engine created?

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

It feels limiting that the one time I try to use Proxy for a significant purpose happens to be with Custom Elements, because that's what I mostly work with is web-based UIs, and I can't do what I want to do, because of outdated architecture designs.

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

This inconsistency across APIs in the browser is not a good thing.

@bathos
Copy link

bathos commented Nov 30, 2019

Proxies forwarding slots (or identity?) has a bunch of weird implications. The most obvious one is that you’d now have two objects floating around which both ‘are’ the same element yet are not the same object. But there are much more subtle issues which relate to the concepts of membranes and information side channels which I skipped over earlier because I don’t think I understand them well enough to actually explain them. However searching for some of those words together (membrane, proxy, etc) would likely turn up good info on the subject. It gets kinda heady though.

In any case you’d still have the target leak issue on account of the unupgraded element (the target) being potentially accessed and closed over before the upgrade took place, and the issue of replacing that target everywhere it gets exposed in the DOM with the new object, e.g. in NodeList instances.

I would say that this isn’t really an inconsistency, even though I feel what you’re saying and agree that it would be great if we had some mechanism to achieve this stuff. It precludes authoring any classes that inherit from platform interfaces which in turn implement stuff like Web IDL getter behaviors, which is limiting when it comes to implementing certain platform APIs/polyfills in the ES layer, a subject I’m super interested in. But there’s a huge amount of thought that went into this design and its limitations stem from the need to ensure certain truths hold (it’s actually closely related to some of the stuff in that other thread, about making sure the ES object model continues to permit ... I don’t know the right word for it, ‘zones of control’ is the best I can think of).

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

I expect a membrane, or whatever I implement with a Proxy, on an element to be for userland. If the engine needs to modify this internally and it will bypass my Proxy, I'm totally fine with that.

The use cases for Proxy (in my case) are to handle users' set/get on the instance. I already know that the HTML engine isn't going to arbitrarily set properties that I care about on my custom element instances. If the engine does anything with my instances, it will call life cycle hooks in response to user actions, which signal for me to manipulate my instance in response to the action. I don't see how the engine using the underlying [[Target]] would be an issue, unless that [[Target]] is not the same object that the engine created.

target leak issue on account of the unupgraded element (the target) being potentially accessed and closed over before the upgrade took place

What leak? The reference to the object created by the HTML engine (which happens to be the target in a proxy in userland code) doesn't change.

I'm imagining that the engine will upgrade the underlying this object that it originally created, not the Proxy, and the underlying [[Target]] reference (that the engine created) won't change. There's no leak. The engine can upgrade it just fine.

Regardless of Proxy, people need to at some point handle a future upgraded instance (whose reference will be the same) in either the custom element constructor or something else. Proxy would contain the same [[Target]] reference whose prototype is upgraded and whose state is potentially modified by the custom element constructor.

It won't affect the Proxy, because the Proxy traps will be called when the user of the Proxy performs an action like getting or setting a property on the Proxy, at which point the property or method will either exist, or it won't, depending on whether the underlying [[Target]] that the HTML engine references is upgraded yet or not.

We can close over non-upgraded instances today, as is, and manipulate them before upgrade, and the problem isn't any different with or without Proxies, the way I see it.

@bathos
Copy link

bathos commented Nov 30, 2019

We can close over non-upgraded instances today, as is, and manipulate them before upgrade, and the problem isn't any different with or without Proxies, the way I see it.

I’m unsure, but possibly I think there may be a misunderstanding here about the upgrade process? When accessed prior to upgrade, the instance isn’t a different object from what’s around after upgrade. I.e. there’s no before element vs after element — the this during construction is that same object. It’s as if the invocation of all derived constructors on top of HTMLElement has been deferred to the point of upgrade. So if return override were supported (for proxies or anything else), the implication is that the DOM would need to update to exchange the original element with the override object in various places, and yes, the target would have leaked.

image

I'm imagining that the engine will upgrade the underlying this object that it originally created, not the Proxy

Yes, it would have to; the proxy doesn’t exist at that point.

There's no leak. The engine can upgrade it just fine.

Leaking isn’t about the upgrade itself — it’s about the target object having been exposed globally in the DOM prior to the return override which would take place after upgrade.

Regardless of Proxy, people need to at some point handle a future upgraded instance (whose reference will be the same) in either the custom element constructor or something else.

I’m not sure what’s meant by this. The reference will be the same when/where?

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

the instance isn’t a different object from what’s around after upgrade. I.e. there’s no before element vs after element

That's what I was saying all along. So the same reference will be in any WeakMap, Array, Proxry target, jQuery cache, etc, and the upgrade process won't break that.

If Proxies were allowed in the engine, then the engine could be a special case where it can access [[Target]] of a proxy when you pass a Proxy to any DOM API. That would simply solve the problem, I think.

So if return override were supported (for proxies or anything else), the implication is that the DOM would need to update to exchange the original element with the override object in various places, and yes, the target would have leaked.

I'm proposing something a little different: the only allowed return override should be Proxies that wrap the object created by the HTML engine. The DOM would not exchange engine's element reference with any userland constructor return value.

Userland code would reference a Proxy, the internal engine would continue to reference the [[Target]] of any Proxy.

So the target won't leak, because the target is what the engine currently would have a reference to, if Proxies were allowed.

To sum it up:

  • Internally, the engine would never have a reference to a Proxy.
  • Externally, userland code can manipulate the element instance with a Proxy
  • When userland code passes a Proxy into any DOM API, the HTML engine would not save any reference to that Proxy, it would have special access to get the [[Target]] out of the Proxy (which is the native element instance) and use it as it would normally.

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

Hmm, but if what I described were the case (if Proxies were allowed, and DOM APIs operated only on the [[Target]]), then an issue I foresee would be that if someone else used querySelector to get an element, it will be the actual element, not a Proxy, and that will end in things not working the way the proxy-returning class designed.

@trusktr
Copy link
Author

trusktr commented Nov 30, 2019

If the Custom Element spec had disallowed upgrades, then we'd be in good shape: the engine could rely on Proxied instances (assuming [[Target]] matches the engine's internal object, otherwise error) because, for internal slots, the engine could read from the target object directly.

It'd be nice if a warning was thrown to console when elements already exist at the time of definition. Then developers would be forced to put element definitions up front. It would guarantee correctness (and prevent all existing upgrade issues).

Anyways, it's just a dream.

I'll have to find another way to implement my multiple inheritance tool with Custom Element classes. It'll have to be a Proxy-on-prototypes-only approach, but the in operator won't work on my instances because the has trap does not have a receiver parameter(See this ES Discuss thread about that problem).

@bathos
Copy link

bathos commented Nov 30, 2019

Yes, I think we’re on the same page now regarding what I was referring to as a leak.

What can be achieved with only an inherited proxy is limited, yeah; I wasn’t sure whether that recipe would do you any good or not. Proxies don’t afford anything that an ordinary object’s internal methods would not have — even a platform-defined exotic object couldn’t bypass that, it’s the signature of the real internal [[HasProperty]] — but then, they wouldn’t need to, since they could make the instance itself exotic instead.

Looking at your use of proxies in that lib, I’d note that you can achieve run-multiple-real-constructors-with-one-instance without using proxies if it’s okay to require those constructors to extend a special meta class. But I didn’t dig in too far, so I’m not sure what other stuff proxies may be facilitating for this lib.

override chaining (unsophisticated demo)
function synthesize(...constructors) {
  return class {
    constructor() {
      for (const constructor of constructors) {
        Reflect.construct(constructor, [ this ]);
      }

      return this;
    }
  };
}

function superOverride(instance) {
  return instance;
}

////////////////////////////////////


const ABC = synthesize(
  class A extends superOverride {
    a = 1;
  },
  class B extends superOverride {
    b = 2;
  },
  class C extends superOverride {
    c = 3;
  }
);

console.log(new ABC); // { a: 1, b: 2, c: 3 }

@trusktr
Copy link
Author

trusktr commented Oct 12, 2020

you can achieve run-multiple-real-constructors-with-one-instance without using proxies if it’s okay to require those constructors to extend a special meta class. But I didn’t dig in too far, so I’m not sure what other stuff proxies may be facilitating for this lib.

@bathos One thing that I want to facilitate with the lib is importing classes from anywhere and extending from them, without needing to modify their source code, f.e.

import {multiple} from "lowclass"
import {EventEmitter} from "events" // does not extend special meta class
import {Something} from "something" // does not extend special meta class

class Foo {...}

class Bar extends multiple(HTMLElement, Foo, EventEmitter, Something) {...}

With class-factory mixins, or with constructors having to extend a meta class, there would be no way to import an arbitrary set of classes from anywhere and extend from all of them because they aren't wrapped in functions, or they don't extend from the meta class (they may extend from some other class), respectively.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants