Pārlūkot izejas kodu

feat(custom-element): inject child components styles to custom element shadow root (#11517)

close #4662
close #7941
close #7942
Evan You 1 gadu atpakaļ
vecāks
revīzija
56c76a8b05

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

@@ -417,7 +417,7 @@ export interface ComponentInternalInstance {
    * is custom element?
    * @internal
    */
-  ce?: Element
+  ce?: ComponentCustomElementInterface
   /**
    * custom element specific HMR method
    * @internal
@@ -1237,3 +1237,8 @@ export function formatComponentName(
 export function isClassComponent(value: unknown): value is ClassComponent {
   return isFunction(value) && '__vccOpts' in value
 }
+
+export interface ComponentCustomElementInterface {
+  injectChildStyle(type: ConcreteComponent): void
+  removeChildStlye(type: ConcreteComponent): void
+}

+ 5 - 0
packages/runtime-core/src/hmr.ts

@@ -159,6 +159,11 @@ function reload(id: string, newComp: HMRComponent) {
         '[HMR] Root or manually mounted instance modified. Full reload required.',
       )
     }
+
+    // update custom element child style
+    if (instance.root.ce && instance !== instance.root) {
+      instance.root.ce.removeChildStlye(oldComp)
+    }
   }
 
   // 5. make sure to cleanup dirty hmr components after update

+ 1 - 0
packages/runtime-core/src/index.ts

@@ -263,6 +263,7 @@ export type {
   GlobalComponents,
   GlobalDirectives,
   ComponentInstance,
+  ComponentCustomElementInterface,
 } from './component'
 export type {
   DefineComponent,

+ 7 - 2
packages/runtime-core/src/renderer.ts

@@ -1276,8 +1276,8 @@ function baseCreateRenderer(
     const componentUpdateFn = () => {
       if (!instance.isMounted) {
         let vnodeHook: VNodeHook | null | undefined
-        const { el, props, type } = initialVNode
-        const { bm, m, parent } = instance
+        const { el, props } = initialVNode
+        const { bm, m, parent, root, type } = instance
         const isAsyncWrapperVNode = isAsyncWrapper(initialVNode)
 
         toggleRecurse(instance, false)
@@ -1335,6 +1335,11 @@ function baseCreateRenderer(
             hydrateSubTree()
           }
         } else {
+          // custom element style injection
+          if (root.ce) {
+            root.ce.injectChildStyle(type)
+          }
+
           if (__DEV__) {
             startMeasure(instance, `render`)
           }

+ 71 - 2
packages/runtime-dom/__tests__/customElement.spec.ts

@@ -1,5 +1,6 @@
 import type { MockedFunction } from 'vitest'
 import {
+  type HMRRuntime,
   type Ref,
   type VueElement,
   createApp,
@@ -15,6 +16,8 @@ import {
   useShadowRoot,
 } from '../src'
 
+declare var __VUE_HMR_RUNTIME__: HMRRuntime
+
 describe('defineCustomElement', () => {
   const container = document.createElement('div')
   document.body.appendChild(container)
@@ -636,18 +639,84 @@ describe('defineCustomElement', () => {
   })
 
   describe('styles', () => {
-    test('should attach styles to shadow dom', () => {
-      const Foo = defineCustomElement({
+    function assertStyles(el: VueElement, css: string[]) {
+      const styles = el.shadowRoot?.querySelectorAll('style')!
+      expect(styles.length).toBe(css.length) // should not duplicate multiple copies from Bar
+      for (let i = 0; i < css.length; i++) {
+        expect(styles[i].textContent).toBe(css[i])
+      }
+    }
+
+    test('should attach styles to shadow dom', async () => {
+      const def = defineComponent({
+        __hmrId: 'foo',
         styles: [`div { color: red; }`],
         render() {
           return h('div', 'hello')
         },
       })
+      const Foo = defineCustomElement(def)
       customElements.define('my-el-with-styles', Foo)
       container.innerHTML = `<my-el-with-styles></my-el-with-styles>`
       const el = container.childNodes[0] as VueElement
       const style = el.shadowRoot?.querySelector('style')!
       expect(style.textContent).toBe(`div { color: red; }`)
+
+      // hmr
+      __VUE_HMR_RUNTIME__.reload('foo', {
+        ...def,
+        styles: [`div { color: blue; }`, `div { color: yellow; }`],
+      } as any)
+
+      await nextTick()
+      assertStyles(el, [`div { color: blue; }`, `div { color: yellow; }`])
+    })
+
+    test("child components should inject styles to root element's shadow root", async () => {
+      const Baz = () => h(Bar)
+      const Bar = defineComponent({
+        __hmrId: 'bar',
+        styles: [`div { color: green; }`, `div { color: blue; }`],
+        render() {
+          return 'bar'
+        },
+      })
+      const Foo = defineCustomElement({
+        styles: [`div { color: red; }`],
+        render() {
+          return [h(Baz), h(Baz)]
+        },
+      })
+      customElements.define('my-el-with-child-styles', Foo)
+      container.innerHTML = `<my-el-with-child-styles></my-el-with-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; }`,
+      ])
+
+      // hmr
+      __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, {
+        ...Bar,
+        styles: [`div { color: red; }`, `div { color: yellow; }`],
+      } as any)
+
+      await nextTick()
+      assertStyles(el, [
+        `div { color: red; }`,
+        `div { color: yellow; }`,
+        `div { color: red; }`,
+      ])
+
+      __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, {
+        ...Bar,
+        styles: [`div { color: blue; }`],
+      } as any)
+      await nextTick()
+      assertStyles(el, [`div { color: blue; }`, `div { color: red; }`])
     })
   })
 

+ 64 - 16
packages/runtime-dom/src/apiCustomElement.ts

@@ -1,5 +1,6 @@
 import {
   type Component,
+  type ComponentCustomElementInterface,
   type ComponentInjectOptions,
   type ComponentInternalInstance,
   type ComponentObjectPropsOptions,
@@ -189,7 +190,10 @@ const BaseClass = (
 
 type InnerComponentDef = ConcreteComponent & CustomElementOptions
 
-export class VueElement extends BaseClass {
+export class VueElement
+  extends BaseClass
+  implements ComponentCustomElementInterface
+{
   /**
    * @internal
    */
@@ -198,7 +202,15 @@ export class VueElement extends BaseClass {
   private _connected = false
   private _resolved = false
   private _numberProps: Record<string, true> | null = null
+  private _styleChildren = new WeakSet()
+  /**
+   * dev only
+   */
   private _styles?: HTMLStyleElement[]
+  /**
+   * dev only
+   */
+  private _childStyles?: Map<string, HTMLStyleElement[]>
   private _ob?: MutationObserver | null = null
   /**
    * @internal
@@ -312,13 +324,14 @@ export class VueElement extends BaseClass {
       }
 
       // apply CSS
-      if (__DEV__ && styles && def.shadowRoot === false) {
+      if (this.shadowRoot) {
+        this._applyStyles(styles)
+      } else if (__DEV__ && styles) {
         warn(
           'Custom element style injection is not supported when using ' +
             'shadowRoot: false',
         )
       }
-      this._applyStyles(styles)
 
       // initial render
       this._update()
@@ -329,7 +342,7 @@ export class VueElement extends BaseClass {
 
     const asyncDef = (this._def as ComponentOptions).__asyncLoader
     if (asyncDef) {
-      asyncDef().then(def => resolve(def, true))
+      asyncDef().then(def => resolve((this._def = def), true))
     } else {
       resolve(this._def)
     }
@@ -486,19 +499,36 @@ export class VueElement extends BaseClass {
     return vnode
   }
 
-  private _applyStyles(styles: string[] | undefined) {
-    const root = this.shadowRoot
-    if (!root) return
-    if (styles) {
-      styles.forEach(css => {
-        const s = document.createElement('style')
-        s.textContent = css
-        root.appendChild(s)
-        // record for HMR
-        if (__DEV__) {
+  private _applyStyles(
+    styles: string[] | undefined,
+    owner?: ConcreteComponent,
+  ) {
+    if (!styles) return
+    if (owner) {
+      if (owner === this._def || this._styleChildren.has(owner)) {
+        return
+      }
+      this._styleChildren.add(owner)
+    }
+    for (let i = styles.length - 1; i >= 0; i--) {
+      const s = document.createElement('style')
+      s.textContent = styles[i]
+      this.shadowRoot!.prepend(s)
+      // record for HMR
+      if (__DEV__) {
+        if (owner) {
+          if (owner.__hmrId) {
+            if (!this._childStyles) this._childStyles = new Map()
+            let entry = this._childStyles.get(owner.__hmrId)
+            if (!entry) {
+              this._childStyles.set(owner.__hmrId, (entry = []))
+            }
+            entry.push(s)
+          }
+        } else {
           ;(this._styles || (this._styles = [])).push(s)
         }
-      })
+      }
     }
   }
 
@@ -547,6 +577,24 @@ export class VueElement extends BaseClass {
       parent.removeChild(o)
     }
   }
+
+  injectChildStyle(comp: ConcreteComponent & CustomElementOptions) {
+    this._applyStyles(comp.styles, comp)
+  }
+
+  removeChildStlye(comp: ConcreteComponent): void {
+    if (__DEV__) {
+      this._styleChildren.delete(comp)
+      if (this._childStyles && comp.__hmrId) {
+        // clear old styles
+        const oldStyles = this._childStyles.get(comp.__hmrId)
+        if (oldStyles) {
+          oldStyles.forEach(s => this._root.removeChild(s))
+          oldStyles.length = 0
+        }
+      }
+    }
+  }
 }
 
 /**
@@ -557,7 +605,7 @@ export function useShadowRoot(): ShadowRoot | null {
   const instance = getCurrentInstance()
   const el = instance && instance.ce
   if (el) {
-    return el.shadowRoot
+    return (el as VueElement).shadowRoot
   } else if (__DEV__) {
     if (!instance) {
       warn(`useCustomElementRoot called without an active component instance.`)