Skip to content

In CustomElements, DOM #shadow-root slot semantics are not being honored, Vue is stepping on them #49

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

Open
smallscript opened this issue Mar 18, 2019 · 8 comments
Labels
enhancement New feature or request slots

Comments

@smallscript
Copy link

smallscript commented Mar 18, 2019

TL;DR
Issue:
vue-web-component-wrapper prevents a WebComponent #shadow-root from containing DOM rendered slot elements.

In its implementation, the wrap function it provides is focused on creating CustomElements tags that behave as VueComponents; rather than focusing on behaving as WebComponents defined from a VueComponent that integrates, with shadow-DOM semantics, as a direct child-element in their #shadow-root definition.

I've written a Vue vue-web-component-plugin (<=) , using a different design, that supports DOM WebComponent slot behavior. It is approximately ~100 lines of self-contained ready to use Javascript .msj module code.

Some of the other differences from wrap are:

  • seamless Vue.component registration via Vue.component(..)
  • any ancestral $root vue is inherited/shared, or if none, then a $root vue is created as needed
  • lazy Vue integration of DOM upgraded custom HTMLElements; reducing temporal side-effects.
  • vueDefinition key shadowCss support for #shadow-root encapsulated css definitions

For slots to work, I had to enhance Vue with a way to honor DOM semantics for #shadow-root slot elements.

Note:
The following discussion assumes some familiarity with the DOM concepts slot, light DOM and shadow DOM.

For more detail on what wrap does, and why it is different from the plugin I mentioned above, I will briefly present a minimal description of how Vue VueComponents work and describe a few browser terms for clarity.

The Vue system has VueComponents (similar to classes) that are based on vueDefinitions associated with a tagName using Vue.component(tagName, vueDefinition). I will be referring to these VueComponents as HTML macros, from here on.

