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

Observable Properties #951

Open
jeff-hykin opened this issue Jan 9, 2022 · 8 comments
Open

Observable Properties #951

jeff-hykin opened this issue Jan 9, 2022 · 8 comments

Comments

@jeff-hykin
Copy link

jeff-hykin commented Jan 9, 2022

TDLR: I don't think there is a way to watch all properties


Workaround #1

  • Use attributes instead of properties
  • Use mutation observer to watch all attributes

Downsides

  • extremely slow
  • dom updates often are unnecessary

Workaround #2

  • Use a proxy
  • Trick the browser into thinking the proxy object is a Node

Downsides

  • doesn't work

Example:

const proxySymbol = Symbol.for('Proxy')
const thisProxySymbol = Symbol('customObject')


const element = new CustomComponent({onConnect, onDisconnect, onAdopted, children})

const elementProxy = new Proxy(element, {
    defineProperty: Reflect.defineProperty,
    getPrototypeOf: Reflect.getPrototypeOf,
    ownKeys(original) { return Object.keys(original) },
    get(original, key) {
        console.debug(`getting key:`,key)
        if (key == proxySymbol||key == thisProxySymbol) {return true}
        if (key == elementSymbol) {return original}
        return original[key]
    },
    set(original, key, value) {
        if (key == proxySymbol||key == thisProxySymbol) {return}
        return original[key] = value
    },
})

//
// attempt
//
elementProxy instanceof Node;       // >>> true 
document.body.append(elementProxy)  // >>> caught TypeError: Failed to execute 'appendChild' on 'Node': parameter 1 is not of type 'Node'

Workaround #3

  • Create a two-way link between the proxy and the element using symbols
  • Monkey patch all these (and more) on Node, Element, HTMLElement, Document, etc
    • children
    • append
    • getElementById
    • prepend
    • querySelector
    • querySelectorAll
    • replaceChildren
    • hasChildNodes
    • insertBefore
    • removeChild
    • replaceChild
    • childNodes
    • firstChild
    • lastChild
    • appendChild
    • contains
    • activeElement
    • pointerLockElement
    • fullscreenElement
    • elementFromPoint
    • elementsFromPoint
    • getSelection
    • firstElementChild
    • lastElementChild
    • getRootNode
    • isEqualNode
    • isSameNode

Downsides

  • (other than the extreme monkey patching itself)
  • this would still fail for things like the debugging var $0 that many browsers have when inspecting an element. It would return the non-proxy-wrapped element
@bathos
Copy link

bathos commented Jan 9, 2022

Re option 2, Proxy exotic objects on their own cannot exhibit the characteristics you’re looking for (i.e., by design — this is very fundamental for a variety of reasons, including proxy’s own core use cases). Membranes can, though that’s unlikely to be worthwhile here. The “monkey patching” in “option 3” is effectively equiv to part of what a membrane entails.

(Side note: @@hasInstance is implemented by the RHS operand, not the left. If providing a custom impl in a class body, it’d be a static method.)

If you want to observe specific/knowable properties in a custom element it is pretty straightforward to use some “meta” helper to declare/define/install them and their change effects (as various libraries like lit do) to avoid boilerplate.

If you really want totally dynamic access, it is possible to achieve that with a prototype that’s itself a proxy, sorta, but it’s pretty chaotic and may defy expectations in various ways, e.g. instance[[HasProperty]](k) will have to either always return true or always return false for the virtualized stuff. Your prototype would be a Proxy that implements what are effectively virtual accessors via receiver-sensitive [[Get]] and [[Set]]. I highly recommend not doing this tho unless it’s just for fun :) Note that you would need to use function rather than class syntax so that fn.prototype is writable.

@jeff-hykin
Copy link
Author

jeff-hykin commented Jan 9, 2022

Thanks!

with a prototype that’s itself a proxy, sorta, but it’s pretty chaotic

I had not thought of that, I'll be keeping that in mind for other projects too. I'm always down to experiment with something chaotic.

I'll see if I can get it to be reliable. If I can intercept the inherited Node properties on HTMLElement I'll consider that a pretty good start. Otherwise I'll probably finish working on #3

@bathos
Copy link

bathos commented Jan 9, 2022

I shoulda also mentioned regarding @@hasInstance that because the default behavior (reflected at Function.prototype[Symbol.hasInstance]) is a (recursive) prototype check rather than a brand check, stuff like new Proxy(document.createElement("a"), {}) instanceof Node evals to true by default.

@jeff-hykin
Copy link
Author

@bathos thank you, I was actually experimenting with that based on your first comment and realized it did nothing.

I've been carrying that approach around for a while not realizing it was a false positive from whenever I last was trying to override instance of.

@justinfagnani
Copy link
Contributor

Neither JavaScript nor the DOM have a way to observe properties generally from outside the objects. This is what Object.observe() added for a subset of objects. What's the use case?

@jeff-hykin
Copy link
Author

jeff-hykin commented Jul 20, 2022

Neither JavaScript nor the DOM have a way to observe properties generally from outside the objects.

I'm not sure exactly what is meant by outside the object.

My only goal is getting proxy-like control over a custom component. We can assume I have full control over the definition of the custom component. Object.observe() would solve my issues, except that it's deprecated. As for usecase, it's the same as the usecase for proxy objects in general (I use them for a bunch of behaviors)

@bathos
Copy link

bathos commented Jul 20, 2022

The platform does include such APIs. In Web IDL parlance they’re called indexed properties (like Array index keys) and named properties (any other string key) and are defined in terms of the getter, setter, and deleter special operations.

These are generally considered a legacy design pattern which isn’t used for new interfaces. It tends to lead to surprise collisions with “normal” members and even though the algorithms are well-defined, no two agents actually implement them the same (try messing with localStorage.getItem and a localStorage entry named getItem in various ways using delete, assignment, etc in a few browsers to get a feel for just how chaotic the interop issues still are). There are two built-in elements that implement them, but not with setters:

Custom elements cannot recreate those APIs (without “deep” virtualization anyway) because the instance returned from the constructor cannot be a new object. I don’t think there would be vendor interest in enabling this given the sentiment that they probably shouldn’t have existed to begin with and because of their high complexity.

@trusktr
Copy link
Contributor

trusktr commented Jul 29, 2022

@jeff-hykin Yeah, Object.observe() would have been the solution, and not just for DOM, but for all of JS.

The only alternative is to patch the objects by replacing their properties with accessors. Here's an example:

https://github.com/trusktr/james-bond/blob/43659f83ddbdc0cac0c98f05035c800e5efc205c/src/observe.ts#L12

And here's what the usage looks like in the unit tests:

https://github.com/trusktr/james-bond/blob/master/src/observe.test.ts

		const el = document.querySelector('.some-element')

		observe(el, ['someProperty', 'otherProperty'], (prop, value) => {
			console.log('prop changed:', prop, value)
		})

The only real downsides are:

  • make sure the patching is done properly, it should not ruin any features of the object. For example, if the object already inherits and uses accessors, then you need to make sure to call those inherited accessors.
    • james-bond/dist/observe.js handles this pretty well
  • this changes the descriptors of the object, which in rare cases could change how the program of the author of the object works. This is rare.
  • unlike Proxy membranes, this does not handle arbitrary properties added later, you must specify which properties to observe ahead of time

I use this in practice to make my element behaviors observe their host element's properties.

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

4 participants