]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(custom-element): inject child components styles to custom element shadow root...
authorEvan You <evan@vuejs.org>
Mon, 5 Aug 2024 12:49:28 +0000 (20:49 +0800)
committerGitHub <noreply@github.com>
Mon, 5 Aug 2024 12:49:28 +0000 (20:49 +0800)
close #4662
close #7941
close #7942

packages/runtime-core/src/component.ts
packages/runtime-core/src/hmr.ts
packages/runtime-core/src/index.ts
packages/runtime-core/src/renderer.ts
packages/runtime-dom/__tests__/customElement.spec.ts
packages/runtime-dom/src/apiCustomElement.ts

index 713c81487902720bc79440d1031b51747cc1a2db..4863c24a8cc4f98d80b45800a14c34368ac7e7d6 100644 (file)
@@ -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
+}
index e5c16b7077bb3fab3a7d3baba60a4cd1f563614b..6eb0c372c3fdfc2811f25e4f4ed7972a19a25600 100644 (file)
@@ -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
index 27372cfc30382ecc99c58e9db0ad4d8c9b4291b0..34d62762a5cf7ada254889aa0ffde2926624aec8 100644 (file)
@@ -263,6 +263,7 @@ export type {
   GlobalComponents,
   GlobalDirectives,
   ComponentInstance,
+  ComponentCustomElementInterface,
 } from './component'
 export type {
   DefineComponent,
index 466a21a7e516b9ac7f0687d948e549fd2733a9b5..588a58e34ca6aa89b62a4f25203be32cc91ee19f 100644 (file)
@@ -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`)
           }
index 91596b67f1ca14eddf684637ae9620eb4e215211..58de18105488d3a7fcd77717d152c883883de6d1 100644 (file)
@@ -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; }`])
     })
   })
 
index 846774fa66ec1ade5b39270c83d52a0fd7712527..4bc0b2924212e549e69ea7169186e98de93ea0b7 100644 (file)
@@ -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.`)