ソースを参照

fix(custom-element): ensure child component styles are injected in correct order before parent styles (#13374)

close #13029
edison 1 ヶ月 前
コミット
1398bf8dfb

+ 1 - 1
packages/runtime-core/src/component.ts

@@ -1271,7 +1271,7 @@ export interface ComponentCustomElementInterface {
   /**
    * @internal
    */
-  _injectChildStyle(type: ConcreteComponent): void
+  _injectChildStyle(type: ConcreteComponent, parent?: ConcreteComponent): void
   /**
    * @internal
    */

+ 4 - 1
packages/runtime-core/src/renderer.ts

@@ -1367,7 +1367,10 @@ function baseCreateRenderer(
         } else {
           // custom element style injection
           if (root.ce && root.ce._hasShadowRoot()) {
-            root.ce._injectChildStyle(type)
+            root.ce._injectChildStyle(
+              type,
+              instance.parent ? instance.parent.type : undefined,
+            )
           }
 
           if (__DEV__) {

+ 184 - 0
packages/runtime-dom/__tests__/customElement.spec.ts

@@ -1218,6 +1218,190 @@ describe('defineCustomElement', () => {
       assertStyles(el, [`div { color: blue; }`, `div { color: red; }`])
     })
 
+    test('root custom element HMR should preserve child-first style order', async () => {
+      const Child = defineComponent({
+        styles: [`div { color: green; }`],
+        render() {
+          return 'child'
+        },
+      })
+      const def = defineComponent({
+        __hmrId: 'root-child-style-order',
+        styles: [`div { color: red; }`],
+        render() {
+          return h(Child)
+        },
+      })
+      const Foo = defineCustomElement(def)
+      customElements.define('my-el-root-hmr-style-order', Foo)
+      container.innerHTML = `<my-el-root-hmr-style-order></my-el-root-hmr-style-order>`
+      const el = container.childNodes[0] as VueElement
+
+      assertStyles(el, [`div { color: green; }`, `div { color: red; }`])
+
+      __VUE_HMR_RUNTIME__.reload(def.__hmrId!, {
+        ...def,
+        styles: [`div { color: blue; }`, `div { color: yellow; }`],
+      } as any)
+
+      await nextTick()
+      assertStyles(el, [
+        `div { color: green; }`,
+        `div { color: blue; }`,
+        `div { color: yellow; }`,
+      ])
+    })
+
+    test('inject child component styles before parent styles', async () => {
+      const Baz = () => h(Bar)
+      const Bar = defineComponent({
+        styles: [`div { color: green; }`],
+        render() {
+          return 'bar'
+        },
+      })
+      const WrapperBar = defineComponent({
+        styles: [`div { color: blue; }`],
+        render() {
+          return h(Baz)
+        },
+      })
+      const WBaz = () => h(WrapperBar)
+      const Foo = defineCustomElement({
+        styles: [`div { color: red; }`],
+        render() {
+          return [h(Baz), h(WBaz)]
+        },
+      })
+      customElements.define('my-el-with-wrapper-child-styles', Foo)
+      container.innerHTML = `<my-el-with-wrapper-child-styles></my-el-with-wrapper-child-styles>`
+      const el = container.childNodes[0] as VueElement
+
+      // inject order should be child -> parent
+      assertStyles(el, [
+        `div { color: green; }`,
+        `div { color: blue; }`,
+        `div { color: red; }`,
+      ])
+    })
+
+    test('inject nested child component styles after HMR removes parent styles', async () => {
+      const Bar = defineComponent({
+        __hmrId: 'nested-child-style-hmr-bar',
+        styles: [`div { color: green; }`],
+        render() {
+          return 'bar'
+        },
+      })
+      const WrapperBar = defineComponent({
+        __hmrId: 'nested-child-style-hmr-wrapper',
+        styles: [`div { color: blue; }`],
+        render() {
+          return h(Bar)
+        },
+      })
+      const Foo = defineCustomElement({
+        styles: [`div { color: red; }`],
+        render() {
+          return h(WrapperBar)
+        },
+      })
+      customElements.define('my-el-with-hmr-nested-child-styles', Foo)
+      container.innerHTML = `<my-el-with-hmr-nested-child-styles></my-el-with-hmr-nested-child-styles>`
+      const el = container.childNodes[0] as VueElement
+
+      assertStyles(el, [
+        `div { color: green; }`,
+        `div { color: blue; }`,
+        `div { color: red; }`,
+      ])
+
+      __VUE_HMR_RUNTIME__.reload(WrapperBar.__hmrId!, {
+        ...WrapperBar,
+        styles: undefined,
+      } as any)
+      await nextTick()
+      assertStyles(el, [`div { color: green; }`, `div { color: red; }`])
+
+      __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, {
+        ...Bar,
+        styles: [`div { color: yellow; }`],
+      } as any)
+      await nextTick()
+      assertStyles(el, [`div { color: yellow; }`, `div { color: red; }`])
+    })
+
+    test('inject child component styles when parent has no styles', async () => {
+      const Baz = () => h(Bar)
+      const Bar = defineComponent({
+        styles: [`div { color: green; }`],
+        render() {
+          return 'bar'
+        },
+      })
+      const WrapperBar = defineComponent({
+        styles: [`div { color: blue; }`],
+        render() {
+          return h(Baz)
+        },
+      })
+      const WBaz = () => h(WrapperBar)
+      // without styles
+      const Foo = defineCustomElement({
+        render() {
+          return [h(Baz), h(WBaz)]
+        },
+      })
+      customElements.define('my-el-with-inject-child-styles', Foo)
+      container.innerHTML = `<my-el-with-inject-child-styles></my-el-with-inject-child-styles>`
+      const el = container.childNodes[0] as VueElement
+
+      assertStyles(el, [`div { color: green; }`, `div { color: blue; }`])
+    })
+
+    test('inject nested child component styles', async () => {
+      const Baz = defineComponent({
+        styles: [`div { color: yellow; }`],
+        render() {
+          return h(Bar)
+        },
+      })
+      const Bar = defineComponent({
+        styles: [`div { color: green; }`],
+        render() {
+          return 'bar'
+        },
+      })
+      const WrapperBar = defineComponent({
+        styles: [`div { color: blue; }`],
+        render() {
+          return h(Baz)
+        },
+      })
+      const WBaz = defineComponent({
+        styles: [`div { color: black; }`],
+        render() {
+          return h(WrapperBar)
+        },
+      })
+      const Foo = defineCustomElement({
+        styles: [`div { color: red; }`],
+        render() {
+          return [h(Baz), h(WBaz)]
+        },
+      })
+      customElements.define('my-el-with-inject-nested-child-styles', Foo)
+      container.innerHTML = `<my-el-with-inject-nested-child-styles></my-el-with-inject-nested-child-styles>`
+      const el = container.childNodes[0] as VueElement
+      assertStyles(el, [
+        `div { color: green; }`,
+        `div { color: yellow; }`,
+        `div { color: blue; }`,
+        `div { color: black; }`,
+        `div { color: red; }`,
+      ])
+    })
+
     test("child components should not inject styles to root element's shadow root w/ shadowRoot false", async () => {
       const Bar = defineComponent({
         styles: [`div { color: green; }`],

+ 48 - 3
packages/runtime-dom/src/apiCustomElement.ts

@@ -235,6 +235,8 @@ export class VueElement
   private _styleChildren = new WeakSet()
   private _pendingResolve: Promise<void> | undefined
   private _parent: VueElement | undefined
+  private _styleAnchors: WeakMap<ConcreteComponent, HTMLStyleElement> =
+    new WeakMap()
   /**
    * dev only
    */
@@ -571,6 +573,7 @@ export class VueElement
               this._styles.forEach(s => this._root.removeChild(s))
               this._styles.length = 0
             }
+            this._styleAnchors.delete(this._def)
             this._applyStyles(newStyles)
             this._instance = null
             this._update()
@@ -607,6 +610,7 @@ export class VueElement
   private _applyStyles(
     styles: string[] | undefined,
     owner?: ConcreteComponent,
+    parentComp?: ConcreteComponent,
   ) {
     if (!styles) return
     if (owner) {
@@ -615,12 +619,25 @@ export class VueElement
       }
       this._styleChildren.add(owner)
     }
+
     const nonce = this._nonce
+    const root = this.shadowRoot!
+    const insertionAnchor = parentComp
+      ? this._getStyleAnchor(parentComp) || this._getStyleAnchor(this._def)
+      : this._getRootStyleInsertionAnchor(root)
+    let last: HTMLStyleElement | null = null
     for (let i = styles.length - 1; i >= 0; i--) {
       const s = document.createElement('style')
       if (nonce) s.setAttribute('nonce', nonce)
       s.textContent = styles[i]
-      this.shadowRoot!.prepend(s)
+
+      root.insertBefore(s, last || insertionAnchor)
+      last = s
+      if (i === 0) {
+        if (!parentComp) this._styleAnchors.set(this._def, s)
+        if (owner) this._styleAnchors.set(owner, s)
+      }
+
       // record for HMR
       if (__DEV__) {
         if (owner) {
@@ -639,6 +656,30 @@ export class VueElement
     }
   }
 
+  private _getStyleAnchor(comp?: ConcreteComponent): HTMLStyleElement | null {
+    if (!comp) {
+      return null
+    }
+    const anchor = this._styleAnchors.get(comp)
+    if (anchor && anchor.parentNode === this.shadowRoot) {
+      return anchor
+    }
+    if (anchor) {
+      this._styleAnchors.delete(comp)
+    }
+    return null
+  }
+
+  private _getRootStyleInsertionAnchor(root: ShadowRoot): ChildNode | null {
+    for (let i = 0; i < root.childNodes.length; i++) {
+      const node = root.childNodes[i]
+      if (!(node instanceof HTMLStyleElement)) {
+        return node
+      }
+    }
+    return null
+  }
+
   /**
    * Only called when shadowRoot is false
    */
@@ -708,8 +749,11 @@ export class VueElement
   /**
    * @internal
    */
-  _injectChildStyle(comp: ConcreteComponent & CustomElementOptions): void {
-    this._applyStyles(comp.styles, comp)
+  _injectChildStyle(
+    comp: ConcreteComponent & CustomElementOptions,
+    parentComp?: ConcreteComponent,
+  ): void {
+    this._applyStyles(comp.styles, comp, parentComp)
   }
 
   /**
@@ -743,6 +787,7 @@ export class VueElement
   _removeChildStyle(comp: ConcreteComponent): void {
     if (__DEV__) {
       this._styleChildren.delete(comp)
+      this._styleAnchors.delete(comp)
       if (this._childStyles && comp.__hmrId) {
         // clear old styles
         const oldStyles = this._childStyles.get(comp.__hmrId)