Shadow DOM support for widgets
krassowski opened this issue · 1 comments
Problem
Lumino can be used in situations where there are hundreds of thousands of nodes, and where stylesheets from different providers are attached at the same time. Limiting the stylesheet applicability to a specific subset of nodes is possible with two approaches:
Lumino currently does not support shadow DOM in the sense that individual widgets cannot be moved to shadow DOM and there are no methods exposed allowing to attach stylesheets to specific widgets/shadow DOM roots.
Proposed Solution
Note: If you have not worked with shadow DOM before please see the details below to understand why the solution (2) wraps the shadow root into another <div>
.
DOM nodes can be moved to the shadow DOM by attaching them to a transient shadow DOM root which is attached to another DOM node. There can ever be only one shadow DOM root in each DOM element:
This is allowed:
<div>
# shadow root
<div class="element-in-shadow-dom"></div>
<div class="another-element-in-shadow-dom"></div>
</div>
<div>
# shadow root
<div class="element-in-another-shadow-dom"></div>
</div>
This is forbidden (multiple shadow roots were removed from the specification in 2015):
<div>
# shadow root
<div class="element-in-shadow-dom"></div>
# shadow root
<div class="another-in-another-shadow-dom"></div>
</div>
The solutions I considered are:
- At widget attachment, check if node of the node of the parent widget hosts a shadow root and if it does, attaching to the shadow root instead of the parent node itself:
in this scenario, the parent widget is in the shadow DOM:
- this.parent!.node.insertBefore(widget.node, ref); + (this.parent!.node.shadowRoot || this.parent!.node).insertBefore(widget.node, ref);
<div class="lm-Widget"> <!-- this.parent.node --> # shadow root <!-- this.parent.node.shadowRoot --> <div class="lm-Widget"></div> <!-- widget.node --> <div class="lm-Widget"></div> <!-- anotherWidget.node --> </div>
- Separate
Widget.node
andWidget.attachmentNode
; by defaultWidget.attachmentNode
would be just an alias forWidget.node
but when a widget is instructed to wrap itself into shadow DOM, it would point to the ShadowRoot (but wrapped into a translucent div to avoid the issue of multiple-roots)in this scenario, the child widgets are in the shadow DOM:- this.parent!.node.insertBefore(widget.node, ref); + this.parent!.node.insertBefore(widget.attachmentNode, ref);
the problem with this approach is that it requires rewritting CSS styles since the<div class="lm-Widget"> <!-- this.parent.node --> <div class="lm-translucentWrapper"> <!-- widget.attachmentNode --> # shadow root <!-- widget.attachmentNode.shadowRoot --> <div class="lm-Widget"></div> <!-- widget.node --> </div> <div class="lm-translucentWrapper"> <!-- anotherWidget.attachmentNode --> # shadow root <!-- anotherWidget.attachmentNode.shadowRoot --> <div class="lm-Widget"></div> <!-- anotherWidget.node --> </div> </div>
widget.node
is no longer a direct descendant of its parent (lm-translucentWrapper
is). - A variation of (2):
<div class="lm-Widget"> <!-- this.parent.node --> <div class="lm-Widget"> <!-- widget.node --> # shadow root <!-- widget.node.shadowRoot --> <div class="lm-contentWrapper"></div> </div> <div class="lm-Widget"> <!-- anotherWidget.node --> # shadow root <!-- anotherWidget.node.shadowRoot --> <div class="lm-contentWrapper"></div> </div> </div>
- Using
Proxy
to create Frankenstein-kind hybrid ofShadowRoot
andHTMLElement
and keepingnode
without changes: this does not work because native code ininsertBefore
and friends does not accept non-native nodes (strict class checks are performed at runtime so even if the proxy quacks like a perfect node, it will be rejected).
Then we could either:
- a) allow each widget to be moved into shadow DOM via a new option in constructor, or
- b) provide a subclass of Widget say
ShadowWidget
which would put its node into shadow DOM.
Further we would need to be consider how to handle CSS stylesheets. Widgets could have a method like:
class Widget {
/**
* Adopt a constructed CSS stylesheet for the use in shadow DOM.
* Is a no-op if the widget is not a shadow DOM root.
* @returns whether the stylesheet was successfully adopted.
*/
adoptStyleSheet(stylesheet: CSSStyleSheet): boolean {
if (!this.options.shadowDOM) { return false; } // not needed in (b)
this.node.shadowRoot.adoptedStyleSheets.push(stylesheet);
return true;
}
}
I am slightly leaning towards 2a as that would allow us to enable shadow DOM downstream by changing the options without a need to duplicate class definitions. The separation of node
and attachmentNode
could be introduced in Lumino 2.0 whether we decide to proceed with the shadow DOM implementation or not.
Additional context
I was able to set it up in JupyterLab and created a code snipped to copy all stylesheets for adoption in widgets, thus limiting the performance benefits to containing style changes to nodes within the shadow DOM but still using all styles; this is super preliminary and I will update if I get a chance to perform a proper benchmark:
- I saw very little performance benefits when moving
MainAreaWidget
to shadow DOM - I saw more performance benefits when moving the entire
DockLayout
to shadow DOM
My confidence in the results above is low. I expect that gains will differ depending on the browser.
One place where shadow DOM wrappers could be very useful (performance aside) are CellOutput widgets in JupyterLab; this would allow outputs to add any CSS stylesheet they want without breaking the JupyterLab styling (on the other hand it would prevent them from modifying the theme which is probably a good thing security-wise).