diff --git a/packages/compiler-core/__tests__/transforms/vSlot.spec.ts b/packages/compiler-core/__tests__/transforms/vSlot.spec.ts
index 97f68101f74..e55da38e0f6 100644
--- a/packages/compiler-core/__tests__/transforms/vSlot.spec.ts
+++ b/packages/compiler-core/__tests__/transforms/vSlot.spec.ts
@@ -759,6 +759,63 @@ describe('compiler: transform component slots', () => {
expect(generate(root).code).toMatchSnapshot()
})
+ // #14425
+ test('conditional slot between static slots preserves template order', () => {
+ const { slots } = parseWithSlots(
+ `
+ foo
+ baz
+ bar
+ `,
+ )
+ expect(slots).toMatchObject({
+ type: NodeTypes.JS_CALL_EXPRESSION,
+ callee: CREATE_SLOTS,
+ arguments: [
+ createObjectMatcher({
+ foo: {
+ type: NodeTypes.JS_FUNCTION_EXPRESSION,
+ returns: [{ type: NodeTypes.TEXT, content: `foo` }],
+ },
+ bar: {
+ type: NodeTypes.JS_FUNCTION_EXPRESSION,
+ returns: [{ type: NodeTypes.TEXT, content: `bar` }],
+ },
+ _: `[2 /* DYNAMIC */]`,
+ }),
+ {
+ type: NodeTypes.JS_ARRAY_EXPRESSION,
+ elements: [
+ {
+ type: NodeTypes.JS_CONDITIONAL_EXPRESSION,
+ test: { content: `ok` },
+ consequent: createObjectMatcher({
+ name: `baz`,
+ fn: {
+ type: NodeTypes.JS_FUNCTION_EXPRESSION,
+ returns: [{ type: NodeTypes.TEXT, content: `baz` }],
+ },
+ key: `0`,
+ }),
+ alternate: {
+ content: `undefined`,
+ isStatic: false,
+ },
+ },
+ ],
+ },
+ {
+ type: NodeTypes.JS_ARRAY_EXPRESSION,
+ elements: [
+ { content: `foo`, isStatic: true },
+ { content: `baz`, isStatic: true },
+ { content: `bar`, isStatic: true },
+ ],
+ },
+ ],
+ })
+ })
+
test('named slot with v-for w/ prefixIdentifiers: true', () => {
const { root, slots } = parseWithSlots(
`
diff --git a/packages/compiler-core/src/transforms/vSlot.ts b/packages/compiler-core/src/transforms/vSlot.ts
index f9a1b72daad..92bbad685e7 100644
--- a/packages/compiler-core/src/transforms/vSlot.ts
+++ b/packages/compiler-core/src/transforms/vSlot.ts
@@ -128,6 +128,7 @@ export function buildSlots(
const { children, loc } = node
const slotsProperties: Property[] = []
const dynamicSlots: (ConditionalExpression | CallExpression)[] = []
+ const slotOrder: string[] = []
// If the slot is inside a v-for or another v-slot, force it to be dynamic
// since it likely uses a scope variable.
@@ -217,6 +218,7 @@ export function buildSlots(
let vElse: DirectiveNode | undefined
if ((vIf = findDir(slotElement, 'if'))) {
hasDynamicSlots = true
+ if (staticSlotName) slotOrder.push(staticSlotName)
dynamicSlots.push(
createConditionalExpression(
vIf.exp!,
@@ -238,6 +240,7 @@ export function buildSlots(
}
if (prev && isTemplateNode(prev) && findDir(prev, /^(?:else-)?if$/)) {
__TEST__ && assert(dynamicSlots.length > 0)
+ if (staticSlotName) slotOrder.push(staticSlotName)
// attach this slot to previous conditional
let conditional = dynamicSlots[
dynamicSlots.length - 1
@@ -305,6 +308,8 @@ export function buildSlots(
hasNamedDefaultSlot = true
}
}
+
+ if (staticSlotName) slotOrder.push(staticSlotName)
slotsProperties.push(createObjectProperty(slotName, slotFunction))
}
}
@@ -366,11 +371,26 @@ export function buildSlots(
),
loc,
) as SlotsExpression
- if (dynamicSlots.length) {
- slots = createCallExpression(context.helper(CREATE_SLOTS), [
+
+ if (dynamicSlots.length > 0) {
+ const createSlotsArgs: CallExpression['arguments'] = [
slots,
createArrayExpression(dynamicSlots),
- ]) as SlotsExpression
+ ]
+ // #14425
+ // Pass slot names to preserve the template ordering
+ if (slotsProperties.length > 0) {
+ createSlotsArgs.push(
+ createArrayExpression(
+ slotOrder.map(name => createSimpleExpression(name, true)),
+ ),
+ )
+ }
+
+ slots = createCallExpression(
+ context.helper(CREATE_SLOTS),
+ createSlotsArgs,
+ ) as SlotsExpression
}
return {
diff --git a/packages/runtime-core/__tests__/componentSlots.spec.ts b/packages/runtime-core/__tests__/componentSlots.spec.ts
index 458731dd150..91a2d1eaadc 100644
--- a/packages/runtime-core/__tests__/componentSlots.spec.ts
+++ b/packages/runtime-core/__tests__/componentSlots.spec.ts
@@ -461,4 +461,51 @@ describe('component: slots', () => {
createApp(App).mount(root)
expect(serializeInner(root)).toBe('foo')
})
+
+ // #14425
+ test('conditionally rendered slot position in `slots` instance property should match its position in template', async () => {
+ const showFoo = ref(true)
+
+ let instance: any
+ const Child = () => {
+ instance = getCurrentInstance()
+ return 'child'
+ }
+
+ const Comp = {
+ setup() {
+ return () => [
+ h(
+ Child,
+ null,
+ createSlots(
+ {
+ bar: () => [h('span', 'bar')],
+ baz: () => [h('span', 'baz')],
+ // @ts-expect-error property holding slots flag DYNAMIC
+ _: 2,
+ },
+ [
+ showFoo.value
+ ? { name: 'foo', fn: () => [h('span', 'foo')] }
+ : undefined,
+ ],
+ ['foo', 'bar', 'baz'],
+ ),
+ ),
+ ]
+ },
+ }
+
+ render(h(Comp), nodeOps.createElement('div'))
+ expect(Object.keys(instance.slots)).toEqual(['foo', 'bar', 'baz'])
+
+ showFoo.value = false
+ await nextTick()
+ expect(Object.keys(instance.slots)).toEqual(['bar', 'baz'])
+
+ showFoo.value = true
+ await nextTick()
+ expect(Object.keys(instance.slots)).toEqual(['foo', 'bar', 'baz'])
+ })
})
diff --git a/packages/runtime-core/__tests__/helpers/createSlots.spec.ts b/packages/runtime-core/__tests__/helpers/createSlots.spec.ts
index 85018854eae..b1949e66eaf 100644
--- a/packages/runtime-core/__tests__/helpers/createSlots.spec.ts
+++ b/packages/runtime-core/__tests__/helpers/createSlots.spec.ts
@@ -73,4 +73,65 @@ describe('createSlot', () => {
descriptor3: slot,
})
})
+
+ describe('order parameter', () => {
+ it('should treat duplicate slot names as a no-op (v-if/v-else branches)', () => {
+ record = { default: slot }
+
+ const actual = createSlots(
+ record,
+ [
+ { name: 'header', fn: slot, key: '0' },
+ { name: 'header', fn: slot, key: '1' },
+ ],
+ ['header', 'header'],
+ )
+
+ expect(Object.keys(actual)).toEqual(['default', 'header'])
+ expect(actual).toHaveProperty('header')
+ })
+
+ // Simulates ` / `
+ it('should reorder mutually exclusive slots from the active branch', () => {
+ record = { default: slot }
+
+ const actual = createSlots(
+ record,
+ [{ name: 'footer', fn: slot }],
+ ['header', 'footer'],
+ )
+
+ const keys = Object.keys(actual)
+ expect(keys).toEqual(['default', 'footer'])
+ expect(actual).toHaveProperty('footer', slot)
+ expect(actual).not.toHaveProperty('header')
+ })
+
+ it('should leave slots unchanged when order is an empty array', () => {
+ const actual = createSlots(record, [{ name: 'default', fn: slot }], [])
+
+ expect(Object.keys(actual)).toEqual(['default'])
+ expect(actual).toHaveProperty('default', slot)
+ })
+
+ it('should leave keys not in the order array untouched', () => {
+ record = { _: 2 as any, default: slot }
+
+ const actual = createSlots(
+ record,
+ [
+ { name: 'header', fn: slot },
+ { name: 'footer', fn: slot },
+ ],
+ ['header', 'footer'],
+ )
+
+ const keys = Object.keys(actual)
+ expect(keys).toEqual(['_', 'default', 'header', 'footer'])
+ expect(actual).toHaveProperty('_', 2)
+ expect(actual).toHaveProperty('default', slot)
+ expect(actual).toHaveProperty('header', slot)
+ expect(actual).toHaveProperty('footer', slot)
+ })
+ })
})
diff --git a/packages/runtime-core/__tests__/hmr.spec.ts b/packages/runtime-core/__tests__/hmr.spec.ts
index 9e92ed2a218..597325e3697 100644
--- a/packages/runtime-core/__tests__/hmr.spec.ts
+++ b/packages/runtime-core/__tests__/hmr.spec.ts
@@ -1093,4 +1093,63 @@ describe('hot module replacement', () => {
`1 static text updated2`,
)
})
+
+ // #14425
+ // Preserve slot order when HMR removes and re-adds a slot
+ test('rerender should preserve slot order after slot removal and re-addition', () => {
+ const root = nodeOps.createElement('div')
+ const parentId = 'test-hmr-slot-reorder-parent'
+ const childId = 'test-hmr-slot-reorder-child'
+
+ let childInstance: any
+ const Child: ComponentOptions = {
+ __hmrId: childId,
+ render() {
+ childInstance = this
+ return h('div', Object.keys(this.$slots).join(','))
+ },
+ }
+ createRecord(childId, Child)
+
+ const Parent: ComponentOptions = {
+ __hmrId: parentId,
+ components: { Child },
+ render: compileToFunction(
+ `
+ foo
+ bar
+ baz
+ `,
+ ),
+ }
+ createRecord(parentId, Parent)
+
+ render(h(Parent), root)
+ expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'bar', 'baz'])
+
+ // HMR rerender: remove #bar slot
+ rerender(
+ parentId,
+ compileToFunction(
+ `
+ foo
+ baz
+ `,
+ ),
+ )
+ expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'baz'])
+
+ // HMR rerender: re-add #bar in its original position
+ rerender(
+ parentId,
+ compileToFunction(
+ `
+ foo
+ bar
+ baz
+ `,
+ ),
+ )
+ expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'bar', 'baz'])
+ })
})
diff --git a/packages/runtime-core/src/componentSlots.ts b/packages/runtime-core/src/componentSlots.ts
index c19df7ec80f..d634b26ea00 100644
--- a/packages/runtime-core/src/componentSlots.ts
+++ b/packages/runtime-core/src/componentSlots.ts
@@ -219,6 +219,12 @@ export const updateSlots = (
if (__DEV__ && isHmrUpdating) {
// Parent was HMR updated so slot content may have changed.
// force update slots and mark instance for hmr as well
+ // #14425 clear keys first to preserve insertion order
+ for (const key in slots) {
+ if (!isInternalKey(key)) {
+ delete slots[key]
+ }
+ }
assignSlots(slots, children as Slots, optimized)
trigger(instance, TriggerOpTypes.SET, '$slots')
} else if (optimized && type === SlotFlags.STABLE) {
@@ -228,7 +234,17 @@ export const updateSlots = (
} else {
// compiled but dynamic (v-if/v-for on slots) - update slots, but skip
// normalization.
+ // #14425 clear all non-internal keys first and re-assign so that
+ // the key insertion order matches children (the new slots).
+ // Without this, a slot removed by v-if and later re-added ends up
+ // at the end of the object.
+ for (const key in slots) {
+ if (!isInternalKey(key)) {
+ delete slots[key]
+ }
+ }
assignSlots(slots, children as Slots, optimized)
+ needDeletionCheck = false
}
} else {
needDeletionCheck = !(children as RawSlots).$stable
diff --git a/packages/runtime-core/src/helpers/createSlots.ts b/packages/runtime-core/src/helpers/createSlots.ts
index f7d6e9d073b..edb4a33dbff 100644
--- a/packages/runtime-core/src/helpers/createSlots.ts
+++ b/packages/runtime-core/src/helpers/createSlots.ts
@@ -21,6 +21,7 @@ export function createSlots(
| CompiledSlotDescriptor[]
| undefined
)[],
+ order?: string[],
): Record {
for (let i = 0; i < dynamicSlots.length; i++) {
const slot = dynamicSlots[i]
@@ -42,5 +43,16 @@ export function createSlots(
: slot.fn
}
}
+
+ if (order) {
+ order.forEach(slotName => {
+ if (slotName in slots) {
+ const reorderedSlot = slots[slotName]
+ delete slots[slotName]
+ slots[slotName] = reorderedSlot
+ }
+ })
+ }
+
return slots
}