diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts
index 05c4ac345eb..79965b28a9c 100644
--- a/packages/runtime-core/src/renderer.ts
+++ b/packages/runtime-core/src/renderer.ts
@@ -934,6 +934,10 @@ function baseCreateRenderer(
dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated')
}, parentSuspense)
}
+
+ if (el._isVueCE && el._def.shadowRoot === false) {
+ el._updateSlots(n1, n2)
+ }
}
// The fast path for blocks.
@@ -962,7 +966,7 @@ function baseCreateRenderer(
!isSameVNodeType(oldVNode, newVNode) ||
// - In the case of a component, it could contain anything.
oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
- ? hostParentNode(oldVNode.el)!
+ ? hostParentNode(oldVNode.el) || oldVNode.el.$parentNode
: // In other cases, the parent container is not actually used so we
// just pass the block element here to avoid a DOM parentNode call.
fallbackContainer
diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts
index df438d47eee..5eb234f66d8 100644
--- a/packages/runtime-dom/__tests__/customElement.spec.ts
+++ b/packages/runtime-dom/__tests__/customElement.spec.ts
@@ -5,6 +5,12 @@ import {
Teleport,
type VueElement,
createApp,
+ createBlock,
+ createCommentVNode,
+ createElementBlock,
+ createElementVNode,
+ createSlots,
+ createTextVNode,
defineAsyncComponent,
defineComponent,
defineCustomElement,
@@ -12,12 +18,14 @@ import {
inject,
nextTick,
onMounted,
+ openBlock,
provide,
ref,
render,
renderSlot,
useHost,
useShadowRoot,
+ withCtx,
} from '../src'
declare var __VUE_HMR_RUNTIME__: HMRRuntime
@@ -1131,6 +1139,197 @@ describe('defineCustomElement', () => {
expect(target.innerHTML).toBe(`default`)
app.unmount()
})
+
+ // #13206
+ test('update slotted v-if nodes w/ shadowRoot false (optimized mode)', async () => {
+ const E = defineCustomElement(
+ defineComponent({
+ props: {
+ isShown: { type: Boolean, required: true },
+ },
+ render() {
+ return this.isShown
+ ? h('div', { key: 0 }, [renderSlot(this.$slots, 'default')])
+ : createCommentVNode('v-if')
+ },
+ }),
+ { shadowRoot: false },
+ )
+ customElements.define('ce-shadow-root-false-optimized', E)
+
+ const Comp = defineComponent({
+ props: {
+ isShown: { type: Boolean, required: true },
+ },
+ render() {
+ return h(
+ 'ce-shadow-root-false-optimized',
+ { 'is-shown': this.isShown },
+ [renderSlot(this.$slots, 'default')],
+ )
+ },
+ })
+
+ const isShown = ref(false)
+ const count = ref(0)
+
+ function click() {
+ isShown.value = !isShown.value
+ count.value++
+ }
+
+ const App = {
+ render() {
+ return (
+ openBlock(),
+ createBlock(
+ Comp,
+ { isShown: isShown.value },
+ {
+ default: withCtx(() => [
+ createElementVNode(
+ 'div',
+ null,
+ String(isShown.value),
+ 1 /* TEXT */,
+ ),
+ count.value > 1
+ ? (openBlock(), createElementBlock('div', { key: 0 }, 'hi'))
+ : createCommentVNode('v-if', true),
+ ]),
+ _: 1 /* STABLE */,
+ },
+ 8 /* PROPS */,
+ ['isShown'],
+ )
+ )
+ },
+ }
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ const app = createApp(App)
+ app.mount(container)
+ expect(container.innerHTML).toBe(
+ `` +
+ `` +
+ ``,
+ )
+
+ click()
+ await nextTick()
+ expect(container.innerHTML).toBe(
+ `` +
+ `` +
+ ``,
+ )
+
+ click()
+ await nextTick()
+ expect(container.innerHTML).toBe(
+ `` +
+ `` +
+ ``,
+ )
+
+ click()
+ await nextTick()
+ expect(container.innerHTML).toBe(
+ `` +
+ `` +
+ ``,
+ )
+ })
+
+ // #13234
+ test('switch between slotted and fallback nodes w/ shadowRoot false (optimized mode)', async () => {
+ const E = defineCustomElement(
+ defineComponent({
+ render() {
+ return renderSlot(this.$slots, 'foo', {}, () => [
+ createTextVNode('fallback'),
+ ])
+ },
+ }),
+ { shadowRoot: false },
+ )
+ customElements.define('ce-with-fallback-shadow-root-false-optimized', E)
+
+ const Comp = defineComponent({
+ render() {
+ return (
+ openBlock(),
+ createElementBlock(
+ 'ce-with-fallback-shadow-root-false-optimized',
+ null,
+ [
+ this.$slots.foo
+ ? (openBlock(),
+ createElementBlock('div', { key: 0, slot: 'foo' }, [
+ renderSlot(this.$slots, 'foo'),
+ ]))
+ : createCommentVNode('v-if', true),
+ renderSlot(this.$slots, 'default'),
+ ],
+ )
+ )
+ },
+ })
+
+ const isShown = ref(false)
+ const App = defineComponent({
+ components: { Comp },
+ render() {
+ return (
+ openBlock(),
+ createBlock(
+ Comp,
+ null,
+ createSlots(
+ { _: 2 /* DYNAMIC */ } as any,
+ [
+ isShown.value
+ ? {
+ name: 'foo',
+ fn: withCtx(() => [createTextVNode('foo')]),
+ key: '0',
+ }
+ : undefined,
+ ] as any,
+ ),
+ 1024 /* DYNAMIC_SLOTS */,
+ )
+ )
+ },
+ })
+
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+
+ const app = createApp(App)
+ app.mount(container)
+ expect(container.innerHTML).toBe(
+ `` +
+ `fallback` +
+ ``,
+ )
+
+ isShown.value = true
+ await nextTick()
+ expect(container.innerHTML).toBe(
+ `` +
+ `foo
` +
+ ``,
+ )
+
+ isShown.value = false
+ await nextTick()
+ expect(container.innerHTML).toBe(
+ `` +
+ `fallback` +
+ ``,
+ )
+ })
})
describe('helpers', () => {
diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts
index aeeaeec9b9f..4c5192e9a39 100644
--- a/packages/runtime-dom/src/apiCustomElement.ts
+++ b/packages/runtime-dom/src/apiCustomElement.ts
@@ -19,15 +19,18 @@ import {
type EmitsOptions,
type EmitsToProps,
type ExtractPropTypes,
+ Fragment,
type MethodOptions,
type RenderFunction,
type SetupContext,
type SlotsType,
type VNode,
+ type VNodeArrayChildren,
type VNodeProps,
createVNode,
defineComponent,
getCurrentInstance,
+ isVNode,
nextTick,
unref,
warn,
@@ -241,7 +244,9 @@ export class VueElement
*/
private _childStyles?: Map
private _ob?: MutationObserver | null = null
- private _slots?: Record
+ private _slots?: Record
+ private _slotFallbacks?: Record
+ private _slotAnchors?: Map
constructor(
/**
@@ -332,6 +337,9 @@ export class VueElement
this._app && this._app.unmount()
if (this._instance) this._instance.ce = undefined
this._app = this._instance = null
+ this._slots = undefined
+ this._slotFallbacks = undefined
+ this._slotAnchors = undefined
}
})
}
@@ -526,8 +534,11 @@ export class VueElement
private _createVNode(): VNode {
const baseProps: VNodeProps = {}
if (!this.shadowRoot) {
- baseProps.onVnodeMounted = baseProps.onVnodeUpdated =
- this._renderSlots.bind(this)
+ baseProps.onVnodeMounted = () => {
+ this._parseSlotFallbacks()
+ this._renderSlots()
+ }
+ baseProps.onVnodeUpdated = this._renderSlots.bind(this)
}
const vnode = createVNode(this._def, extend(baseProps, this._props))
if (!this._instance) {
@@ -614,14 +625,19 @@ export class VueElement
/**
* Only called when shadowRoot is false
*/
- private _parseSlots() {
+ private _parseSlots(remove: boolean = true) {
const slots: VueElement['_slots'] = (this._slots = {})
- let n
- while ((n = this.firstChild)) {
+ let n = this.firstChild
+ while (n) {
const slotName =
(n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default'
;(slots[slotName] || (slots[slotName] = [])).push(n)
- this.removeChild(n)
+ const next = n.nextSibling
+ // store the parentNode reference since node will be removed
+ // but it is needed during patching
+ ;(n as any).$parentNode = n.parentNode
+ if (remove) this.removeChild(n)
+ n = next
}
}
@@ -631,11 +647,21 @@ export class VueElement
private _renderSlots() {
const outlets = (this._teleportTarget || this).querySelectorAll('slot')
const scopeId = this._instance!.type.__scopeId
+
for (let i = 0; i < outlets.length; i++) {
const o = outlets[i] as HTMLSlotElement
const slotName = o.getAttribute('name') || 'default'
const content = this._slots![slotName]
const parent = o.parentNode!
+
+ // insert an anchor to facilitate updates
+ const anchor = document.createTextNode('')
+ ;(this._slotAnchors || (this._slotAnchors = new Map())).set(
+ slotName,
+ anchor,
+ )
+ parent.insertBefore(anchor, o)
+
if (content) {
for (const n of content) {
// for :slotted css
@@ -648,15 +674,89 @@ export class VueElement
;(child as Element).setAttribute(id, '')
}
}
- parent.insertBefore(n, o)
+ n.$parentNode = parent
+ parent.insertBefore(n, anchor)
+ }
+ } else if (this._slotFallbacks) {
+ const nodes = this._slotFallbacks[slotName]
+ if (nodes) {
+ for (const n of nodes) {
+ parent.insertBefore(n, anchor)
+ }
}
- } else {
- while (o.firstChild) parent.insertBefore(o.firstChild, o)
}
parent.removeChild(o)
}
}
+ /**
+ * Only called when shadowRoot is false
+ */
+ _updateSlots(n1: VNode, n2: VNode): void {
+ // switch v-if nodes
+ const prevNodes = collectNodes(n1.children as VNodeArrayChildren)
+ const newNodes = collectNodes(n2.children as VNodeArrayChildren)
+ for (let i = 0; i < prevNodes.length; i++) {
+ const prevNode = prevNodes[i]
+ const newNode = newNodes[i]
+ if (isComment(prevNode, 'v-if') || isComment(newNode, 'v-if')) {
+ Object.entries(this._slots!).forEach(([_, nodes]) => {
+ const nodeIndex = nodes.indexOf(prevNode)
+ if (nodeIndex > -1) {
+ nodes[nodeIndex] = newNode
+ }
+ })
+ }
+ }
+
+ // switch between fallback and provided content
+ if (this._slotFallbacks) {
+ const oldSlotNames = Object.keys(this._slots!)
+ // re-parse slots
+ this._parseSlots(false)
+ const newSlotNames = Object.keys(this._slots!)
+ const allSlotNames = new Set([...oldSlotNames, ...newSlotNames])
+ allSlotNames.forEach(name => {
+ const fallbackNodes = this._slotFallbacks![name]
+ if (fallbackNodes) {
+ // render fallback nodes for removed slots
+ if (!newSlotNames.includes(name)) {
+ const anchor = this._slotAnchors!.get(name)!
+ fallbackNodes.forEach(fallbackNode =>
+ this.insertBefore(fallbackNode, anchor),
+ )
+ }
+
+ // remove fallback nodes for added slots
+ if (!oldSlotNames.includes(name)) {
+ fallbackNodes.forEach(fallbackNode =>
+ this.removeChild(fallbackNode),
+ )
+ }
+ }
+ })
+ }
+ }
+
+ /**
+ * Only called when shadowRoot is false
+ */
+ private _parseSlotFallbacks() {
+ const outlets = (this._teleportTarget || this).querySelectorAll('slot')
+ for (let i = 0; i < outlets.length; i++) {
+ const slotElement = outlets[i] as HTMLSlotElement
+ const slotName = slotElement.getAttribute('name') || 'default'
+ const fallbackNodes: Node[] = []
+ while (slotElement.firstChild) {
+ fallbackNodes.push(slotElement.removeChild(slotElement.firstChild))
+ }
+ if (fallbackNodes.length) {
+ ;(this._slotFallbacks || (this._slotFallbacks = {}))[slotName] =
+ fallbackNodes
+ }
+ }
+ }
+
/**
* @internal
*/
@@ -710,3 +810,33 @@ export function useShadowRoot(): ShadowRoot | null {
const el = __DEV__ ? useHost('useShadowRoot') : useHost()
return el && el.shadowRoot
}
+
+function collectFragmentNodes(child: VNode): Node[] {
+ return [
+ child.el as Node,
+ ...collectNodes(child.children as VNodeArrayChildren),
+ child.anchor as Node,
+ ]
+}
+
+function collectNodes(
+ children: VNodeArrayChildren,
+): (Node & { $parentNode?: Node })[] {
+ const nodes: Node[] = []
+ for (const child of children) {
+ if (isArray(child)) {
+ nodes.push(...collectNodes(child))
+ } else if (isVNode(child)) {
+ if (child.type === Fragment) {
+ nodes.push(...collectFragmentNodes(child))
+ } else if (child.el) {
+ nodes.push(child.el as Node)
+ }
+ }
+ }
+ return nodes
+}
+
+function isComment(node: Node, data: string): node is Comment {
+ return node.nodeType === 8 && (node as Comment).data === data
+}