whatwg / html

HTML Standard
https://html.spec.whatwg.org/multipage/
Other
8.12k stars 2.67k forks source link

<mirror> element, like <slot>, but not limited to ShadowDOM, elements from anywhere can be assigned to it #6507

Open trusktr opened 3 years ago

trusktr commented 3 years ago

Having the ability to render an element relative to other places in the DOM would alleviate overhead from situations where an element should be rendered more than once in different places of a web app (f.e. implementing CSS-based VR, and would provide the machinery for new patterns like what virtual component systems currently do with "portals".

The <mirror> element would be similar to ShadowDOM <slot> elements, except that

There may be both an imperative way to assign nodes to a <mirror>, and a declarative way.

A declarative example:

<style>
  h1 span { color: deeppink }
  h2 span { color: yellow }
  h3 span { color: cyan }
</style>
<h1>My name is <mirror name="nametag1"></mirror></h1>
<h2>I am called <mirror name="nametag2"></mirror></h2>
<h3>People call me <span mirror="nametag1 nametag2">Joe</span><h3>

would render as if we had written

<style>
  h1 span { color: deeppink }
  h2 span { color: yellow }
  h3 span { color: cyan }
</style>
<h1>My name is <span>Joe</span></h1>
<h2>I am called <span>Joe</span></h2>
<h3>People call me <span>Joe</span><h3>

Affecting the style of the mirrored <span> element causes the effect to be mirrored to all locations. For example,

<style>
  h1 span { color: deeppink }
  h2 span { color: yellow }
  h3 span { color: cyan }
  .underline { text-decoration: underline }
</style>
<h1>My name is <mirror name="nametag1"></mirror></h1>
<h2>I am called <mirror name="nametag2"></mirror></h2>
<h3>People call me <span mirror="nametag1 nametag2">Joe</span><h3>
<script>
  document.querySelector('span').classList.add('underline')
</script>

would render as if we had written

<style>
  h1 span { color: deeppink }
  h2 span { color: yellow }
  h3 span { color: cyan }
  .underline { text-decoration: underline }
</style>
<h1>My name is <span>Joe</span></h1>
<h2>I am called <span>Joe</span></h2>
<h3>People call me <span>Joe</span><h3>
<script>
  document.querySelectorAll('span').forEach(s => s.classList.add('underline'))
</script>

An imperative example:

<style>
  h1 span { color: deeppink }
  h2 span { color: yellow }
  h3 span { color: cyan }
  .underline { text-decoration: underline }
</style>
<h1>My name is <mirror id="mirror1"></mirror></h1>
<h2>I am called <mirror id="mirror2"></mirror></h2>
<h3>People call me <span>Joe</span><h3>
<script>
  const span = document.querySelector('span')
  span.classList.add('underline')
  document.getElementById('mirror1').assign(span)
  document.getElementById('mirror2').assign(span)
</script>

would render as if we had written

<style>
  h1 span { color: deeppink }
  h2 span { color: yellow }
  h3 span { color: cyan }
  .underline { text-decoration: underline }
</style>
<h1>My name is <span>Joe</span></h1>
<h2>I am called <span>Joe</span></h2>
<h3>People call me <span>Joe</span><h3>
<script>
  document.querySelectorAll('span').forEach(s => s.classList.add('underline'))
</script>

The assign() method name is borrowed from https://github.com/whatwg/html/issues/3534.

Use Case 1: CSS-based VR (f.e. phones inside binoculars)

The content to be displayed in 3D only needs to have a single DOM for manipulation (a single source of truth) so as not to worry about having to copy all modifications from one tree (f.e. left eye) to the other (f.e. right eye).

<div class="left-eye" style="transform: ...left eye offset transformation...">
  <div style="transform: translateZ(-400px) rotateY(20deg)" mirror="rightEye">This is content in 3D space.</div>
</div>
<div class="left-eye" style="transform: ...right eye offset transformation...">
  <mirror name="rightEye" />
</div>

Then the user only ever has to modify the left eye content, and it is mirrored to the right eye with the proper right-eye transform.

Use Case 2: Render useful things in multiple places

This is an e-commerce website:

<style>
  header .shoppingCart {
    /*header-specific style for the cart*/
  }
  .sidebar .shoppingCart {
    /* Perhaps the rendering of the cart in the sidebar has a tweak or two. */
  }
  footer .shoppingCart {
    /* Perhaps the rendering of the cart in the footer has a tweak or two. */
  }
  /* The above CSS applies in a manner similar to nodes distributed into a <slot> where
      the ShadowRoot styles distributed children relative to their shadow tree position.
      In this case the distributed node is styled relative to its mirror locations (but like normal
      in its original location). */
</style>
<header class="nav">
  <div class="shoppingCart" mirror="cart">
    <div>3 items, total: $50</div>
    <button>Checkout</button>
  </div>
</header>
<aside class="sidebar">
  ...
  <mirror name="cart">
  ...
</aside>
<footer>
  ...
  <mirror name="cart">
  ...
</footer>

Bike sheddable: perhaps mirrors can have the same name, and something assigned to that mirror name renders at all locations.

Use Case 3: "portals"

On its own, it is similar to the portal concepts in other virtual component systems like React, Vue, etc.

Here's the Svelte Portal example. The part with <div use:createPortal={'foo'}> is the <mirror>, and <div use:portal={'foo'}> is an element in another component being mounted to that position in the other component.

The difference with those concepts and this new <mirror> concept is that in this <mirror> concept the element being mirrored can be mirrored to multiple locations as well as render in its original location.

A library author could implement their own "portal" concept on top of the <mirror> system in order to enforce certain restrictions like an element mirroring only to one outside location (f.e. using a unique naming, or some form of mirror reference passing), or enforcing that the portaled item does not render in its original location with some CSS styling.

<style>
  .originalLocation > * {
    display: none;
  }
</style>

<!-- ...component's tree... -->
<div class=".originalLocation">
  <div portal="foo"></div>
</div>

<!-- ...other component's portal... -->
<mirror name="uniqueName" />

Most common use case

Probably the most common use case will be duplicating content in various places, like with the shopping cart example. The following are sometimes seen in multiple locations of web apps:

Additional API ideas

Other benefits:

By having only one source of truth for things that should be rendered in multiple places,

rniwa commented 3 years ago

This is quite a bit like svg use element. Ironically, WebKit & Blink implement svg use element using shadow roots and replicating DOM tree in each instance of use element. FWIW, this seems like something you can easily implement in the user land. You just need to "mirror" DOM tree for each instance using MutationObserver. That's effectively what WebKit/Blink does except it gets updated whenever style gets updated so the timing is slightly different. Trident/Edge used to implement svg use element at more of rendering layer so they avoided replicating the DOM tree but it leads to many architectural challenges in other engines so that's not really practical.

trusktr commented 3 years ago

this seems like something you can easily implement in the user land. You just need to "mirror" DOM tree for each instance using MutationObserver.

It is possible, yes, just like we can polyfill Custom Elements, which comes with its can of worms.

Performance aside (either the browser replicates internal trees or it chooses a fast render-only path), the <mirror> idea would improve developer experience and development simplicity in a standard and powerful way.

If a user writes their own duplicate-DOM-and-mirror-changes-with-MutationObserver system, it can open up the surface area for bugs. It can interfere with the end users DOM representation. For example, imagine the user installs a 3rd-party jQuery plugin, and when the user instantiates the plugin, it accidentally also applies itself onto the duplicated DOM trees and causes unintended side-effects. The user is expecting to manipulate only one DOM tree as the source of truth. But due to the plugin also manipulating the duplicate DOM trees, the MutationObserver mechanism could break in some unexpected way, or the jQuery APIs may return multiple states when there should only be one and it could confuse the user code. Of course there would always be some way to fix such an issue, but it would complicate the application code base.

rniwa commented 3 years ago

Performance aside (either the browser replicates internal trees or it chooses a fast render-only path), the <mirror> idea would improve developer experience and development simplicity in a standard and powerful way.

I mean... it's true that all new APIs will improve developer ergonomics for developers who need them but adding a very complex feature like this requires a really good reason to do so.

If a user writes their own duplicate-DOM-and-mirror-changes-with-MutationObserver system, it can open up the surface area for bugs. It can interfere with the end users DOM representation. For example, imagine the user installs a 3rd-party jQuery plugin, and when the user instantiates the plugin, it accidentally also applies itself onto the duplicated DOM trees and causes unintended side-effects. The user is expecting to manipulate only one DOM tree as the source of truth. But due to the plugin also manipulating the duplicate DOM trees, the MutationObserver mechanism could break in some unexpected way, or the jQuery APIs may return multiple states when there should only be one and it could confuse the user code. Of course there would always be some way to fix such an issue, but it would complicate the application code base.

How does jQuery plugin start working on / gain access to a duplicated DOM tree? Just put it inside a closed shadow tree and nobody else will find the duplicated instances.

trusktr commented 3 years ago

Just put it inside a closed shadow tree and nobody else will find the duplicated instances.

Yeah, there are workarounds of course. Also using unique selectors would help (f.e. .left-eye > .foo).

Considering performance, isn't crossing JS-native boundaries more expensive than keeping things on the native side (f.e. keeping the duplicates as native trees)?

The complexity is a little more simple than ShadowRoot and <slot>. I imagine various parts of the internal code would be re-used.

jonathantneal commented 2 years ago

This kind of functionality has seemed necessary to me when building custom previews, selects, trees, tables, and multitracks. I suspect it’s applicable to experiences that clone DOM trees for the purpose of mirroring content, or custom elements that require nesting deeper than a single parent and child.

Cloning a DOM tree and mirroring it with a MutationObserver would be insufficient when the source elements contain events or styles. For the web components I’ve been working on, our users plan to slot in their own content with its own functionality.

Without something like this mirror proposal, my currently alternative is to create some kind of proxy-like prototype for everything from EventTarget to HTMLElement for functionality cloning, and/or observers chained to a cached CSSStyleDeclaration from getComputedStyle for style cloning. Normally, I wouldn’t entertain this, but I already have to do something very similar to emulate constructible stylesheets. Still, the idea of messing around with native classes beyond polyfilling gives me MooTools vibes, so I’m hoping some of the linked examples will help illuminate a need for this.

yinonov commented 2 years ago

can cross pages be considered in this scope? would it be feasible to mirror from 1 page to another same as GH do with permalinks?

https://github.com/whatwg/html/blob/823a14b4353266c7885c7b06da0d6ba1e4f1bb20/README.md?plain=1#L5