Skip to content

Instantly share code, notes, and snippets.

@alice
Last active January 22, 2020 03:34
Show Gist options
  • Save alice/174ae481dacdae9c934e3ecb2f752ccb to your computer and use it in GitHub Desktop.
Save alice/174ae481dacdae9c934e3ecb2f752ccb to your computer and use it in GitHub Desktop.

See whatwg/html#4925 for discussion.

Option 1: All element references are valid

  • If you can get a reference to an element, you can technically set it as a reference, and that reference will persist until it is un-set.
  • However, it may not have any effect, and may be an authoring error.
let el = document.body.appendChild(document.createElement('div'));
let descendant = document.createElement('div');
el.ariaActiveDescendantElement = descendant;  // this is valid
console.log(el.ariaActiveDescendantElement);  // logs descendant

el.parentNode.appendChild(el.ariaActiveDescendantElement);  // still valid
console.log(el.ariaActiveDescendantElement);  // logs descendant

descendant.remove();                   // still valid, descendant is not garbage collected
console.log(el.ariaActiveDescendantElement);  // logs descendant

let shadowRoot = el.attachShadow({mode: 'closed'});
shadowRoot.appendChild(descendant);  // still valid
console.log(el.ariaActiveDescendantElement);  // logs descendant

el.ariaActiveDescendantElement = null;
descendant.ariaActiveDescendantElement = el;  // works the other way around too
console.log(descendant.ariaActiveDescendantElement);  // logs el
Pros
  • Easy to understand (consistent with expando properties).
  • Easy to implement (with a caveat that there is some complexity added for checking validity when attr-associated elements may have effects).
  • Allows relationships to persist when removing and re-adding elements from/to the tree.
  • Allows relationships to be created within a sub-tree which is created before being added to the document/shadow tree.
Cons
  • May be construed as weakening Shadow DOM encapsulation. Specific concerns:
    • May cause problems if scripts "accidentally" walk into deeper shadow content via known properties e.g.
      // lightEl.ariaLabelledByElement was set by the component to be 
      // an element within the component's shadow DOM
      lightEl.ariaLabelledByElement.textContent = "new label";
      lightEl.ariaLabelledByElement.parent.appendChild(document.createTextNode("🆕"));
      • (Note that extension scripts often already have access to Shadow DOM in any case; the concern here would be page scripts.)
    • Provides a surface for developers to "hack" their way into depending on implementation details of components, if components expose elements within shadow DOM, e.g.
      // In this method, component sets `for` on lightDOMElement to an <input> inside Shadow DOM.
      // The author uses this to get access to elements inside Shadow DOM,
      // creating an implicit dependency on Shadow DOM internals.
      component.setLabel(lightDOMLabelElement);
      lightDOMLabelElement.for.style.backgroundColor = "papayawhip";
      lightDOMLabelElement.for = null;
    • May be cited as a precedent for other APIs which may also be construed as weakening Shadow DOM encapsulation.
  • May cause a "memory leak" (i.e. surprising retention of a DOM object in memory) if authors do not remove references to DOM elements which are removed from the tree.
  • Each API which refers to an attr-associated element needs to specify what the conditions are for an association to be valid (for example, an API may or may not require an element to be in the tree for the association to be valid, depending on the purpose of the association).
  • May allow specifying a cross-document association without using adoptNode() or importNode().
  • Cross-document references may cause memory leaks due to a Node effectively having two owner Documents (its actual owner Document, and the owner Document of the node which has the reference)

Option 1.1: Any element reference in the same Document is valid

Drop references on adoptNode() into a new Document

let el = document.body.appendChild(document.createElement('div'));
let descendant = document.createElement('div');
el.ariaActiveDescendantElement = descendant;
console.log(el.ariaActiveDescendantElement);          // logs descendant

var iframe = document.createElement('iframe');
var html = '<body></body>';
iframe.src = 'data:text/html;charset=utf-8,' + encodeURI(html);
document.body.appendChild(iframe);
iframe.contentDocument.adoptNode(descendant);         // Reference is dropped
console.log(el.ariaActiveDescendantElement);          // logs null

And/or: use WeakReferences

let el = document.body.appendChild(document.createElement('div'));
// not keeping a JS reference to the newly created element
el.ariaActiveDescendantElement = document.body.appendChild(document.createElement('div'));  
console.log(el.ariaActiveDescendantElement);  // logs newly created element from above

var iframe = document.createElement('iframe');
var html = '<body></body>';
iframe.src = 'data:text/html;charset=utf-8,' + encodeURI(html);
document.body.appendChild(iframe);
iframe.contentDocument.adoptNode(el.ariaActiveDescendantElement);
console.log(el.ariaActiveDescendantElement);  // logs null - no current reference to the ariaActiveDescendantElement
  • Dependent on WeakRefs proposal being implemented
  • Unclear how to handle array types
  • Would probably still need to drop references at adoptNode() time?

