From 8e5a43842b5dc068860a42e867094ec65bed9bff Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 16 Apr 2025 11:34:33 +0800 Subject: [PATCH 1/9] fix(custom-element): update slot nodes when shadowRoot is false --- .../runtime-core/src/helpers/renderSlot.ts | 1 + packages/runtime-core/src/renderer.ts | 4 +++ packages/runtime-core/src/vnode.ts | 5 ++++ packages/runtime-dom/src/apiCustomElement.ts | 30 +++++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/packages/runtime-core/src/helpers/renderSlot.ts b/packages/runtime-core/src/helpers/renderSlot.ts index 92f7dab36b6..aec49d1eaea 100644 --- a/packages/runtime-core/src/helpers/renderSlot.ts +++ b/packages/runtime-core/src/helpers/renderSlot.ts @@ -96,6 +96,7 @@ export function renderSlot( if (slot && (slot as ContextualRenderFn)._c) { ;(slot as ContextualRenderFn)._d = true } + rendered.slotName = name return rendered } diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 05c4ac345eb..9494dac09d8 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) { + el._updateSlots(n2.children) + } } // The fast path for blocks. diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index a8c5340cd1f..a146cdc162a 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -253,6 +253,10 @@ export interface VNode< * @internal custom element interception hook */ ce?: (instance: ComponentInternalInstance) => void + /** + * @internal + */ + slotName?: string } // Since v-if and v-for are the two possible ways node structure can dynamically @@ -715,6 +719,7 @@ export function cloneVNode( anchor: vnode.anchor, ctx: vnode.ctx, ce: vnode.ce, + slotName: vnode.slotName, } // if the vnode will be replaced by the cloned one, it is necessary diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index aeeaeec9b9f..5cab16ad679 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, @@ -657,6 +660,17 @@ export class VueElement } } + /** + * @internal + */ + _updateSlots(children: VNode[]): void { + children.forEach(child => { + this._slots![child.slotName!] = collectElements( + child.children as VNodeArrayChildren, + ) + }) + } + /** * @internal */ @@ -710,3 +724,19 @@ export function useShadowRoot(): ShadowRoot | null { const el = __DEV__ ? useHost('useShadowRoot') : useHost() return el && el.shadowRoot } + +function collectElements(children: VNodeArrayChildren): Node[] { + const nodes: Node[] = [] + for (const vnode of children) { + if (isArray(vnode)) { + nodes.push(...collectElements(vnode)) + } else if (isVNode(vnode)) { + if (vnode.type === Fragment) { + nodes.push(...collectElements(vnode.children as VNodeArrayChildren)) + } else if (vnode.el) { + nodes.push(vnode.el as Node) + } + } + } + return nodes +} From 68b20010559f56f26ba4f596005ab2c262dcacd4 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 16 Apr 2025 11:40:12 +0800 Subject: [PATCH 2/9] wip: update --- packages/runtime-core/src/renderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 9494dac09d8..a4f533c4879 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -935,7 +935,7 @@ function baseCreateRenderer( }, parentSuspense) } - if (el._isVueCE && !el._def.shadowRoot) { + if (el._isVueCE && el._def.shadowRoot === false) { el._updateSlots(n2.children) } } From 1a5ea558a5b25ef0218c5e6b3ba352eb8bd12508 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 16 Apr 2025 21:28:36 +0800 Subject: [PATCH 3/9] test: add tests --- .../__tests__/customElement.spec.ts | 92 +++++++++++++++++++ packages/runtime-dom/src/apiCustomElement.ts | 29 +++--- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index df438d47eee..fa14f6d48f7 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -5,6 +5,10 @@ import { Teleport, type VueElement, createApp, + createBlock, + createCommentVNode, + createElementBlock, + createElementVNode, defineAsyncComponent, defineComponent, defineCustomElement, @@ -12,12 +16,14 @@ import { inject, nextTick, onMounted, + openBlock, provide, ref, render, renderSlot, useHost, useShadowRoot, + withCtx, } from '../src' declare var __VUE_HMR_RUNTIME__: HMRRuntime @@ -1131,6 +1137,92 @@ describe('defineCustomElement', () => { expect(target.innerHTML).toBe(`default`) app.unmount() }) + + //#13206 + test('should update slotted children correctly w/ shadowRoot false', async () => { + const E = defineCustomElement( + defineComponent({ + props: { + isShown: { type: Boolean, required: true }, + }, + render() { + return this.isShown + ? h('div', { key: 0 }, [renderSlot(this.$slots, 'default')]) + : null + }, + }), + { shadowRoot: false }, + ) + customElements.define('ce-shadow-root-false', E) + + const Comp = defineComponent({ + props: { + isShown: { type: Boolean, required: true }, + }, + render() { + return h('ce-shadow-root-false', { '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, 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
`, + ) + }) }) describe('helpers', () => { diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 5cab16ad679..ebd75b252ad 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -665,9 +665,8 @@ export class VueElement */ _updateSlots(children: VNode[]): void { children.forEach(child => { - this._slots![child.slotName!] = collectElements( - child.children as VNodeArrayChildren, - ) + // slot children are always Fragments + this._slots![child.slotName!] = collectFragmentElements(child) }) } @@ -725,16 +724,24 @@ export function useShadowRoot(): ShadowRoot | null { return el && el.shadowRoot } +function collectFragmentElements(child: VNode): Node[] { + return [ + child.el as Node, + ...collectElements(child.children as VNodeArrayChildren), + child.anchor as Node, + ] +} + function collectElements(children: VNodeArrayChildren): Node[] { const nodes: Node[] = [] - for (const vnode of children) { - if (isArray(vnode)) { - nodes.push(...collectElements(vnode)) - } else if (isVNode(vnode)) { - if (vnode.type === Fragment) { - nodes.push(...collectElements(vnode.children as VNodeArrayChildren)) - } else if (vnode.el) { - nodes.push(vnode.el as Node) + for (const child of children) { + if (isArray(child)) { + nodes.push(...collectElements(child)) + } else if (isVNode(child)) { + if (child.type === Fragment) { + nodes.push(...collectFragmentElements(child)) + } else if (child.el) { + nodes.push(child.el as Node) } } } From 9f51f1303b20f58a0b132dc77ab8f119ee50c20f Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 8 May 2025 11:58:42 +0800 Subject: [PATCH 4/9] wip: save --- .../runtime-core/src/helpers/renderSlot.ts | 1 - packages/runtime-core/src/renderer.ts | 6 +- packages/runtime-core/src/vnode.ts | 5 - packages/runtime-dom/src/apiCustomElement.ts | 129 +++++++++++++++--- 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/packages/runtime-core/src/helpers/renderSlot.ts b/packages/runtime-core/src/helpers/renderSlot.ts index aec49d1eaea..92f7dab36b6 100644 --- a/packages/runtime-core/src/helpers/renderSlot.ts +++ b/packages/runtime-core/src/helpers/renderSlot.ts @@ -96,7 +96,6 @@ export function renderSlot( if (slot && (slot as ContextualRenderFn)._c) { ;(slot as ContextualRenderFn)._d = true } - rendered.slotName = name return rendered } diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index a4f533c4879..e77f624c4f6 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -936,7 +936,7 @@ function baseCreateRenderer( } if (el._isVueCE && el._def.shadowRoot === false) { - el._updateSlots(n2.children) + el._updateSlots(n1, n2) } } @@ -966,7 +966,9 @@ function baseCreateRenderer( !isSameVNodeType(oldVNode, newVNode) || // - In the case of a component, it could contain anything. oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT)) - ? hostParentNode(oldVNode.el)! + ? oldVNode.el._parentNode && !oldVNode.el.isConnected + ? oldVNode.el._parentNode + : hostParentNode(oldVNode.el)! : // 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-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index a146cdc162a..a8c5340cd1f 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -253,10 +253,6 @@ export interface VNode< * @internal custom element interception hook */ ce?: (instance: ComponentInternalInstance) => void - /** - * @internal - */ - slotName?: string } // Since v-if and v-for are the two possible ways node structure can dynamically @@ -719,7 +715,6 @@ export function cloneVNode( anchor: vnode.anchor, ctx: vnode.ctx, ce: vnode.ce, - slotName: vnode.slotName, } // if the vnode will be replaced by the cloned one, it is necessary diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index ebd75b252ad..41d4dc50307 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -245,6 +245,8 @@ export class VueElement private _childStyles?: Map private _ob?: MutationObserver | null = null private _slots?: Record + private _slotFallbacks?: Record + private _slotAnchors?: Map constructor( /** @@ -529,8 +531,11 @@ export class VueElement private _createVNode(): VNode { const baseProps: VNodeProps = {} if (!this.shadowRoot) { - baseProps.onVnodeMounted = baseProps.onVnodeUpdated = - this._renderSlots.bind(this) + baseProps.onVnodeMounted = () => { + this._captureSlotFallbacks() + this._renderSlots() + } + baseProps.onVnodeUpdated = this._renderSlots.bind(this) } const vnode = createVNode(this._def, extend(baseProps, this._props)) if (!this._instance) { @@ -617,14 +622,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 } } @@ -634,11 +644,18 @@ export class VueElement private _renderSlots() { const outlets = (this._teleportTarget || this).querySelectorAll('slot') const scopeId = this._instance!.type.__scopeId + this._slotAnchors = new Map() 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.set(slotName, anchor) + parent.insertBefore(anchor, o) + if (content) { for (const n of content) { // for :slotted css @@ -651,23 +668,91 @@ export class VueElement ;(child as Element).setAttribute(id, '') } } - parent.insertBefore(n, o) + 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) } } /** - * @internal + * Only called when shadowRoot is false */ - _updateSlots(children: VNode[]): void { - children.forEach(child => { - // slot children are always Fragments - this._slots![child.slotName!] = collectFragmentElements(child) - }) + _updateSlots(n1: VNode, n2: VNode): void { + // replace 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.keys(this._slots!).forEach(name => { + const slotNodes = this._slots![name] + if (slotNodes) { + for (const node of slotNodes) { + if (node === prevNode) { + this._slots![name][i] = newNode + break + } + } + } + }) + } + } + + // 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 _captureSlotFallbacks() { + 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 + } + } } /** @@ -724,22 +809,22 @@ export function useShadowRoot(): ShadowRoot | null { return el && el.shadowRoot } -function collectFragmentElements(child: VNode): Node[] { +function collectFragmentNodes(child: VNode): Node[] { return [ child.el as Node, - ...collectElements(child.children as VNodeArrayChildren), + ...collectNodes(child.children as VNodeArrayChildren), child.anchor as Node, ] } -function collectElements(children: VNodeArrayChildren): Node[] { +function collectNodes(children: VNodeArrayChildren): Node[] { const nodes: Node[] = [] for (const child of children) { if (isArray(child)) { - nodes.push(...collectElements(child)) + nodes.push(...collectNodes(child)) } else if (isVNode(child)) { if (child.type === Fragment) { - nodes.push(...collectFragmentElements(child)) + nodes.push(...collectFragmentNodes(child)) } else if (child.el) { nodes.push(child.el as Node) } @@ -747,3 +832,7 @@ function collectElements(children: VNodeArrayChildren): Node[] { } return nodes } + +function isComment(node: Node, data: string): node is Comment { + return node.nodeType === 8 && (node as Comment).data === data +} From 6a3e771dce7127fc8c749989aafa6f7c69d87efa Mon Sep 17 00:00:00 2001 From: daiwei Date: Thu, 8 May 2025 17:13:54 +0800 Subject: [PATCH 5/9] test: add test --- packages/runtime-core/src/renderer.ts | 4 +- .../__tests__/customElement.spec.ts | 98 ++++++++++++++++++- packages/runtime-dom/src/apiCustomElement.ts | 8 +- 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index e77f624c4f6..d75efb890cd 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -966,9 +966,7 @@ function baseCreateRenderer( !isSameVNodeType(oldVNode, newVNode) || // - In the case of a component, it could contain anything. oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT)) - ? oldVNode.el._parentNode && !oldVNode.el.isConnected - ? oldVNode.el._parentNode - : 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 fa14f6d48f7..85f1d0820aa 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -9,6 +9,8 @@ import { createCommentVNode, createElementBlock, createElementVNode, + createSlots, + createTextVNode, defineAsyncComponent, defineComponent, defineCustomElement, @@ -1138,8 +1140,8 @@ describe('defineCustomElement', () => { app.unmount() }) - //#13206 - test('should update slotted children correctly w/ shadowRoot false', async () => { + // #13206 + test('update slotted v-if nodes w/ shadowRoot false', async () => { const E = defineCustomElement( defineComponent({ props: { @@ -1148,7 +1150,7 @@ describe('defineCustomElement', () => { render() { return this.isShown ? h('div', { key: 0 }, [renderSlot(this.$slots, 'default')]) - : null + : createCommentVNode('v-if') }, }), { shadowRoot: false }, @@ -1202,7 +1204,7 @@ describe('defineCustomElement', () => { const app = createApp(App) app.mount(container) expect(container.innerHTML).toBe( - ``, + ``, ) click() @@ -1214,7 +1216,7 @@ describe('defineCustomElement', () => { click() await nextTick() expect(container.innerHTML).toBe( - ``, + ``, ) click() @@ -1223,6 +1225,92 @@ describe('defineCustomElement', () => { `
true
hi
`, ) }) + + // #13234 + test('switch between slotted and fallback nodes w/ shadowRoot false', async () => { + const E = defineCustomElement( + defineComponent({ + render() { + return renderSlot(this.$slots, 'foo', {}, () => [ + createTextVNode('fallback'), + ]) + }, + }), + { shadowRoot: false }, + ) + customElements.define('ce-with-fallback-shadow-root-false', E) + + const Comp = defineComponent({ + render() { + return ( + openBlock(), + createElementBlock('ce-with-fallback-shadow-root-false', 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 41d4dc50307..c5f3c9e341c 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -337,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 } }) } @@ -692,7 +695,10 @@ export class VueElement 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')) { + if ( + prevNode !== newNode && + (isComment(prevNode, 'v-if') || isComment(newNode, 'v-if')) + ) { Object.keys(this._slots!).forEach(name => { const slotNodes = this._slots![name] if (slotNodes) { From aec2dfb59d4f5d0cfb7f9e74cb52d199f27499d6 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 9 May 2025 16:54:54 +0800 Subject: [PATCH 6/9] wip: save --- packages/runtime-core/src/renderer.ts | 2 +- .../__tests__/customElement.spec.ts | 228 ++++++++++++++++-- packages/runtime-dom/src/apiCustomElement.ts | 67 +++-- 3 files changed, 247 insertions(+), 50 deletions(-) diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index d75efb890cd..79965b28a9c 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -966,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) || oldVNode.el._parentNode + ? 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 85f1d0820aa..02522fbd92b 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -1141,7 +1141,7 @@ describe('defineCustomElement', () => { }) // #13206 - test('update slotted v-if nodes w/ shadowRoot false', async () => { + test('update slotted v-if nodes w/ shadowRoot false (optimized mode)', async () => { const E = defineCustomElement( defineComponent({ props: { @@ -1155,16 +1155,18 @@ describe('defineCustomElement', () => { }), { shadowRoot: false }, ) - customElements.define('ce-shadow-root-false', E) + customElements.define('ce-shadow-root-false-optimized', E) const Comp = defineComponent({ props: { isShown: { type: Boolean, required: true }, }, render() { - return h('ce-shadow-root-false', { 'is-shown': this.isShown }, [ - renderSlot(this.$slots, 'default'), - ]) + return h( + 'ce-shadow-root-false-optimized', + { 'is-shown': this.isShown }, + [renderSlot(this.$slots, 'default')], + ) }, }) @@ -1185,7 +1187,12 @@ describe('defineCustomElement', () => { { isShown: isShown.value }, { default: withCtx(() => [ - createElementVNode('div', null, isShown.value, 1 /* TEXT */), + createElementVNode( + 'div', + null, + String(isShown.value), + 1 /* TEXT */, + ), count.value > 1 ? (openBlock(), createElementBlock('div', { key: 0 }, 'hi')) : createCommentVNode('v-if', true), @@ -1204,7 +1211,94 @@ describe('defineCustomElement', () => { const app = createApp(App) app.mount(container) expect(container.innerHTML).toBe( - ``, + `` + + `
false
` + + `
`, + ) + + click() + await nextTick() + expect(container.innerHTML).toBe( + `` + + `
true
` + + `
`, + ) + + click() + await nextTick() + expect(container.innerHTML).toBe( + `` + + `
false
` + + `
`, + ) + + click() + await nextTick() + expect(container.innerHTML).toBe( + `` + + `
true
hi
` + + `
`, + ) + }) + + test.todo('update slotted v-if nodes w/ shadowRoot false', 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', E) + + const Comp = defineComponent({ + props: { + isShown: { type: Boolean, required: true }, + }, + render() { + return h('ce-shadow-root-false', { '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 h( + Comp, + { isShown: isShown.value }, + { + default: () => [ + h('div', null, String(isShown.value)), + count.value > 1 + ? h('div', { key: 0 }, 'hi') + : createCommentVNode('v-if', true), + ], + }, + ) + }, + } + const container = document.createElement('div') + document.body.appendChild(container) + + const app = createApp(App) + app.mount(container) + expect(container.innerHTML).toBe( + `
false
`, ) click() @@ -1216,7 +1310,7 @@ describe('defineCustomElement', () => { click() await nextTick() expect(container.innerHTML).toBe( - ``, + `
false
`, ) click() @@ -1227,7 +1321,7 @@ describe('defineCustomElement', () => { }) // #13234 - test('switch between slotted and fallback nodes w/ shadowRoot false', async () => { + test('switch between slotted and fallback nodes w/ shadowRoot false (optimized mode)', async () => { const E = defineCustomElement( defineComponent({ render() { @@ -1238,21 +1332,25 @@ describe('defineCustomElement', () => { }), { shadowRoot: false }, ) - customElements.define('ce-with-fallback-shadow-root-false', E) + customElements.define('ce-with-fallback-shadow-root-false-optimized', E) const Comp = defineComponent({ render() { return ( openBlock(), - createElementBlock('ce-with-fallback-shadow-root-false', null, [ - this.$slots.foo - ? (openBlock(), - createElementBlock('div', { key: 0, slot: 'foo' }, [ - renderSlot(this.$slots, 'foo'), - ])) - : createCommentVNode('v-if', true), - renderSlot(this.$slots, 'default'), - ]) + 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'), + ], + ) ) }, }) @@ -1290,27 +1388,107 @@ describe('defineCustomElement', () => { 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` + - ``, + ``, ) }) + + test.todo( + 'switch between slotted and fallback nodes w/ shadowRoot false', + async () => { + const E = defineCustomElement( + defineComponent({ + render() { + return renderSlot(this.$slots, 'foo', {}, () => [ + createTextVNode('fallback'), + ]) + }, + }), + { shadowRoot: false }, + ) + customElements.define('ce-with-fallback-shadow-root-false', E) + + const Comp = defineComponent({ + render() { + return h('ce-with-fallback-shadow-root-false', null, [ + this.$slots.foo + ? h('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 h( + Comp, + null, + createSlots( + { _: 2 /* DYNAMIC */ } as any, + [ + isShown.value + ? { + name: 'foo', + fn: withCtx(() => [createTextVNode('foo')]), + key: '0', + } + : undefined, + ] as any, + ), + ) + }, + }) + + 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 c5f3c9e341c..a6a4b1ba1e0 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -635,7 +635,7 @@ export class VueElement 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 + ;(n as any).$parentNode = n.parentNode if (remove) this.removeChild(n) n = next } @@ -648,9 +648,12 @@ export class VueElement const outlets = (this._teleportTarget || this).querySelectorAll('slot') const scopeId = this._instance!.type.__scopeId this._slotAnchors = new Map() + const processedSlots = new Set() + for (let i = 0; i < outlets.length; i++) { const o = outlets[i] as HTMLSlotElement const slotName = o.getAttribute('name') || 'default' + processedSlots.add(slotName) const content = this._slots![slotName] const parent = o.parentNode! @@ -660,19 +663,7 @@ export class VueElement parent.insertBefore(anchor, o) if (content) { - for (const n of content) { - // for :slotted css - if (scopeId && n.nodeType === 1) { - const id = scopeId + '-s' - const walker = document.createTreeWalker(n, 1) - ;(n as Element).setAttribute(id, '') - let child - while ((child = walker.nextNode())) { - ;(child as Element).setAttribute(id, '') - } - } - parent.insertBefore(n, anchor) - } + insertSlottedContent(content, scopeId, parent, anchor) } else if (this._slotFallbacks) { const nodes = this._slotFallbacks[slotName] if (nodes) { @@ -683,13 +674,25 @@ export class VueElement } parent.removeChild(o) } + + // ensure default slot content is rendered if provided + if (!processedSlots.has('default')) { + let content = this._slots!['default'] + if (content) { + // TODO + content = content.filter( + n => !(n.nodeType === 8 && (n as Comment).data === 'v-if'), + ) + insertSlottedContent(content, scopeId, this, this.firstChild) + } + } } /** * Only called when shadowRoot is false */ _updateSlots(n1: VNode, n2: VNode): void { - // replace v-if nodes + // 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++) { @@ -699,15 +702,10 @@ export class VueElement prevNode !== newNode && (isComment(prevNode, 'v-if') || isComment(newNode, 'v-if')) ) { - Object.keys(this._slots!).forEach(name => { - const slotNodes = this._slots![name] - if (slotNodes) { - for (const node of slotNodes) { - if (node === prevNode) { - this._slots![name][i] = newNode - break - } - } + Object.entries(this._slots!).forEach(([_, nodes]) => { + const nodeIndex = nodes.indexOf(prevNode) + if (nodeIndex > -1) { + nodes[nodeIndex] = newNode } }) } @@ -815,6 +813,27 @@ export function useShadowRoot(): ShadowRoot | null { return el && el.shadowRoot } +function insertSlottedContent( + content: Node[], + scopeId: string | undefined, + parent: ParentNode, + anchor: Node | null, +) { + for (const n of content) { + // for :slotted css + if (scopeId && n.nodeType === 1) { + const id = scopeId + '-s' + const walker = document.createTreeWalker(n, 1) + ;(n as Element).setAttribute(id, '') + let child + while ((child = walker.nextNode())) { + ;(child as Element).setAttribute(id, '') + } + } + parent.insertBefore(n, anchor) + } +} + function collectFragmentNodes(child: VNode): Node[] { return [ child.el as Node, From 2d7e5afc2e4d40d2a452ac83e747ceff36678d68 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 10 May 2025 22:18:11 +0800 Subject: [PATCH 7/9] wip: save --- packages/runtime-core/src/renderer.ts | 14 +- .../__tests__/customElement.spec.ts | 173 +++++++++--------- packages/runtime-dom/src/apiCustomElement.ts | 38 ++-- 3 files changed, 129 insertions(+), 96 deletions(-) diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 79965b28a9c..d1278bda31f 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -711,6 +711,18 @@ function baseCreateRenderer( if (needCallTransitionHooks) { transition!.beforeEnter(el) } + + // For custom element with shadowRoot: false, the anchor node may be moved + // to the slot container. In this case, it need to use the anchor's parent + // node as the actual container. + if ( + container._isVueCE && + container._def.shadowRoot === false && + anchor && + anchor.$parentNode + ) { + container = anchor.$parentNode + } hostInsert(el, container, anchor) if ( (vnodeHook = props && props.onVnodeMounted) || @@ -966,7 +978,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) || oldVNode.el.$parentNode + ? hostParentNode(oldVNode.el)! : // 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 02522fbd92b..0fe0b2275c0 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -1212,7 +1212,7 @@ describe('defineCustomElement', () => { app.mount(container) expect(container.innerHTML).toBe( `` + - `
false
` + + `
false
` + `
`, ) @@ -1228,7 +1228,7 @@ describe('defineCustomElement', () => { await nextTick() expect(container.innerHTML).toBe( `` + - `
false
` + + `
false
` + `
`, ) @@ -1236,12 +1236,12 @@ describe('defineCustomElement', () => { await nextTick() expect(container.innerHTML).toBe( `` + - `
true
hi
` + + `
true
hi
` + `
`, ) }) - test.todo('update slotted v-if nodes w/ shadowRoot false', async () => { + test('update slotted v-if nodes w/ shadowRoot false', async () => { const E = defineCustomElement( defineComponent({ props: { @@ -1298,25 +1298,33 @@ describe('defineCustomElement', () => { const app = createApp(App) app.mount(container) expect(container.innerHTML).toBe( - `
false
`, + `` + + `
false
` + + `
`, ) click() await nextTick() expect(container.innerHTML).toBe( - `
true
`, + `` + + `
true
` + + `
`, ) click() await nextTick() expect(container.innerHTML).toBe( - `
false
`, + `` + + `
false
` + + `
`, ) click() await nextTick() expect(container.innerHTML).toBe( - `
true
hi
`, + `` + + `
true
hi
` + + `
`, ) }) @@ -1389,7 +1397,7 @@ describe('defineCustomElement', () => { app.mount(container) expect(container.innerHTML).toBe( `` + - `fallback` + + `fallback` + ``, ) @@ -1405,90 +1413,87 @@ describe('defineCustomElement', () => { await nextTick() expect(container.innerHTML).toBe( `` + - `fallback` + + `fallback` + ``, ) }) - test.todo( - 'switch between slotted and fallback nodes w/ shadowRoot false', - async () => { - const E = defineCustomElement( - defineComponent({ - render() { - return renderSlot(this.$slots, 'foo', {}, () => [ - createTextVNode('fallback'), - ]) - }, - }), - { shadowRoot: false }, - ) - customElements.define('ce-with-fallback-shadow-root-false', E) - - const Comp = defineComponent({ + test('switch between slotted and fallback nodes w/ shadowRoot false', async () => { + const E = defineCustomElement( + defineComponent({ render() { - return h('ce-with-fallback-shadow-root-false', null, [ - this.$slots.foo - ? h('div', { key: 0, slot: 'foo' }, [ - renderSlot(this.$slots, 'foo'), - ]) - : createCommentVNode('v-if', true), - renderSlot(this.$slots, 'default'), + return renderSlot(this.$slots, 'foo', {}, () => [ + createTextVNode('fallback'), ]) }, - }) + }), + { shadowRoot: false }, + ) + customElements.define('ce-with-fallback-shadow-root-false', E) - const isShown = ref(false) - const App = defineComponent({ - components: { Comp }, - render() { - return h( - Comp, - null, - createSlots( - { _: 2 /* DYNAMIC */ } as any, - [ - isShown.value - ? { - name: 'foo', - fn: withCtx(() => [createTextVNode('foo')]), - key: '0', - } - : undefined, - ] as any, - ), - ) - }, - }) + const Comp = defineComponent({ + render() { + return h('ce-with-fallback-shadow-root-false', null, [ + this.$slots.foo + ? h('div', { key: 0, slot: 'foo' }, [ + renderSlot(this.$slots, 'foo'), + ]) + : createCommentVNode('v-if', true), + renderSlot(this.$slots, 'default'), + ]) + }, + }) - 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` + - ``, - ) - }, - ) + const isShown = ref(false) + const App = defineComponent({ + components: { Comp }, + render() { + return h( + Comp, + null, + createSlots( + { _: 2 /* DYNAMIC */ } as any, + [ + isShown.value + ? { + name: 'foo', + fn: withCtx(() => [createTextVNode('foo')]), + key: '0', + } + : undefined, + ] as any, + ), + ) + }, + }) + + 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 a6a4b1ba1e0..0d9c1754e80 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -247,6 +247,7 @@ export class VueElement private _slots?: Record private _slotFallbacks?: Record private _slotAnchors?: Map + private _slotNames: Set | undefined constructor( /** @@ -632,10 +633,8 @@ export class VueElement const slotName = (n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default' ;(slots[slotName] || (slots[slotName] = [])).push(n) + ;(this._slotNames || (this._slotNames = new Set())).add(slotName) 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 } @@ -647,7 +646,6 @@ export class VueElement private _renderSlots() { const outlets = (this._teleportTarget || this).querySelectorAll('slot') const scopeId = this._instance!.type.__scopeId - this._slotAnchors = new Map() const processedSlots = new Set() for (let i = 0; i < outlets.length; i++) { @@ -659,7 +657,10 @@ export class VueElement // insert an anchor to facilitate updates const anchor = document.createTextNode('') - this._slotAnchors.set(slotName, anchor) + ;(this._slotAnchors || (this._slotAnchors = new Map())).set( + slotName, + anchor, + ) parent.insertBefore(anchor, o) if (content) { @@ -679,11 +680,25 @@ export class VueElement if (!processedSlots.has('default')) { let content = this._slots!['default'] if (content) { - // TODO - content = content.filter( - n => !(n.nodeType === 8 && (n as Comment).data === 'v-if'), + let anchor + // if the default slot is not the first one, insert it behind the previous slot + if (this._slotAnchors) { + const slotNames = Array.from(this._slotNames!) + const defaultSlotIndex = slotNames.indexOf('default') + if (defaultSlotIndex > 0) { + const prevSlotAnchor = this._slotAnchors.get( + slotNames[defaultSlotIndex - 1], + ) + if (prevSlotAnchor) anchor = prevSlotAnchor.nextSibling + } + } + + insertSlottedContent( + content, + scopeId, + this._root, + anchor || this.firstChild, ) - insertSlottedContent(content, scopeId, this, this.firstChild) } } } @@ -722,8 +737,8 @@ export class VueElement const fallbackNodes = this._slotFallbacks![name] if (fallbackNodes) { // render fallback nodes for removed slots - if (!newSlotNames.includes(name)) { - const anchor = this._slotAnchors!.get(name)! + if (!newSlotNames.includes(name) && this._slotAnchors) { + const anchor = this._slotAnchors.get(name)! fallbackNodes.forEach(fallbackNode => this.insertBefore(fallbackNode, anchor), ) @@ -830,6 +845,7 @@ function insertSlottedContent( ;(child as Element).setAttribute(id, '') } } + ;(n as any).$parentNode = parent parent.insertBefore(n, anchor) } } From 01684caf5d92c85472a898457d9a17dd72df6374 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 12 May 2025 08:10:39 +0800 Subject: [PATCH 8/9] chore: update --- packages/runtime-core/src/hydration.ts | 14 +-- packages/runtime-core/src/index.ts | 1 + .../__tests__/customElement.spec.ts | 35 +++--- packages/runtime-dom/src/apiCustomElement.ts | 101 +++++++++++++----- 4 files changed, 102 insertions(+), 49 deletions(-) diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index a94ff356810..1372c126e80 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -772,13 +772,6 @@ export function createHydrationFunctions( } } - const isTemplateNode = (node: Node): node is HTMLTemplateElement => { - return ( - node.nodeType === DOMNodeTypes.ELEMENT && - (node as Element).tagName === 'TEMPLATE' - ) - } - return [hydrate, hydrateNode] } @@ -993,3 +986,10 @@ function isMismatchAllowed( return allowedAttr.split(',').includes(MismatchTypeString[allowedType]) } } + +export const isTemplateNode = (node: Node): node is HTMLTemplateElement => { + return ( + node.nodeType === DOMNodeTypes.ELEMENT && + (node as Element).tagName === 'TEMPLATE' + ) +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 9910f82102b..da171b0d057 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -382,6 +382,7 @@ export { normalizeClass, normalizeStyle, } from '@vue/shared' +export { isTemplateNode } from './hydration' // For test-utils export { transformVNodeArgs } from './vnode' diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index 0fe0b2275c0..e7c5bcd47f8 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -1029,7 +1029,10 @@ describe('defineCustomElement', () => { toggle.value = false await nextTick() expect(e.innerHTML).toBe( - `defaulttext` + `` + `
fallback
`, + `defaulttext` + + `` + + `` + + `
fallback
`, ) }) @@ -1212,7 +1215,7 @@ describe('defineCustomElement', () => { app.mount(container) expect(container.innerHTML).toBe( `` + - `
false
` + + `` + `
`, ) @@ -1228,7 +1231,7 @@ describe('defineCustomElement', () => { await nextTick() expect(container.innerHTML).toBe( `` + - `
false
` + + `` + `
`, ) @@ -1236,7 +1239,7 @@ describe('defineCustomElement', () => { await nextTick() expect(container.innerHTML).toBe( `` + - `
true
hi
` + + `
true
hi
` + `
`, ) }) @@ -1299,7 +1302,7 @@ describe('defineCustomElement', () => { app.mount(container) expect(container.innerHTML).toBe( `` + - `
false
` + + `` + `
`, ) @@ -1315,7 +1318,7 @@ describe('defineCustomElement', () => { await nextTick() expect(container.innerHTML).toBe( `` + - `
false
` + + `` + `
`, ) @@ -1323,7 +1326,7 @@ describe('defineCustomElement', () => { await nextTick() expect(container.innerHTML).toBe( `` + - `
true
hi
` + + `
true
hi
` + `
`, ) }) @@ -1397,7 +1400,7 @@ describe('defineCustomElement', () => { app.mount(container) expect(container.innerHTML).toBe( `` + - `fallback` + + `fallback` + ``, ) @@ -1474,7 +1477,7 @@ describe('defineCustomElement', () => { app.mount(container) expect(container.innerHTML).toBe( `` + - `fallback` + + `fallback` + ``, ) @@ -1486,13 +1489,13 @@ describe('defineCustomElement', () => { ``, ) - isShown.value = false - await nextTick() - expect(container.innerHTML).toBe( - `` + - `fallback` + - ``, - ) + // isShown.value = false + // await nextTick() + // expect(container.innerHTML).toBe( + // `` + + // `fallback` + + // ``, + // ) }) }) diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index 0d9c1754e80..d3233935be9 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -30,6 +30,7 @@ import { createVNode, defineComponent, getCurrentInstance, + isTemplateNode, isVNode, nextTick, unref, @@ -244,7 +245,7 @@ export class VueElement */ private _childStyles?: Map private _ob?: MutationObserver | null = null - private _slots?: Record + private _slots?: Record private _slotFallbacks?: Record private _slotAnchors?: Map private _slotNames: Set | undefined @@ -627,19 +628,44 @@ export class VueElement * Only called when shadowRoot is false */ private _parseSlots(remove: boolean = true) { - const slots: VueElement['_slots'] = (this._slots = {}) + if (!this._slotNames) this._slotNames = new Set() + else this._slotNames.clear() + this._slots = {} + let n = this.firstChild while (n) { + const next = n.nextSibling + if (isTemplateNode(n)) { + this.processTemplateChildren(n, remove) + this.removeChild(n) + } else { + const slotName = + (n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default' + this.addToSlot(slotName, n, remove) + } + + n = next + } + } + + private processTemplateChildren(template: Node, remove: boolean) { + let n = template.firstChild + while (n) { + const next = n.nextSibling const slotName = (n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default' - ;(slots[slotName] || (slots[slotName] = [])).push(n) - ;(this._slotNames || (this._slotNames = new Set())).add(slotName) - const next = n.nextSibling - if (remove) this.removeChild(n) + this.addToSlot(slotName, n, remove) + if (remove) template.removeChild(n) n = next } } + private addToSlot(slotName: string, node: Node, remove: boolean) { + ;(this._slots![slotName] || (this._slots![slotName] = [])).push(node) + this._slotNames!.add(slotName) + if (remove) this.removeChild(node) + } + /** * Only called when shadowRoot is false */ @@ -664,7 +690,12 @@ export class VueElement parent.insertBefore(anchor, o) if (content) { + const parentNode = content[0].parentNode insertSlottedContent(content, scopeId, parent, anchor) + // remove empty template container + if (parentNode && isTemplateNode(parentNode)) { + this.removeChild(parentNode) + } } else if (this._slotFallbacks) { const nodes = this._slotFallbacks[slotName] if (nodes) { @@ -676,29 +707,32 @@ export class VueElement parent.removeChild(o) } - // ensure default slot content is rendered if provided - if (!processedSlots.has('default')) { - let content = this._slots!['default'] - if (content) { + // create template for unprocessed slots and insert their content + // this prevents errors during full diff when anchors are not in the DOM tree + for (const slotName of this._slotNames!) { + if (processedSlots.has(slotName)) continue + + const content = this._slots![slotName] + if (content && !content[0].isConnected) { let anchor - // if the default slot is not the first one, insert it behind the previous slot if (this._slotAnchors) { const slotNames = Array.from(this._slotNames!) - const defaultSlotIndex = slotNames.indexOf('default') - if (defaultSlotIndex > 0) { + const slotIndex = slotNames.indexOf(slotName) + if (slotIndex > 0) { const prevSlotAnchor = this._slotAnchors.get( - slotNames[defaultSlotIndex - 1], + slotNames[slotIndex - 1], ) if (prevSlotAnchor) anchor = prevSlotAnchor.nextSibling } } - insertSlottedContent( - content, - scopeId, - this._root, - anchor || this.firstChild, - ) + const container = document.createElement('template') + container.setAttribute('name', slotName) + for (const n of content) { + n.$parentNode = container + container.insertBefore(n, null) + } + this.insertBefore(container, anchor || null) } } } @@ -720,7 +754,10 @@ export class VueElement Object.entries(this._slots!).forEach(([_, nodes]) => { const nodeIndex = nodes.indexOf(prevNode) if (nodeIndex > -1) { + const oldNode = nodes[nodeIndex] + const parentNode = (newNode.$parentNode = oldNode.$parentNode)! nodes[nodeIndex] = newNode + if (oldNode.isConnected) parentNode.replaceChild(newNode, oldNode) } }) } @@ -728,10 +765,10 @@ export class VueElement // switch between fallback and provided content if (this._slotFallbacks) { - const oldSlotNames = Object.keys(this._slots!) + const oldSlotNames = Array.from(this._slotNames!) // re-parse slots this._parseSlots(false) - const newSlotNames = Object.keys(this._slots!) + const newSlotNames = Array.from(this._slotNames!) const allSlotNames = new Set([...oldSlotNames, ...newSlotNames]) allSlotNames.forEach(name => { const fallbackNodes = this._slotFallbacks![name] @@ -744,11 +781,21 @@ export class VueElement ) } - // remove fallback nodes for added slots + // remove fallback nodes and render provided nodes for added slots if (!oldSlotNames.includes(name)) { fallbackNodes.forEach(fallbackNode => this.removeChild(fallbackNode), ) + + const content = this._slots![name] + if (content) { + insertSlottedContent( + content, + this._instance!.type.__scopeId, + this._root, + (this._slotAnchors && this._slotAnchors!.get(name)) || null, + ) + } } } }) @@ -829,7 +876,7 @@ export function useShadowRoot(): ShadowRoot | null { } function insertSlottedContent( - content: Node[], + content: (Node & { $parentNode?: Node })[], scopeId: string | undefined, parent: ParentNode, anchor: Node | null, @@ -845,7 +892,7 @@ function insertSlottedContent( ;(child as Element).setAttribute(id, '') } } - ;(n as any).$parentNode = parent + n.$parentNode = parent parent.insertBefore(n, anchor) } } @@ -858,7 +905,9 @@ function collectFragmentNodes(child: VNode): Node[] { ] } -function collectNodes(children: VNodeArrayChildren): Node[] { +function collectNodes( + children: VNodeArrayChildren, +): (Node & { $parentNode?: Node })[] { const nodes: Node[] = [] for (const child of children) { if (isArray(child)) { From 3e724095072d6ae4f8b095e6fc0eb25eb1564f67 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 13 May 2025 11:17:22 +0800 Subject: [PATCH 9/9] chore: remove the code related to handling full diff _renderSlots only renders slots that have corresponding outlets, but not all slots in this._slots are rendered to the DOM tree. During a full diff, parent.insertBefore(node,anchor) will throw an error because the anchor is not in the DOM tree. --- packages/runtime-core/src/hydration.ts | 14 +- packages/runtime-core/src/index.ts | 1 - packages/runtime-core/src/renderer.ts | 14 +- .../__tests__/customElement.spec.ts | 177 +----------------- packages/runtime-dom/src/apiCustomElement.ts | 142 +++----------- 5 files changed, 41 insertions(+), 307 deletions(-) diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index 1372c126e80..a94ff356810 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -772,6 +772,13 @@ export function createHydrationFunctions( } } + const isTemplateNode = (node: Node): node is HTMLTemplateElement => { + return ( + node.nodeType === DOMNodeTypes.ELEMENT && + (node as Element).tagName === 'TEMPLATE' + ) + } + return [hydrate, hydrateNode] } @@ -986,10 +993,3 @@ function isMismatchAllowed( return allowedAttr.split(',').includes(MismatchTypeString[allowedType]) } } - -export const isTemplateNode = (node: Node): node is HTMLTemplateElement => { - return ( - node.nodeType === DOMNodeTypes.ELEMENT && - (node as Element).tagName === 'TEMPLATE' - ) -} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index da171b0d057..9910f82102b 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -382,7 +382,6 @@ export { normalizeClass, normalizeStyle, } from '@vue/shared' -export { isTemplateNode } from './hydration' // For test-utils export { transformVNodeArgs } from './vnode' diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index d1278bda31f..79965b28a9c 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -711,18 +711,6 @@ function baseCreateRenderer( if (needCallTransitionHooks) { transition!.beforeEnter(el) } - - // For custom element with shadowRoot: false, the anchor node may be moved - // to the slot container. In this case, it need to use the anchor's parent - // node as the actual container. - if ( - container._isVueCE && - container._def.shadowRoot === false && - anchor && - anchor.$parentNode - ) { - container = anchor.$parentNode - } hostInsert(el, container, anchor) if ( (vnodeHook = props && props.onVnodeMounted) || @@ -978,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 e7c5bcd47f8..5eb234f66d8 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -1029,10 +1029,7 @@ describe('defineCustomElement', () => { toggle.value = false await nextTick() expect(e.innerHTML).toBe( - `defaulttext` + - `` + - `` + - `
fallback
`, + `defaulttext` + `` + `
fallback
`, ) }) @@ -1215,7 +1212,7 @@ describe('defineCustomElement', () => { app.mount(container) expect(container.innerHTML).toBe( `` + - `` + + `` + ``, ) @@ -1231,7 +1228,7 @@ describe('defineCustomElement', () => { await nextTick() expect(container.innerHTML).toBe( `` + - `` + + `` + ``, ) @@ -1244,93 +1241,6 @@ describe('defineCustomElement', () => { ) }) - test('update slotted v-if nodes w/ shadowRoot false', 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', E) - - const Comp = defineComponent({ - props: { - isShown: { type: Boolean, required: true }, - }, - render() { - return h('ce-shadow-root-false', { '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 h( - Comp, - { isShown: isShown.value }, - { - default: () => [ - h('div', null, String(isShown.value)), - count.value > 1 - ? h('div', { key: 0 }, 'hi') - : createCommentVNode('v-if', true), - ], - }, - ) - }, - } - 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( @@ -1400,7 +1310,7 @@ describe('defineCustomElement', () => { app.mount(container) expect(container.innerHTML).toBe( `` + - `fallback` + + `fallback` + ``, ) @@ -1416,87 +1326,10 @@ describe('defineCustomElement', () => { await nextTick() expect(container.innerHTML).toBe( `` + - `fallback` + + `fallback` + ``, ) }) - - test('switch between slotted and fallback nodes w/ shadowRoot false', async () => { - const E = defineCustomElement( - defineComponent({ - render() { - return renderSlot(this.$slots, 'foo', {}, () => [ - createTextVNode('fallback'), - ]) - }, - }), - { shadowRoot: false }, - ) - customElements.define('ce-with-fallback-shadow-root-false', E) - - const Comp = defineComponent({ - render() { - return h('ce-with-fallback-shadow-root-false', null, [ - this.$slots.foo - ? h('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 h( - Comp, - null, - createSlots( - { _: 2 /* DYNAMIC */ } as any, - [ - isShown.value - ? { - name: 'foo', - fn: withCtx(() => [createTextVNode('foo')]), - key: '0', - } - : undefined, - ] as any, - ), - ) - }, - }) - - 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 d3233935be9..4c5192e9a39 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -30,7 +30,6 @@ import { createVNode, defineComponent, getCurrentInstance, - isTemplateNode, isVNode, nextTick, unref, @@ -248,7 +247,6 @@ export class VueElement private _slots?: Record private _slotFallbacks?: Record private _slotAnchors?: Map - private _slotNames: Set | undefined constructor( /** @@ -537,7 +535,7 @@ export class VueElement const baseProps: VNodeProps = {} if (!this.shadowRoot) { baseProps.onVnodeMounted = () => { - this._captureSlotFallbacks() + this._parseSlotFallbacks() this._renderSlots() } baseProps.onVnodeUpdated = this._renderSlots.bind(this) @@ -628,56 +626,31 @@ export class VueElement * Only called when shadowRoot is false */ private _parseSlots(remove: boolean = true) { - if (!this._slotNames) this._slotNames = new Set() - else this._slotNames.clear() - this._slots = {} - + const slots: VueElement['_slots'] = (this._slots = {}) let n = this.firstChild while (n) { - const next = n.nextSibling - if (isTemplateNode(n)) { - this.processTemplateChildren(n, remove) - this.removeChild(n) - } else { - const slotName = - (n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default' - this.addToSlot(slotName, n, remove) - } - - n = next - } - } - - private processTemplateChildren(template: Node, remove: boolean) { - let n = template.firstChild - while (n) { - const next = n.nextSibling const slotName = (n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default' - this.addToSlot(slotName, n, remove) - if (remove) template.removeChild(n) + ;(slots[slotName] || (slots[slotName] = [])).push(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 } } - private addToSlot(slotName: string, node: Node, remove: boolean) { - ;(this._slots![slotName] || (this._slots![slotName] = [])).push(node) - this._slotNames!.add(slotName) - if (remove) this.removeChild(node) - } - /** * Only called when shadowRoot is false */ private _renderSlots() { const outlets = (this._teleportTarget || this).querySelectorAll('slot') const scopeId = this._instance!.type.__scopeId - const processedSlots = new Set() for (let i = 0; i < outlets.length; i++) { const o = outlets[i] as HTMLSlotElement const slotName = o.getAttribute('name') || 'default' - processedSlots.add(slotName) const content = this._slots![slotName] const parent = o.parentNode! @@ -690,11 +663,19 @@ export class VueElement parent.insertBefore(anchor, o) if (content) { - const parentNode = content[0].parentNode - insertSlottedContent(content, scopeId, parent, anchor) - // remove empty template container - if (parentNode && isTemplateNode(parentNode)) { - this.removeChild(parentNode) + for (const n of content) { + // for :slotted css + if (scopeId && n.nodeType === 1) { + const id = scopeId + '-s' + const walker = document.createTreeWalker(n, 1) + ;(n as Element).setAttribute(id, '') + let child + while ((child = walker.nextNode())) { + ;(child as Element).setAttribute(id, '') + } + } + n.$parentNode = parent + parent.insertBefore(n, anchor) } } else if (this._slotFallbacks) { const nodes = this._slotFallbacks[slotName] @@ -706,35 +687,6 @@ export class VueElement } parent.removeChild(o) } - - // create template for unprocessed slots and insert their content - // this prevents errors during full diff when anchors are not in the DOM tree - for (const slotName of this._slotNames!) { - if (processedSlots.has(slotName)) continue - - const content = this._slots![slotName] - if (content && !content[0].isConnected) { - let anchor - if (this._slotAnchors) { - const slotNames = Array.from(this._slotNames!) - const slotIndex = slotNames.indexOf(slotName) - if (slotIndex > 0) { - const prevSlotAnchor = this._slotAnchors.get( - slotNames[slotIndex - 1], - ) - if (prevSlotAnchor) anchor = prevSlotAnchor.nextSibling - } - } - - const container = document.createElement('template') - container.setAttribute('name', slotName) - for (const n of content) { - n.$parentNode = container - container.insertBefore(n, null) - } - this.insertBefore(container, anchor || null) - } - } } /** @@ -747,17 +699,11 @@ export class VueElement for (let i = 0; i < prevNodes.length; i++) { const prevNode = prevNodes[i] const newNode = newNodes[i] - if ( - prevNode !== newNode && - (isComment(prevNode, 'v-if') || isComment(newNode, 'v-if')) - ) { + if (isComment(prevNode, 'v-if') || isComment(newNode, 'v-if')) { Object.entries(this._slots!).forEach(([_, nodes]) => { const nodeIndex = nodes.indexOf(prevNode) if (nodeIndex > -1) { - const oldNode = nodes[nodeIndex] - const parentNode = (newNode.$parentNode = oldNode.$parentNode)! nodes[nodeIndex] = newNode - if (oldNode.isConnected) parentNode.replaceChild(newNode, oldNode) } }) } @@ -765,37 +711,27 @@ export class VueElement // switch between fallback and provided content if (this._slotFallbacks) { - const oldSlotNames = Array.from(this._slotNames!) + const oldSlotNames = Object.keys(this._slots!) // re-parse slots this._parseSlots(false) - const newSlotNames = Array.from(this._slotNames!) + 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) && this._slotAnchors) { - const anchor = this._slotAnchors.get(name)! + if (!newSlotNames.includes(name)) { + const anchor = this._slotAnchors!.get(name)! fallbackNodes.forEach(fallbackNode => this.insertBefore(fallbackNode, anchor), ) } - // remove fallback nodes and render provided nodes for added slots + // remove fallback nodes for added slots if (!oldSlotNames.includes(name)) { fallbackNodes.forEach(fallbackNode => this.removeChild(fallbackNode), ) - - const content = this._slots![name] - if (content) { - insertSlottedContent( - content, - this._instance!.type.__scopeId, - this._root, - (this._slotAnchors && this._slotAnchors!.get(name)) || null, - ) - } } } }) @@ -805,7 +741,7 @@ export class VueElement /** * Only called when shadowRoot is false */ - private _captureSlotFallbacks() { + private _parseSlotFallbacks() { const outlets = (this._teleportTarget || this).querySelectorAll('slot') for (let i = 0; i < outlets.length; i++) { const slotElement = outlets[i] as HTMLSlotElement @@ -875,28 +811,6 @@ export function useShadowRoot(): ShadowRoot | null { return el && el.shadowRoot } -function insertSlottedContent( - content: (Node & { $parentNode?: Node })[], - scopeId: string | undefined, - parent: ParentNode, - anchor: Node | null, -) { - for (const n of content) { - // for :slotted css - if (scopeId && n.nodeType === 1) { - const id = scopeId + '-s' - const walker = document.createTreeWalker(n, 1) - ;(n as Element).setAttribute(id, '') - let child - while ((child = walker.nextNode())) { - ;(child as Element).setAttribute(id, '') - } - } - n.$parentNode = parent - parent.insertBefore(n, anchor) - } -} - function collectFragmentNodes(child: VNode): Node[] { return [ child.el as Node,