From c16891640229dbb39d3da094cf3ebc439f90b599 Mon Sep 17 00:00:00 2001 From: Andrew Kazakov Date: Thu, 12 Feb 2026 13:24:46 +0300 Subject: [PATCH 1/2] fix(compiler-core,runtime-core): preserve slots order on instance property --- .../__tests__/transforms/vSlot.spec.ts | 57 ++++++++++++++++++ .../compiler-core/src/transforms/vSlot.ts | 26 +++++++- .../__tests__/componentSlots.spec.ts | 47 +++++++++++++++ packages/runtime-core/__tests__/hmr.spec.ts | 59 +++++++++++++++++++ packages/runtime-core/src/componentSlots.ts | 16 +++++ .../runtime-core/src/helpers/createSlots.ts | 12 ++++ 6 files changed, 214 insertions(+), 3 deletions(-) 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( + ` + + + + `, + ) + 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__/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( + ` + + + + `, + ), + } + createRecord(parentId, Parent) + + render(h(Parent), root) + expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'bar', 'baz']) + + // HMR rerender: remove #bar slot + rerender( + parentId, + compileToFunction( + ` + + + `, + ), + ) + expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'baz']) + + // HMR rerender: re-add #bar in its original position + rerender( + parentId, + compileToFunction( + ` + + + + `, + ), + ) + 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 } From c0371f6bbb8007e7aa9fa50fd4d18e4783505a4c Mon Sep 17 00:00:00 2001 From: Andrew Kazakov Date: Thu, 12 Feb 2026 14:07:48 +0300 Subject: [PATCH 2/2] test(runtime-core): add tests for `createSlots`'s `order` argument --- .../__tests__/helpers/createSlots.spec.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) 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 `