Option 2: Only elements in the same* scope are valid

  • If an element is in a tree, the attr-associated element must be in the same tree. If an element is removed from a tree, any relationship it is participating in is invalid and removed.
let el = document.body.appendChild(document.createElement('div'));
let descendant = document.createElement('div');
el.ariaActiveDescendant = descendant;  // this is not valid
console.log(el.ariaActiveDescendant);  // logs null

el.parentNode.appendChild(el.ariaActiveDescendant);
console.log(el.ariaActiveDescendant);  // logs null, the association was never created

el.ariaActiveDescendant = descendant;  // this is valid
console.log(el.ariaActiveDescendant);  // logs descendant

descendant.remove();                   // relationship is annulled,
                                       // descendant would be garbage collected if we hadn't kept a reference to it
console.log(el.ariaActiveDescendant);  // logs null

let shadowRoot = el.attachShadow({mode: 'closed'});
shadowRoot.appendChild(descendant);
el.ariaActiveDescendant = descendant;  // this is not valid
console.log(el.ariaActiveDescendant);  // logs null

el.ariaActiveDescendant = null;        
descendant.ariaActiveDescendant = el;  // not valid
console.log(descendant.ariaActiveDescendant);  // logs null
Pros
  • Stricter about Shadow DOM encapsulation than Option 1.
  • Does not require each API to determine validity.
  • Does not constitute a dangling reference to nodes which are removed from the tree.
  • Does not allow cross-document references.
  • Reasonably simple to understand.
Cons
  • Some implementation complexity to remove element relationships when elements are removed from the tree (for any element being removed, need to look up whether it is the subject of a relationship and remove it).
  • Possibly more difficult for developers to reason about:
    • "action at a distance" when removing an element from the DOM affects the properties of another element
    • may cause surprising behaviour when relationships do not persist across element remove/re-add.
  • Requires more bookkeeping from developers as relationships may not persist across mutations.
  • Does not allow authors to refer to elements across any shadow boundaries.

Option 2.1 Validate on insertion

  • A relationship between two elements may be created if one or both is not yet in the tree, but is validated and may be removed after both nodes have been inserted in the tree.
  • The validation check may occur on setting, and/or when elements are added to/removed from a tree.
  • Elements in different shadow scopes are still not valid.
let el = document.body.appendChild(document.createElement('div'));
let descendant = document.createElement('div');
el.ariaActiveDescendant = descendant;  // this is valid
console.log(el.ariaActiveDescendant);  // logs descendant

el.parentNode.appendChild(el.ariaActiveDescendant);  // relationship may be validated here...
console.log(el.ariaActiveDescendant);  // ...or here. 
                                       // Logs descendant either way, unless it was annulled in the earlier get.

descendant.remove();                   // no longer valid.
console.log(el.ariaActiveDescendant);  // logs null.
                                       // descendant would be garbage collected if we hadn't kept a reference to it.

let shadowRoot = el.attachShadow({mode: 'closed'});
shadowRoot.appendChild(descendant);
el.ariaActiveDescendant = descendant;  // this is not valid, validated on set
console.log(el.ariaActiveDescendant);  // logs null

el.ariaActiveDescendant = null;
descendant.ariaActiveDescendant = el;  // not valid, validated on set
console.log(descendant.ariaActiveDescendant);  // logs null
Pros
  • Allows developers to build up subtrees of nodes with inter-relationships before inserting the subtree into the tree (for example, when building a component).
Cons
  • Implementation/runtime complexity to continually validate elements being added to/removed from the DOM may be significant (would require a mechanism to determine whether a given element is the subject of an element relationship, and a lookup of this mechanism any time an element is added to or removed from the tree)
  • May still require author bookkeeping as relationships do not persist across removing and re-adding elements.
  • Does not allow authors to refer to elements across any shadow boundaries.

Option 2.2: Elements in lighter shadow trees are also valid

Elements in "lighter" shadow trees may be allowed to be set as associated elements, while excluding references to "darker" shadow trees.

// mostly the same as above, except:
el.ariaActiveDescendant = null;
descendant.ariaActiveDescendant = el;  // this is valid, validated on set
console.log(descendant.ariaActiveDescendant);  // logs el

// and as a reminder...
el.remove();                           // no longer valid
console.log(descendant.ariaActiveDescendant);  // logs null
Pros
  • As above, but also allows authors to create references across shadow boundaries in limited cases.
Cons
  • Largely as above, plus potentially more difficult to understand the cases in which relationships are and are not valid.

Option 3: Stick with IDREFs, potentially add syntactic sugar

  • For example, sprout a GUID if an element doesn't already have one.
Pros
  • Avoids dealing with the trade-offs of either of the above options.
Cons
  • GUIDs :(
  • Can't refer to lighter Shadow DOM without an extra mechanism (e.g. a space-separated token list indicating how many levels "up" to go before searching for the given ID)

Option 4: Accessibility-only relationships

This was the original idea in AOM: each element would have an associated accessibleNode which could be associated with any other accessibleNode regardless of Shadow DOM (and which does not allow retrieving the associated element).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment