Skip to content

Commit 37d2ce5

Browse files
committed
feat(custom-element): support shadowRoot: false in defineCustomElement()
close #4314 close #4404
1 parent 267093c commit 37d2ce5

File tree

3 files changed

+185
-35
lines changed

3 files changed

+185
-35
lines changed

packages/runtime-core/src/helpers/renderSlot.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import {
1010
type VNode,
1111
type VNodeArrayChildren,
1212
createBlock,
13+
createVNode,
1314
isVNode,
1415
openBlock,
1516
} from '../vnode'
1617
import { PatchFlags, SlotFlags } from '@vue/shared'
1718
import { warn } from '../warning'
18-
import { createVNode } from '@vue/runtime-core'
1919
import { isAsyncWrapper } from '../apiAsyncComponent'
2020

2121
/**
@@ -37,8 +37,19 @@ export function renderSlot(
3737
isAsyncWrapper(currentRenderingInstance!.parent) &&
3838
currentRenderingInstance!.parent.isCE)
3939
) {
40+
// in custom element mode, render <slot/> as actual slot outlets
41+
// wrap it with a fragment because in shadowRoot: false mode the slot
42+
// element gets replaced by injected content
4043
if (name !== 'default') props.name = name
41-
return createVNode('slot', props, fallback && fallback())
44+
return (
45+
openBlock(),
46+
createBlock(
47+
Fragment,
48+
null,
49+
[createVNode('slot', props, fallback && fallback())],
50+
PatchFlags.STABLE_FRAGMENT,
51+
)
52+
)
4253
}
4354

4455
let slot = slots[name]

packages/runtime-dom/__tests__/customElement.spec.ts

+68-1
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ describe('defineCustomElement', () => {
505505
})
506506
customElements.define('my-el-slots', E)
507507

508-
test('default slot', () => {
508+
test('render slots correctly', () => {
509509
container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
510510
const e = container.childNodes[0] as VueElement
511511
// native slots allocation does not affect innerHTML, so we just
@@ -777,4 +777,71 @@ describe('defineCustomElement', () => {
777777
)
778778
})
779779
})
780+
781+
describe('shadowRoot: false', () => {
782+
const E = defineCustomElement({
783+
shadowRoot: false,
784+
props: {
785+
msg: {
786+
type: String,
787+
default: 'hello',
788+
},
789+
},
790+
render() {
791+
return h('div', this.msg)
792+
},
793+
})
794+
customElements.define('my-el-shadowroot-false', E)
795+
796+
test('should work', async () => {
797+
function raf() {
798+
return new Promise(resolve => {
799+
requestAnimationFrame(resolve)
800+
})
801+
}
802+
803+
container.innerHTML = `<my-el-shadowroot-false></my-el-shadowroot-false>`
804+
const e = container.childNodes[0] as VueElement
805+
await raf()
806+
expect(e).toBeInstanceOf(E)
807+
expect(e._instance).toBeTruthy()
808+
expect(e.innerHTML).toBe(`<div>hello</div>`)
809+
expect(e.shadowRoot).toBe(null)
810+
})
811+
812+
const toggle = ref(true)
813+
const ES = defineCustomElement({
814+
shadowRoot: false,
815+
render() {
816+
return [
817+
renderSlot(this.$slots, 'default'),
818+
toggle.value ? renderSlot(this.$slots, 'named') : null,
819+
renderSlot(this.$slots, 'omitted', {}, () => [h('div', 'fallback')]),
820+
]
821+
},
822+
})
823+
customElements.define('my-el-shadowroot-false-slots', ES)
824+
825+
test('should render slots', async () => {
826+
container.innerHTML =
827+
`<my-el-shadowroot-false-slots>` +
828+
`<span>default</span>text` +
829+
`<div slot="named">named</div>` +
830+
`</my-el-shadowroot-false-slots>`
831+
const e = container.childNodes[0] as VueElement
832+
// native slots allocation does not affect innerHTML, so we just
833+
// verify that we've rendered the correct native slots here...
834+
expect(e.innerHTML).toBe(
835+
`<span>default</span>text` +
836+
`<div slot="named">named</div>` +
837+
`<div>fallback</div>`,
838+
)
839+
840+
toggle.value = false
841+
await nextTick()
842+
expect(e.innerHTML).toBe(
843+
`<span>default</span>text` + `<!---->` + `<div>fallback</div>`,
844+
)
845+
})
846+
})
780847
})

packages/runtime-dom/src/apiCustomElement.ts

+104-32
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type SetupContext,
2222
type SlotsType,
2323
type VNode,
24+
type VNodeProps,
2425
createVNode,
2526
defineComponent,
2627
nextTick,
@@ -33,21 +34,28 @@ export type VueElementConstructor<P = {}> = {
3334
new (initialProps?: Record<string, any>): VueElement & P
3435
}
3536

37+
export interface CustomElementOptions {
38+
styles?: string[]
39+
shadowRoot?: boolean
40+
}
41+
3642
// defineCustomElement provides the same type inference as defineComponent
3743
// so most of the following overloads should be kept in sync w/ defineComponent.
3844

3945
// overload 1: direct setup function
4046
export function defineCustomElement<Props, RawBindings = object>(
4147
setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction,
42-
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> & {
43-
props?: (keyof Props)[]
44-
},
48+
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> &
49+
CustomElementOptions & {
50+
props?: (keyof Props)[]
51+
},
4552
): VueElementConstructor<Props>
4653
export function defineCustomElement<Props, RawBindings = object>(
4754
setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction,
48-
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> & {
49-
props?: ComponentObjectPropsOptions<Props>
50-
},
55+
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> &
56+
CustomElementOptions & {
57+
props?: ComponentObjectPropsOptions<Props>
58+
},
5159
): VueElementConstructor<Props>
5260

5361
// overload 2: defineCustomElement with options object, infer props from options
@@ -81,27 +89,27 @@ export function defineCustomElement<
8189
: { [key in PropsKeys]?: any },
8290
ResolvedProps = InferredProps & EmitsToProps<RuntimeEmitsOptions>,
8391
>(
84-
options: {
92+
options: CustomElementOptions & {
8593
props?: (RuntimePropsOptions & ThisType<void>) | PropsKeys[]
8694
} & ComponentOptionsBase<
87-
ResolvedProps,
88-
SetupBindings,
89-
Data,
90-
Computed,
91-
Methods,
92-
Mixin,
93-
Extends,
94-
RuntimeEmitsOptions,
95-
EmitsKeys,
96-
{}, // Defaults
97-
InjectOptions,
98-
InjectKeys,
99-
Slots,
100-
LocalComponents,
101-
Directives,
102-
Exposed,
103-
Provide
104-
> &
95+
ResolvedProps,
96+
SetupBindings,
97+
Data,
98+
Computed,
99+
Methods,
100+
Mixin,
101+
Extends,
102+
RuntimeEmitsOptions,
103+
EmitsKeys,
104+
{}, // Defaults
105+
InjectOptions,
106+
InjectKeys,
107+
Slots,
108+
LocalComponents,
109+
Directives,
110+
Exposed,
111+
Provide
112+
> &
105113
ThisType<
106114
CreateComponentPublicInstanceWithMixins<
107115
Readonly<ResolvedProps>,
@@ -163,7 +171,7 @@ const BaseClass = (
163171
typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
164172
) as typeof HTMLElement
165173

166-
type InnerComponentDef = ConcreteComponent & { styles?: string[] }
174+
type InnerComponentDef = ConcreteComponent & CustomElementOptions
167175

168176
export class VueElement extends BaseClass {
169177
/**
@@ -176,22 +184,32 @@ export class VueElement extends BaseClass {
176184
private _numberProps: Record<string, true> | null = null
177185
private _styles?: HTMLStyleElement[]
178186
private _ob?: MutationObserver | null = null
187+
private _root: Element | ShadowRoot
188+
private _slots?: Record<string, Node[]>
189+
179190
constructor(
180191
private _def: InnerComponentDef,
181192
private _props: Record<string, any> = {},
182193
hydrate?: RootHydrateFunction,
183194
) {
184195
super()
196+
// TODO handle non-shadowRoot hydration
185197
if (this.shadowRoot && hydrate) {
186198
hydrate(this._createVNode(), this.shadowRoot)
199+
this._root = this.shadowRoot
187200
} else {
188201
if (__DEV__ && this.shadowRoot) {
189202
warn(
190203
`Custom element has pre-rendered declarative shadow root but is not ` +
191204
`defined as hydratable. Use \`defineSSRCustomElement\`.`,
192205
)
193206
}
194-
this.attachShadow({ mode: 'open' })
207+
if (_def.shadowRoot !== false) {
208+
this.attachShadow({ mode: 'open' })
209+
this._root = this.shadowRoot!
210+
} else {
211+
this._root = this
212+
}
195213
if (!(this._def as ComponentOptions).__asyncLoader) {
196214
// for sync component defs we can immediately resolve props
197215
this._resolveProps(this._def)
@@ -200,6 +218,9 @@ export class VueElement extends BaseClass {
200218
}
201219

202220
connectedCallback() {
221+
if (!this.shadowRoot) {
222+
this._parseSlots()
223+
}
203224
this._connected = true
204225
if (!this._instance) {
205226
if (this._resolved) {
@@ -218,7 +239,7 @@ export class VueElement extends BaseClass {
218239
this._ob.disconnect()
219240
this._ob = null
220241
}
221-
render(null, this.shadowRoot!)
242+
render(null, this._root)
222243
this._instance = null
223244
}
224245
})
@@ -353,11 +374,16 @@ export class VueElement extends BaseClass {
353374
}
354375

355376
private _update() {
356-
render(this._createVNode(), this.shadowRoot!)
377+
render(this._createVNode(), this._root)
357378
}
358379

359380
private _createVNode(): VNode<any, any> {
360-
const vnode = createVNode(this._def, extend({}, this._props))
381+
const baseProps: VNodeProps = {}
382+
if (!this.shadowRoot) {
383+
baseProps.onVnodeMounted = baseProps.onVnodeUpdated =
384+
this._renderSlots.bind(this)
385+
}
386+
const vnode = createVNode(this._def, extend(baseProps, this._props))
361387
if (!this._instance) {
362388
vnode.ce = instance => {
363389
this._instance = instance
@@ -367,7 +393,7 @@ export class VueElement extends BaseClass {
367393
instance.ceReload = newStyles => {
368394
// always reset styles
369395
if (this._styles) {
370-
this._styles.forEach(s => this.shadowRoot!.removeChild(s))
396+
this._styles.forEach(s => this._root.removeChild(s))
371397
this._styles.length = 0
372398
}
373399
this._applyStyles(newStyles)
@@ -416,12 +442,58 @@ export class VueElement extends BaseClass {
416442
styles.forEach(css => {
417443
const s = document.createElement('style')
418444
s.textContent = css
419-
this.shadowRoot!.appendChild(s)
445+
this._root.appendChild(s)
420446
// record for HMR
421447
if (__DEV__) {
422448
;(this._styles || (this._styles = [])).push(s)
423449
}
424450
})
425451
}
426452
}
453+
454+
/**
455+
* Only called when shaddowRoot is false
456+
*/
457+
private _parseSlots() {
458+
const slots: VueElement['_slots'] = (this._slots = {})
459+
let n
460+
while ((n = this.firstChild)) {
461+
const slotName =
462+
(n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default'
463+
;(slots[slotName] || (slots[slotName] = [])).push(n)
464+
this.removeChild(n)
465+
}
466+
}
467+
468+
/**
469+
* Only called when shaddowRoot is false
470+
*/
471+
private _renderSlots() {
472+
const outlets = this.querySelectorAll('slot')
473+
const scopeId = this._instance!.type.__scopeId
474+
for (let i = 0; i < outlets.length; i++) {
475+
const o = outlets[i] as HTMLSlotElement
476+
const slotName = o.getAttribute('name') || 'default'
477+
const content = this._slots![slotName]
478+
const parent = o.parentNode!
479+
if (content) {
480+
for (const n of content) {
481+
// for :slotted css
482+
if (scopeId && n.nodeType === 1) {
483+
const id = scopeId + '-s'
484+
const walker = document.createTreeWalker(n, 1)
485+
;(n as Element).setAttribute(id, '')
486+
let child
487+
while ((child = walker.nextNode())) {
488+
;(child as Element).setAttribute(id, '')
489+
}
490+
}
491+
parent.insertBefore(n, o)
492+
}
493+
} else {
494+
while (o.firstChild) parent.insertBefore(o.firstChild, o)
495+
}
496+
parent.removeChild(o)
497+
}
498+
}
427499
}

0 commit comments

Comments
 (0)