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

close #4662
close #7941
close #7942
This commit is contained in:
Evan You 2024-08-05 20:49:28 +08:00 committed by GitHub
parent b74687c0bb
commit 56c76a8b05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 154 additions and 21 deletions

View 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
}

View 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

View File

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

View 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`)
}

View 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; }`])
})
})

View 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.`)