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( + `` + + `
true
` + + `
`, + ) + + click() + await nextTick() + expect(container.innerHTML).toBe( + `` + + `` + + ``, + ) + + click() + await nextTick() + expect(container.innerHTML).toBe( + `` + + `
true
hi
` + + `
`, + ) + }) + + // #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 +}