Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/compiler-core/__tests__/transforms/vSlot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<Comp>
<template #foo>foo</template>
<template #baz v-if="ok">baz</template>
<template #bar>bar</template>
</Comp>`,
)
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(
`<Comp>
Expand Down
26 changes: 23 additions & 3 deletions packages/compiler-core/src/transforms/vSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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!,
Expand All @@ -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
Expand Down Expand Up @@ -305,6 +308,8 @@ export function buildSlots(
hasNamedDefaultSlot = true
}
}

if (staticSlotName) slotOrder.push(staticSlotName)
slotsProperties.push(createObjectProperty(slotName, slotFunction))
}
}
Expand Down Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions packages/runtime-core/__tests__/componentSlots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})
})
59 changes: 59 additions & 0 deletions packages/runtime-core/__tests__/hmr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1093,4 +1093,63 @@ describe('hot module replacement', () => {
`1 <button>++</button> 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(
`<Child>
<template #foo>foo</template>
<template #bar>bar</template>
<template #baz>baz</template>
</Child>`,
),
}
createRecord(parentId, Parent)

render(h(Parent), root)
expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'bar', 'baz'])

// HMR rerender: remove #bar slot
rerender(
parentId,
compileToFunction(
`<Child>
<template #foo>foo</template>
<template #baz>baz</template>
</Child>`,
),
)
expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'baz'])

// HMR rerender: re-add #bar in its original position
rerender(
parentId,
compileToFunction(
`<Child>
<template #foo>foo</template>
<template #bar>bar</template>
<template #baz>baz</template>
</Child>`,
),
)
expect(Object.keys(childInstance.$slots)).toEqual(['foo', 'bar', 'baz'])
})
})
16 changes: 16 additions & 0 deletions packages/runtime-core/src/componentSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/runtime-core/src/helpers/createSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function createSlots(
| CompiledSlotDescriptor[]
| undefined
)[],
order?: string[],
): Record<string, SSRSlot> {
for (let i = 0; i < dynamicSlots.length; i++) {
const slot = dynamicSlots[i]
Expand All @@ -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
}