When you create a new $root vue using new Vue(vueDefinition) with a el: member in the vueDefinition (or manually invoke its' $mount), Vue will walk the childElements and recursively perform macro substitution of all the elements whose tagName matches the VueComponents you registered using Vue.component(tagName, vueDefinition). It will continue to perform this behavior based on reactive change tracking from that point on.

It does this using the render function defined on the vueDefinition.

If no render function is defined, it will look for a template to compile into a render function. If no template is defined, it will extract the innerHTML of the corresponding tagName element, and use that as the template.

This makes Vue a nicely designed and powerful reactive HTML macro (aka VueComponent) system, where arguments to the tagName element macros are provided in their attributes, and all rendered macro results are reactively tracked for efficient management of change propagation (observe and react patterns from Smalltalk). In that same system templates and (innerHTML) support {{..}} substitution rules.

The browser DOM allows you to define CustomElements and WebComponents.

To enable that, it provides the customElements API to allow registering a Javascript class as the behavior for an element with a hyphenated tagName.

A custom element once registered is a new type of browser DOM element; depending on your browser, custom elements technology is the same technique by which it internally implements a number of the intrinsic browser elements with closed #shadow-roots.

A custom element is not a macro and thus can't do some of the things Vue VueComponents can, but a WebComponent using a shadow DOM #shadow-root can provide some capabilities that Vue VueComponents cannot. This can be specifically seen in how it uses DOM slot elements, and how it lazily upgrades tagName elements when a corresponding custom element is registered, and in how it encapsulates the #shadow-root elements including css.

When you issue the call:

customElements.define(tagName, behavior_class, options)

you are defining a CustomElement. If that CustomElement will utilize a #shadow-root then you are defining a WebComponent. A WebComponent uses slot elements within the #shadow-root for slotting (transposing) the innerHTML (DOM Nodes) contained within any HTML that has that tagName element.

What's the problem, why should I think about these things?

Given that background we now have the foundation to understand why wrap does not provide expected WebComponent #shadow-root semantics and behavior, but does allow self-contained CustomElement isolated $root wrappering of Vue VueComponent vueDefinitions.

Phew, sorry you had to read all that. It is a lot of pedantically crafted explanatory discussion for saying DOM slot elements in a WebComponent's shadowRoot don't work in Vue, and that you can't use WebComponents (defined by a vueDefinition) properly in Vue without some modifications to Vue. Specifically, we need a way to tell Vue to treat appropriately marked slot elements, as DOM slot elements and not as Vue slot macros.

Basically, Vue thinks that any slot elements it sees within a vue subtree of VNodes are slot elements that it should always use as its (Vue's) own macro element directives for performing transpositional replacement of innerHTML content into a render/template definition.

That behavior leads to a direct conflict with the DOM semantics of slot elements in #shadow-roots.

In other words Vue grabs all slot elements wherever it sees them, which with wrap defined CustomElements includes their #shadow-root. Therefore the DOM never gets to see them in a wrapped WebComponents #shadow-root and can't perform the expected DOM slotting semantics.

@smallscript smallscript changed the title Intialization and mutation are using this.children and NOT this.shadowRoot.children wrap initialization and mutation are using this.children not this.shadowRoot.children? Mar 18, 2019
@smallscript
Copy link
Author

smallscript commented Mar 20, 2019

[TL;DR spoiler alert, jump ahead one reply to a plugin solution]

If you want to experiment, and try this yourself, the following patches to vue.js and vue-wc-wrapper.js are what you need:

  1. vue.js:10224
if (el.tag === 'slot') {
TO
if (el.tag === 'slot' && el.attrsMap['data-wcs'] === undefined) {
  1. vue.js:10940
} else if (el.tag === 'slot') {
TO
} else if (el.tag === 'slot' && el.attrsMap['data-wcs'] === undefined) {
  1. vue-wc-wrapper.js:2XX
this.childNodes
TO
this.shadowRoot.childNodes
  1. In your options.template, make sure you add data-wcs to all your <slot> elements. I.e., make sure you inform Vue to ignore them in (1) and (2). I've got toVNode code in vue-wc-wrapper.js that automatically injects that attribute onto slot elements, so the manual step of adding data-wcs to your slot elements is only needed if you don't have my vue-wc-wrapper.js auto-attr modification. By leaving it as a manual step while you try the experiment, you can put it on one named slot and not on another named slot and verify/observe the effect.

Note: If you decide to use a "closed" shadowRoot, that's fine. But you must then keep a let local shadowRoot reference since this.shadowRoot will be undefined.

  1. You should also, within the toVNode function, remove the entire if code-block in vue-wc-wrapper.js:2XX that deletes any slot attribute (as opposed to slot element), or you will not be able to reference "named-slots" in other real WebComponents that may be placed within your shadowRoot code (template).
    delete data.attrs.slot

Bonus, for better efficiency and less side-effects, do this customElements.get(tag) fix too:

    if (unknownElementCache[tag] != null) {
      return unknownElementCache[tag]
    }
    if (tag.indexOf('-') > -1) {
      // http://stackoverflow.com/a/28210364/1070244
      if(typeof customElements !== 'undefined') {
        let cec, uec = window.HTMLUnknownElement; 
        return (unknownElementCache[tag] = 
          (cec = customElements.get(tag)) ? cec : uec) === uec;
      }
      else {
        const el = document.createElement(tag);
        return (unknownElementCache[tag] = (
          el.constructor === window.HTMLUnknownElement ||
          el.constructor === window.HTMLElement
        ))
      }
    } else {

and, for a cleaner version of the above use const $isDOMSlotAttr = 'data-wcs'; in your vue.js modifications instead of hard-coding data-wcs (in steps 1 and 2) along with configuring it as a Vue export to that serves to flag-the-feature-capability and export the Vue change:

Vue.$isDOMSlotAttr = $isDOMSlotAttr;

That's it. Generally I've done a number of other things within vue-wc-wrapper.js that simplified the model, offered hooks and shadowCss, as well as reduced the size of the lexical closures being generated for better JavaScript execution efficiency. But they are not required to see the fix in action.

Those changes are preparatory for creating a Vue plugin that shims Vue.component(..) function to support lazy auto-registration as a WebComponent (instead of registering it as a Vue VDOM HTMLMacroElement) when the options contain a shadowCss definition. This makes the entire process seamless and light weight declarative behavior for any Vue VueComponent vueDefinition aka $options definition.

@smallscript
Copy link
Author

smallscript commented Mar 24, 2019

As mentioned in the more recent-version of the original "issue posting" at the top of this page, I ended up creating a Vue plugin to support using Vue to create WebComponents that support shadow-DOM semantics.

Once I had gone through the task of fixing the design of vue-web-component-wrapper.js, and achieving a working version (look in .archives folder), I was not satisfied.

In Smalltalk lingo, once I looked at the result of modifying vue-web-component-wrapper.js, it "smelled".

p.s., I've a multi-decade implementation background with Smalltalk, Javascript, and other languages where framework capabilities similar to webpack like bundling, VDom patterns and Vue's implementation approach were required.

With that said, I started from scratch this morning and wrote a Vue plugin that takes-arg-options, shims Vue.component and handles the necessary aspects of the lifecycle, dealing with needing a root-element in the shadowRoot, managing the shadowRoot slot elements to keep Vue from fiddling them, and lazily registering them and making them visible or rendering, and it works with lazy deferred loading (lazy loading mechanics changing in final plugin, pending ongoing back-end EdgeScript jit HMR service changes).

vue-web-component-plugin implementation available here

The lightweight end-result is ~100 lines of code, one class, and the resulting native DOM WebComponents are JS objects with few values on them, optimized for proto-inheritance and minimization of shared closure-context costs (Js-Functions/Smalltalk-Block lambda capture-and-lift of lexical-scope shared variables).

The implementation also supports one of my goals of allowing seamless use of the existing VueComponent API and vueDefinitions model. I.e., however you might build or bundle them, they can be used as either a VueComponent macro, or a native DOM WebComponent with all the Vue features.

A WebComponent instantiated from a vueDefinition does not need a Vue root, but if it is contained within one, it will inherit it properly. If the vueDefinition has a shadowCss member, it tells the WebComponent plugin (shim) to make any Vue.component(tagName, vueDefinition) a WebComponent.

My sincere hope is that the Vue-team incorporates the required native slot fix; or some equivalent, back into the Vue vue.js core code.

@smallscript smallscript changed the title wrap initialization and mutation are using this.children not this.shadowRoot.children? In CustomElements, DOM #shadow-root slot semantics are not being honored, Vue is stepping on them Mar 27, 2019
@anthonylebrun
Copy link

@smallscript This is amazing, and exactly what I was looking for. Perfect timing too! Going to give your plugin / modified vue.js a shot.

@Adam-RapidRTC
Copy link

I found a workaround for this that doesn't require any modifications to Vue or plug-ins and it appears to be working across all major browsers.

Basically just wire up the $nexttick event to clear out the innerHTML of the parent of the slot element and insert a new slot. I answered my own question on StackOverflow so you can see the working example here: https://stackoverflow.com/questions/55169976/how-to-properly-use-slot-inside-of-vue-js-web-component-and-apply-styles/55712612#55712612

@LinusBorg LinusBorg added enhancement New feature or request slots labels Apr 17, 2019
@TomCaserta
Copy link

TomCaserta commented May 9, 2019

I've had this issue too and forked the library to make it actually function somewhat correctly: https://github.com/GiG/vue-web-component-wrapper (readme isn't updated).

Because I wanted to get something working (until a real solution came along) it's not overly thought out and includes some code specific to what we're working on at the moment.

It was somewhat easy though to add real slot support in vue without modifying Vue itself by modifying the toVNodes method to output real slot elements instead of the child component dom when it finds slotted children.

https://github.com/GiG/vue-web-component-wrapper/blob/master/src/utils.js#L68 (modified toVNodes method).

Great write up and explanation, I do hope this is looked into from Vue itself as quite honestly in it's current form exporting web components from vue just doesn't work as you would expect.

@mdo2
Copy link

mdo2 commented Jul 1, 2020

First of all, thanks @TomCaserta for your great job fixing the library.

We are using this lib as part of the cross technologies (React, Vue, Angular, native, etc.) compatibility feature of a front-end framework based on Vue as you can see here Power-Solutions/power-solutions-frontend-framework#2.

We decide to use Vue because of the community, activity, and compatibility, but we will not follow this project on Vue if there is no community support.
We are talking about a core feature of the package, not about an unnecessary addition.

@TomCaserta and others had proposed specific solutions for this, but nothing has been done. So please, if you can not do anything for now or this project is dead, SAY SOMETHING!!!.

Sorry for the emotional speaking but I think that when you own wide used repositories, you have a responsibility too.

Thanks for all, hope you answer soon.

@AThiyagarajan
Copy link

We are also running into the similar issues when using slot along with shadow-root.

we are using an external webcomponent library (which is using stencilJS) in our VueJS (^2.6.10) repo.

An example would be like this, form-field is a webcomponent which has named slots within in the shadow root.

<form-field>
   <input slot="input"/>
   <span v-if="hasError" slot="error"/>
</form-field>

Now the error slot does not render based on the hasError condition.

@earlAchromatic
Copy link

@LinusBorg You mention in issue #93 (comment) Vue 3 supports built in Custom Elements with the new wrapper implementation but I am curious, does this implementation address this slots override issue? I am running into a use case where this seems to be what is happening.

Basically, Vue thinks that any slot elements it sees within a vue subtree of VNodes are slot elements that it should always use as its (Vue's) own macro element directives for performing transpositional replacement of innerHTML content into a render/template definition.

That behavior leads to a direct conflict with the DOM semantics of slot elements in #shadow-roots.

I am attempting to develop a custom element library using Vue 3's defineCustomElement and nested slots and am running into problems when I try to use a native <slot/> component inside a Vue Custom Element. I should be able to use a 'slot' attribute and a 'name' attribute on this native slot element but when I do so inside of a Vue SFC, the 'slot' attribute gets stripped away somewhere in between the declaration in the SFC and the rendered HTML output.

My implementation looks something like this:

//someNewComponent.ce.vue
<template>
  <div>
    <custom-element>
      <slot name="content-b" slot="content-a" /> // this is the el whose attributes get overridden
    </custom-element>
  </div>
</template>

<script>
import './element.js' // access to defined custom element
...
</script>

with the custom-element component file (customElement.ce.vue):

//customElement.ce.vue
<template>
    <custom-element>
      <div class="wrapper">
          <slot name="content-a" />
      </div>
  </custom-element>
</template>

and the element.js file where the custom element is declared:

//element.js
import customElement from './customElement.ce.vue'
import { defineCustomElement } from "vue";
const ce = defineCustomElement(customElement);
customElements.define("custom-element", ce);

and the rendered html output of the new component.

<div>
  #shadow-root (open)
    <custom-element>
      <slot name="content-b" /> // Should be <slot name="content-b" slot="content-a"/>
    </custom-element>
  <div slot="content-b"></div>
</div>

where there is no longer a 'slot' attribute on the slot inside <custom-element> so the connection between the slot content never gets made and the desired content never successfully rendered within the component. If I manually add the slot attribute, everything works as intended, but I can't figure out why the attribute is being stripped out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request slots
Projects
None yet
Development

No branches or pull requests

8 participants