Skip to content

Commit 8bc4d14

Browse files
committed
fix(compiler-core,runtime-core): preserve slots order on instance property
1 parent 09dec96 commit 8bc4d14

File tree

6 files changed

+215
-3
lines changed

6 files changed

+215
-3
lines changed

packages/compiler-core/__tests__/transforms/vSlot.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,63 @@ describe('compiler: transform component slots', () => {
759759
expect(generate(root).code).toMatchSnapshot()
760760
})
761761

762+
// #14425
763+
test('conditional slot between static slots preserves template order', () => {
764+
const { slots } = parseWithSlots(
765+
`<Comp>
766+
<template #foo>foo</template>
767+
<template #baz v-if="ok">baz</template>
768+
<template #bar>bar</template>
769+
</Comp>`,
770+
)
771+
expect(slots).toMatchObject({
772+
type: NodeTypes.JS_CALL_EXPRESSION,
773+
callee: CREATE_SLOTS,
774+
arguments: [
775+
createObjectMatcher({
776+
foo: {
777+
type: NodeTypes.JS_FUNCTION_EXPRESSION,
778+
returns: [{ type: NodeTypes.TEXT, content: `foo` }],
779+
},
780+
bar: {
781+
type: NodeTypes.JS_FUNCTION_EXPRESSION,
782+
returns: [{ type: NodeTypes.TEXT, content: `bar` }],
783+
},
784+
_: `[2 /* DYNAMIC */]`,
785+
}),
786+
{
787+
type: NodeTypes.JS_ARRAY_EXPRESSION,
788+
elements: [
789+
{
790+
type: NodeTypes.JS_CONDITIONAL_EXPRESSION,
791+
test: { content: `ok` },
792+
consequent: createObjectMatcher({
793+
name: `baz`,
794+
fn: {
795+
type: NodeTypes.JS_FUNCTION_EXPRESSION,
796+
returns: [{ type: NodeTypes.TEXT, content: `baz` }],
797+
},
798+
key: `0`,
799+
}),
800+
alternate: {
801+
content: `undefined`,
802+
isStatic: false,
803+
},
804+
},
805+
],
806+
},
807+
{
808+
type: NodeTypes.JS_ARRAY_EXPRESSION,
809+
elements: [
810+
{ content: `foo`, isStatic: true },
811+
{ content: `baz`, isStatic: true },
812+
{ content: `bar`, isStatic: true },
813+
],
814+
},
815+
],
816+
})
817+
})
818+
762819
test('named slot with v-for w/ prefixIdentifiers: true', () => {
763820
const { root, slots } = parseWithSlots(
764821
`<Comp>

packages/compiler-core/src/transforms/vSlot.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export function buildSlots(
128128
const { children, loc } = node
129129
const slotsProperties: Property[] = []
130130
const dynamicSlots: (ConditionalExpression | CallExpression)[] = []
131+
const slotOrder: string[] = []
131132

132133
// If the slot is inside a v-for or another v-slot, force it to be dynamic
133134
// since it likely uses a scope variable.
@@ -217,6 +218,7 @@ export function buildSlots(
217218
let vElse: DirectiveNode | undefined
218219
if ((vIf = findDir(slotElement, 'if'))) {
219220
hasDynamicSlots = true
221+
if (staticSlotName) slotOrder.push(staticSlotName)
220222
dynamicSlots.push(
221223
createConditionalExpression(
222224
vIf.exp!,
@@ -238,6 +240,7 @@ export function buildSlots(
238240
}
239241
if (prev && isTemplateNode(prev) && findDir(prev, /^(?:else-)?if$/)) {
240242
__TEST__ && assert(dynamicSlots.length > 0)
243+
if (staticSlotName) slotOrder.push(staticSlotName)
241244
// attach this slot to previous conditional
242245
let conditional = dynamicSlots[
243246
dynamicSlots.length - 1
@@ -305,6 +308,8 @@ export function buildSlots(
305308
hasNamedDefaultSlot = true
306309
}
307310
}
311+
312+
if (staticSlotName) slotOrder.push(staticSlotName)
308313
slotsProperties.push(createObjectProperty(slotName, slotFunction))
309314
}
310315
}
@@ -366,11 +371,26 @@ export function buildSlots(
366371
),
367372
loc,
368373
) as SlotsExpression
369-
if (dynamicSlots.length) {
370-
slots = createCallExpression(context.helper(CREATE_SLOTS), [
374+
375+
if (dynamicSlots.length > 0) {
376+
const createSlotsArgs: CallExpression['arguments'] = [
371377
slots,
372378
createArrayExpression(dynamicSlots),
373-
]) as SlotsExpression
379+
]
380+
// #14425
381+
// Pass slot names to preserve the template ordering
382+
if (slotsProperties.length > 0) {
383+
createSlotsArgs.push(
384+
createArrayExpression(
385+
slotOrder.map(name => createSimpleExpression(name, true)),
386+
),
387+
)
388+
}
389+
390+
slots = createCallExpression(
391+
context.helper(CREATE_SLOTS),
392+
createSlotsArgs,
393+
) as SlotsExpression
374394
}
375395

376396
return {

packages/runtime-core/__tests__/componentSlots.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,4 +461,52 @@ describe('component: slots', () => {
461461
createApp(App).mount(root)
462462
expect(serializeInner(root)).toBe('foo')
463463
})
464+
465+
// #14425
466+
test('conditionally rendered slot position in `slots` instance property should match its position in template', async () => {
467+
const showFoo = ref(true)
468+
469+
let instance: any
470+
const Child = () => {
471+
instance = getCurrentInstance()
472+
return 'child'
473+
}
474+
475+
const Comp = {
476+
setup() {
477+
return () => [
478+
h(
479+
Child,
480+
null,
481+
createSlots(
482+
{
483+
bar: () => [h('span', 'bar')],
484+
baz: () => [h('span', 'baz')],
485+
// @ts-expect-error property holding slots flag DYNAMIC
486+
_: 2,
487+
},
488+
[
489+
showFoo.value
490+
? { name: 'foo', fn: () => [h('span', 'foo')] }
491+
: undefined,
492+
],
493+
['foo', 'bar', 'baz'],
494+
),
495+
),
496+
]
497+
},
498+
}
499+
500+
render(h(Comp), nodeOps.createElement('div'))
501+
console.log('instance.slots:', instance.slots)
502+
expect(Object.keys(instance.slots)).toEqual(['foo', 'bar', 'baz'])
503+
504+
showFoo.value = false
505+
await nextTick()
506+
expect(Object.keys(instance.slots)).toEqual(['bar', 'baz'])
507+
508+
showFoo.value = true
509+
await nextTick()
510+
expect(Object.keys(instance.slots)).toEqual(['foo', 'bar', 'baz'])
511+
})
464512
})

packages/runtime-core/__tests__/hmr.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,4 +1093,63 @@ describe('hot module replacement', () => {
10931093
`1 <button>++</button> static text updated2`,
10941094
)
10951095
})
1096+
1097+
// #14425
1098+
// Preserve slot order when HMR removes and re-adds a slot
1099+
test('rerender should preserve slot order after slot removal and re-addition', () => {
1100+
const root = nodeOps.createElement('div')
1101+
const parentId = 'test-hmr-slot-reorder-parent'
1102+
const childId = 'test-hmr-slot-reorder-child'
1103+
1104+
let childInstance: any
1105+
const Child: ComponentOptions = {
1106+
__hmrId: childId,
1107+
render() {
1108+
childInstance = this
1109+
return h('div', Object.keys(this.$slots).join(','))
1110+
},
1111+
}
1112+
createRecord(childId, Child)
1113+
1114+
const Parent: ComponentOptions = {
1115+
__hmrId: parentId,
1116+
components: { Child },
1117+
render: compileToFunction(
1118+
`<Child>
1119+
<template #foo>foo</template>
1120+
<template #bar>bar</template>
1121+
<template #baz>baz</template>
1122+
</Child>`,
1123+
),
1124+
}
1125+
createRecord(parentId, Parent)
1126+
1127+
render(h(Parent), root)
1128+
expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'bar', 'baz'])
1129+
1130+
// HMR rerender: remove #bar slot
1131+
rerender(
1132+
parentId,
1133+
compileToFunction(
1134+
`<Child>
1135+
<template #foo>foo</template>
1136+
<template #baz>baz</template>
1137+
</Child>`,
1138+
),
1139+
)
1140+
expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'baz'])
1141+
1142+
// HMR rerender: re-add #bar in its original position
1143+
rerender(
1144+
parentId,
1145+
compileToFunction(
1146+
`<Child>
1147+
<template #foo>foo</template>
1148+
<template #bar>bar</template>
1149+
<template #baz>baz</template>
1150+
</Child>`,
1151+
),
1152+
)
1153+
expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'bar', 'baz'])
1154+
})
10961155
})

packages/runtime-core/src/componentSlots.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,12 @@ export const updateSlots = (
219219
if (__DEV__ && isHmrUpdating) {
220220
// Parent was HMR updated so slot content may have changed.
221221
// force update slots and mark instance for hmr as well
222+
// #14425 clear keys first to preserve insertion order
223+
for (const key in slots) {
224+
if (!isInternalKey(key)) {
225+
delete slots[key]
226+
}
227+
}
222228
assignSlots(slots, children as Slots, optimized)
223229
trigger(instance, TriggerOpTypes.SET, '$slots')
224230
} else if (optimized && type === SlotFlags.STABLE) {
@@ -228,7 +234,17 @@ export const updateSlots = (
228234
} else {
229235
// compiled but dynamic (v-if/v-for on slots) - update slots, but skip
230236
// normalization.
237+
// #14425 clear all non-internal keys first and re-assign so that
238+
// the key insertion order matches children (the new slots).
239+
// Without this, a slot removed by v-if and later re-added ends up
240+
// at the end of the object.
241+
for (const key in slots) {
242+
if (!isInternalKey(key)) {
243+
delete slots[key]
244+
}
245+
}
231246
assignSlots(slots, children as Slots, optimized)
247+
needDeletionCheck = false
232248
}
233249
} else {
234250
needDeletionCheck = !(children as RawSlots).$stable

packages/runtime-core/src/helpers/createSlots.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export function createSlots(
2121
| CompiledSlotDescriptor[]
2222
| undefined
2323
)[],
24+
order?: string[],
2425
): Record<string, SSRSlot> {
2526
for (let i = 0; i < dynamicSlots.length; i++) {
2627
const slot = dynamicSlots[i]
@@ -42,5 +43,16 @@ export function createSlots(
4243
: slot.fn
4344
}
4445
}
46+
47+
if (order) {
48+
order.forEach(slotName => {
49+
if (slotName in slots) {
50+
const reorderedSlot = slots[slotName]
51+
delete slots[slotName]
52+
slots[slotName] = reorderedSlot
53+
}
54+
})
55+
}
56+
4557
return slots
4658
}

0 commit comments

Comments
 (0)