mirror of https://github.com/vuejs/core.git
feat(custom-element): inject child components styles to custom element shadow root (#11517)
close #4662 close #7941 close #7942
This commit is contained in:
parent
b74687c0bb
commit
56c76a8b05
|
@ -417,7 +417,7 @@ export interface ComponentInternalInstance {
|
||||||
* is custom element?
|
* is custom element?
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
ce?: Element
|
ce?: ComponentCustomElementInterface
|
||||||
/**
|
/**
|
||||||
* custom element specific HMR method
|
* custom element specific HMR method
|
||||||
* @internal
|
* @internal
|
||||||
|
@ -1237,3 +1237,8 @@ export function formatComponentName(
|
||||||
export function isClassComponent(value: unknown): value is ClassComponent {
|
export function isClassComponent(value: unknown): value is ClassComponent {
|
||||||
return isFunction(value) && '__vccOpts' in value
|
return isFunction(value) && '__vccOpts' in value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComponentCustomElementInterface {
|
||||||
|
injectChildStyle(type: ConcreteComponent): void
|
||||||
|
removeChildStlye(type: ConcreteComponent): void
|
||||||
|
}
|
||||||
|
|
|
@ -159,6 +159,11 @@ function reload(id: string, newComp: HMRComponent) {
|
||||||
'[HMR] Root or manually mounted instance modified. Full reload required.',
|
'[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
|
// 5. make sure to cleanup dirty hmr components after update
|
||||||
|
|
|
@ -263,6 +263,7 @@ export type {
|
||||||
GlobalComponents,
|
GlobalComponents,
|
||||||
GlobalDirectives,
|
GlobalDirectives,
|
||||||
ComponentInstance,
|
ComponentInstance,
|
||||||
|
ComponentCustomElementInterface,
|
||||||
} from './component'
|
} from './component'
|
||||||
export type {
|
export type {
|
||||||
DefineComponent,
|
DefineComponent,
|
||||||
|
|
|
@ -1276,8 +1276,8 @@ function baseCreateRenderer(
|
||||||
const componentUpdateFn = () => {
|
const componentUpdateFn = () => {
|
||||||
if (!instance.isMounted) {
|
if (!instance.isMounted) {
|
||||||
let vnodeHook: VNodeHook | null | undefined
|
let vnodeHook: VNodeHook | null | undefined
|
||||||
const { el, props, type } = initialVNode
|
const { el, props } = initialVNode
|
||||||
const { bm, m, parent } = instance
|
const { bm, m, parent, root, type } = instance
|
||||||
const isAsyncWrapperVNode = isAsyncWrapper(initialVNode)
|
const isAsyncWrapperVNode = isAsyncWrapper(initialVNode)
|
||||||
|
|
||||||
toggleRecurse(instance, false)
|
toggleRecurse(instance, false)
|
||||||
|
@ -1335,6 +1335,11 @@ function baseCreateRenderer(
|
||||||
hydrateSubTree()
|
hydrateSubTree()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// custom element style injection
|
||||||
|
if (root.ce) {
|
||||||
|
root.ce.injectChildStyle(type)
|
||||||
|
}
|
||||||
|
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
startMeasure(instance, `render`)
|
startMeasure(instance, `render`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import type { MockedFunction } from 'vitest'
|
import type { MockedFunction } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
type HMRRuntime,
|
||||||
type Ref,
|
type Ref,
|
||||||
type VueElement,
|
type VueElement,
|
||||||
createApp,
|
createApp,
|
||||||
|
@ -15,6 +16,8 @@ import {
|
||||||
useShadowRoot,
|
useShadowRoot,
|
||||||
} from '../src'
|
} from '../src'
|
||||||
|
|
||||||
|
declare var __VUE_HMR_RUNTIME__: HMRRuntime
|
||||||
|
|
||||||
describe('defineCustomElement', () => {
|
describe('defineCustomElement', () => {
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container)
|
||||||
|
@ -636,18 +639,84 @@ describe('defineCustomElement', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('styles', () => {
|
describe('styles', () => {
|
||||||
test('should attach styles to shadow dom', () => {
|
function assertStyles(el: VueElement, css: string[]) {
|
||||||
const Foo = defineCustomElement({
|
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; }`],
|
styles: [`div { color: red; }`],
|
||||||
render() {
|
render() {
|
||||||
return h('div', 'hello')
|
return h('div', 'hello')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const Foo = defineCustomElement(def)
|
||||||
customElements.define('my-el-with-styles', Foo)
|
customElements.define('my-el-with-styles', Foo)
|
||||||
container.innerHTML = `<my-el-with-styles></my-el-with-styles>`
|
container.innerHTML = `<my-el-with-styles></my-el-with-styles>`
|
||||||
const el = container.childNodes[0] as VueElement
|
const el = container.childNodes[0] as VueElement
|
||||||
const style = el.shadowRoot?.querySelector('style')!
|
const style = el.shadowRoot?.querySelector('style')!
|
||||||
expect(style.textContent).toBe(`div { color: red; }`)
|
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; }`])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
|
type ComponentCustomElementInterface,
|
||||||
type ComponentInjectOptions,
|
type ComponentInjectOptions,
|
||||||
type ComponentInternalInstance,
|
type ComponentInternalInstance,
|
||||||
type ComponentObjectPropsOptions,
|
type ComponentObjectPropsOptions,
|
||||||
|
@ -189,7 +190,10 @@ const BaseClass = (
|
||||||
|
|
||||||
type InnerComponentDef = ConcreteComponent & CustomElementOptions
|
type InnerComponentDef = ConcreteComponent & CustomElementOptions
|
||||||
|
|
||||||
export class VueElement extends BaseClass {
|
export class VueElement
|
||||||
|
extends BaseClass
|
||||||
|
implements ComponentCustomElementInterface
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
|
@ -198,7 +202,15 @@ export class VueElement extends BaseClass {
|
||||||
private _connected = false
|
private _connected = false
|
||||||
private _resolved = false
|
private _resolved = false
|
||||||
private _numberProps: Record<string, true> | null = null
|
private _numberProps: Record<string, true> | null = null
|
||||||
|
private _styleChildren = new WeakSet()
|
||||||
|
/**
|
||||||
|
* dev only
|
||||||
|
*/
|
||||||
private _styles?: HTMLStyleElement[]
|
private _styles?: HTMLStyleElement[]
|
||||||
|
/**
|
||||||
|
* dev only
|
||||||
|
*/
|
||||||
|
private _childStyles?: Map<string, HTMLStyleElement[]>
|
||||||
private _ob?: MutationObserver | null = null
|
private _ob?: MutationObserver | null = null
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
|
@ -312,13 +324,14 @@ export class VueElement extends BaseClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply CSS
|
// apply CSS
|
||||||
if (__DEV__ && styles && def.shadowRoot === false) {
|
if (this.shadowRoot) {
|
||||||
|
this._applyStyles(styles)
|
||||||
|
} else if (__DEV__ && styles) {
|
||||||
warn(
|
warn(
|
||||||
'Custom element style injection is not supported when using ' +
|
'Custom element style injection is not supported when using ' +
|
||||||
'shadowRoot: false',
|
'shadowRoot: false',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this._applyStyles(styles)
|
|
||||||
|
|
||||||
// initial render
|
// initial render
|
||||||
this._update()
|
this._update()
|
||||||
|
@ -329,7 +342,7 @@ export class VueElement extends BaseClass {
|
||||||
|
|
||||||
const asyncDef = (this._def as ComponentOptions).__asyncLoader
|
const asyncDef = (this._def as ComponentOptions).__asyncLoader
|
||||||
if (asyncDef) {
|
if (asyncDef) {
|
||||||
asyncDef().then(def => resolve(def, true))
|
asyncDef().then(def => resolve((this._def = def), true))
|
||||||
} else {
|
} else {
|
||||||
resolve(this._def)
|
resolve(this._def)
|
||||||
}
|
}
|
||||||
|
@ -486,19 +499,36 @@ export class VueElement extends BaseClass {
|
||||||
return vnode
|
return vnode
|
||||||
}
|
}
|
||||||
|
|
||||||
private _applyStyles(styles: string[] | undefined) {
|
private _applyStyles(
|
||||||
const root = this.shadowRoot
|
styles: string[] | undefined,
|
||||||
if (!root) return
|
owner?: ConcreteComponent,
|
||||||
if (styles) {
|
) {
|
||||||
styles.forEach(css => {
|
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')
|
const s = document.createElement('style')
|
||||||
s.textContent = css
|
s.textContent = styles[i]
|
||||||
root.appendChild(s)
|
this.shadowRoot!.prepend(s)
|
||||||
// record for HMR
|
// record for HMR
|
||||||
if (__DEV__) {
|
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)
|
;(this._styles || (this._styles = [])).push(s)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -547,6 +577,24 @@ export class VueElement extends BaseClass {
|
||||||
parent.removeChild(o)
|
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 instance = getCurrentInstance()
|
||||||
const el = instance && instance.ce
|
const el = instance && instance.ce
|
||||||
if (el) {
|
if (el) {
|
||||||
return el.shadowRoot
|
return (el as VueElement).shadowRoot
|
||||||
} else if (__DEV__) {
|
} else if (__DEV__) {
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
warn(`useCustomElementRoot called without an active component instance.`)
|
warn(`useCustomElementRoot called without an active component instance.`)
|
||||||
|
|
Loading…
Reference in New Issue