Tuesday, December 10, 2013

Unvexing the Shadow Root


I find my myself slightly vexed. I suppose this is better than being very vexed or in a state of high vexation, but still, I don't care to be even slightly vexed. It irritates the bowels which only increases vexation because there is so much yummy holiday food about. So, you see, a slight vexation simply won't do.

What vexes me today is communication. I still do not believe that I have communication down in Polymer. I continue to struggle with a series of Polymers arranged as follows:
    <store-changes>
      <store-changes-load></store-changes-load>
      <div contenteditable>
        <!-- actual changes occur here -->
      </div>
    </store-changes>
The crux of the communication is the <store-changes-load> element. It is responsible for communicating the initial load value of the parent element, <store-changes> down to the content-editable <div>. The communication down to the <div> is in OK shape. I have to walk the shadow DOM to find where the <div> is projected, but it works. The communication from the parent <store-changes> turns out to be trickier—at least in Dart.

The problem is that <store-changes-load> is in the shadow DOM of <store-changes>. The whole point (well one of the points) of the shadow DOM is to provide strict element encapsulation. Everything from the shadow root down has its own document fragment with no way back out to the “real” element. That's not 100% accurate—it is possible to access the ownerDocument property of a shadow root to get back to the real document, but that still leaves the bother of tracking down the real element in that DOM. Hassle aside, the DOM encapsulation created by the shadow root is a good thing, which should never be broken.

To maintain the integrity of the shadow DOM, I cannot have <store-changes-load> access properties on <store-changes>. The most I can do is interact with the root of <store-changes>'s shadow DOM. Since that has no actual information, I instead resorted to having <store-changes> walk down its own shadow DOM to fire events on <store-changes-load> elements:
@CustomTag('store-changes')
class StoreChangesElement extends PolymerElement {
  // ...
  void _fetchCurrent() {
    stores.forEach((store) {
      store.fetch().then((_r) {
        record = _r;
        shadowRoot.queryAll('store-changes-load').
          forEach((el) {
            fire('store-changes-load', detail: record, toNode: el);
          });
      });
    });
  }
  // ...
}
I am somewhat OK with this apparent coupling of <store-changes> and <store-changes-load>. The names of the elements already suggest that there is coupling. In fact, the <store-changes-load> element is even hard-coded into the template of the <store-changes> element. So there is very explicit coupling taking place. And yet, this still bothers me. It slightly vexes.

What keeps bugging me is that I have to fire my events on the <store-changes-load> element (via toNode). So even though I am rife with coupling everywhere, it is this piling on more coupling that gets to me. The whole point of events is to separate the firer from the listener and here I am doing the opposite: targeting a very specific element.

But what else can I do? The <store-changes-element> cannot gain access to the “real” <store-change>, just its shadow DOM. If I had access to the real element, I could do something as simple as reading properties. But all I can do is access the shadow root, which has precious few properties and none of them custom.

It turns out that the shadow root has two important things going for it. First, the <store-changes> Polymer code has access to it. Second, it has dispatchEvent() and addEventListener() methods. In other words, I can have my <store-changes> Polymer fire the store-changes-load event on its own shadow root, for which the <store-changes-load> Polymer can listen.

So I replace that shadow DOM walking with a dispatchEvent() on the shadow root:
@CustomTag('store-changes')
class StoreChangesElement extends PolymerElement {
  // ...
  void _fetchCurrent() {
    stores.forEach((store) {
      store.fetch().then((_r) {
        record = _r;
        shadowRoot.
          dispatchEvent(new CustomEvent('store-changes-load', detail: record));
        // shadowRoot.queryAll('store-changes-load').
        //   forEach((el) {
        //     fire('store-changes-load', detail: record, toNode: el);
        //   });

      });
    });
  }
  // ...
}
It is a slight bummer that Dart's ShadowRoot class does not have a fire() method of its own. But, as that would only be a very thin wrapper around dispatching a custom event, I am not too fussed. I am not even slightly vexed.

What this means is that the parent Polymer no longer needs to care about the internal structure of its elements. It need only know that it has to communicate a custom event down and do so at the topmost point possible. With that, the elements that care about this event now have to walk up their respective shadow DOMs to find the necessary shadow root for listening. In my case here, I only need to listen to the parent node:
@CustomTag('store-changes-load')
class StoreChangesLoadElement extends PolymerElement {
  // ...
  StoreChangesLoadElement.created(): super.created();
  ready() {
    super.ready();
    parentNode.addEventListener('store-changes-load', (e){
      var current = e.detail['current'];
      editable.innerHtml = current;
    });
  }
  // ...
}
With that, I have my Polymers again communicating, this time without breaking encapsulation.

This might seem a minute distinction, but I think it could be valuable. It is easier for a child element to walk up to a node that for the parent to walk through all descendants to find the child. In other words, this approach should make for some cleaner code. I also appreciate one less point of coupling. For other Polymers that have no natural coupling point, this could make all the difference in the world between spaghetti callbacks and clean, non-vexing code.


Day #961

2 comments:

  1. Hi Chris,

    I've been vexed a time or two myself :) The trick is to to think of elements in terms of their published (observable) properties and events and to use binding to compose child elements together from their parent declaratively in it's template. In your example, the store-changes element could bind to the store-changes-load element's 'store-changes-load' event in it's template and then set the div's innerHtml in an event handler. You could also create a content-editable element with a published 'value' property, add a published 'value' property to the store-changes-load element and bind the two together in the store-changes template.

    ReplyDelete
    Replies
    1. Ahh.. much thanks again!

      I realized this approach was not going to work when I tried it in JS (events don't seem to work on shadowRoot from the child's perspective). I realized it wouldn't work, but had no idea how to fix until your tip.

      Anyhow, I think what I need is to bind to a store-changes-load property in the store-changes template. The store-changes-load Polymer can then observe for changes in that property and do its thing when such a change occurs. I really appreciate the pointer -- it would have taken me a while to figure out that I needed to go in that direction. Thanks!

      Delete