diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 713c8148790..4863c24a8cc 100644 --- a/packages/runtime-core/src/component.ts +++ b/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 +} diff --git a/packages/runtime-core/src/hmr.ts b/packages/runtime-core/src/hmr.ts index e5c16b7077b..6eb0c372c3f 100644 --- a/packages/runtime-core/src/hmr.ts +++ b/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 diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 27372cfc303..34d62762a5c 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -263,6 +263,7 @@ export type { GlobalComponents, GlobalDirectives, ComponentInstance, + ComponentCustomElementInterface, } from './component' export type { DefineComponent, diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 466a21a7e51..588a58e34ca 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/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`) } diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index 91596b67f1c..58de1810548 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/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 = `` 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 = `` + 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; }`]) }) }) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 846774fa66e..4bc0b292421 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/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 | null = null + private _styleChildren = new WeakSet() + /** + * dev only + */ private _styles?: HTMLStyleElement[] + /** + * dev only + */ + private _childStyles?: Map 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.`)