Skip to content

Commit 78f74ce

Browse files
ubugeeeisxzz
andauthoredMar 24, 2024
feat(runtime-vapor): component slot (#143)
Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
1 parent bd888b9 commit 78f74ce

File tree

10 files changed

+411
-6
lines changed

10 files changed

+411
-6
lines changed
 

‎packages/compiler-vapor/src/generators/component.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { genExpression } from './expression'
1313
import { genPropKey } from './prop'
1414

15+
// TODO: generate component slots
1516
export function genCreateComponent(
1617
oper: CreateComponentIRNode,
1718
context: CodegenContext,

‎packages/runtime-vapor/__tests__/apiInject.spec.ts

-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
createComponent,
77
createTextNode,
88
createVaporApp,
9-
getCurrentInstance,
109
hasInjectionContext,
1110
inject,
1211
nextTick,

‎packages/runtime-vapor/__tests__/componentAttrs.spec.ts

+8
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ describe('attribute fallthrough', () => {
4040
id: () => _ctx.id,
4141
},
4242
],
43+
null,
44+
null,
4345
true,
4446
)
4547
},
@@ -85,6 +87,8 @@ describe('attribute fallthrough', () => {
8587
id: () => _ctx.id,
8688
},
8789
],
90+
null,
91+
null,
8892
true,
8993
)
9094
},
@@ -123,6 +127,8 @@ describe('attribute fallthrough', () => {
123127
'custom-attr': () => 'custom-attr',
124128
},
125129
],
130+
null,
131+
null,
126132
true,
127133
)
128134
return n0
@@ -144,6 +150,8 @@ describe('attribute fallthrough', () => {
144150
id: () => _ctx.id,
145151
},
146152
],
153+
null,
154+
null,
147155
true,
148156
)
149157
},

‎packages/runtime-vapor/__tests__/componentProps.spec.ts

+2
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ describe('component: props', () => {
244244
foo: () => _ctx.foo,
245245
id: () => _ctx.id,
246246
},
247+
null,
248+
null,
247249
true,
248250
)
249251
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// NOTE: This test is implemented based on the case of `runtime-core/__test__/componentSlots.spec.ts`.
2+
3+
import {
4+
createComponent,
5+
createVaporApp,
6+
defineComponent,
7+
getCurrentInstance,
8+
nextTick,
9+
ref,
10+
template,
11+
} from '../src'
12+
import { makeRender } from './_utils'
13+
14+
const define = makeRender<any>()
15+
function renderWithSlots(slots: any): any {
16+
let instance: any
17+
const Comp = defineComponent({
18+
render() {
19+
const t0 = template('<div></div>')
20+
const n0 = t0()
21+
instance = getCurrentInstance()
22+
return n0
23+
},
24+
})
25+
26+
const { render } = define({
27+
render() {
28+
return createComponent(Comp, {}, slots)
29+
},
30+
})
31+
32+
render()
33+
return instance
34+
}
35+
36+
describe('component: slots', () => {
37+
test('initSlots: instance.slots should be set correctly', () => {
38+
const { slots } = renderWithSlots({ _: 1 })
39+
expect(slots).toMatchObject({ _: 1 })
40+
})
41+
42+
// NOTE: slot normalization is not supported
43+
test.todo(
44+
'initSlots: should normalize object slots (when value is null, string, array)',
45+
() => {},
46+
)
47+
test.todo(
48+
'initSlots: should normalize object slots (when value is function)',
49+
() => {},
50+
)
51+
52+
test('initSlots: instance.slots should be set correctly', () => {
53+
let instance: any
54+
const Comp = defineComponent({
55+
render() {
56+
const t0 = template('<div></div>')
57+
const n0 = t0()
58+
instance = getCurrentInstance()
59+
return n0
60+
},
61+
})
62+
63+
const { render } = define({
64+
render() {
65+
return createComponent(Comp, {}, { header: () => template('header')() })
66+
},
67+
})
68+
69+
render()
70+
71+
expect(instance.slots.header()).toMatchObject(
72+
document.createTextNode('header'),
73+
)
74+
})
75+
76+
// runtime-core's "initSlots: instance.slots should be set correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)"
77+
test('initSlots: instance.slots should be set correctly', () => {
78+
const { slots } = renderWithSlots({
79+
default: () => template('<span></span>')(),
80+
})
81+
82+
// expect(
83+
// '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
84+
// ).toHaveBeenWarned()
85+
86+
expect(slots.default()).toMatchObject(document.createElement('span'))
87+
})
88+
89+
test('updateSlots: instance.slots should be updated correctly', async () => {
90+
const flag1 = ref(true)
91+
92+
let instance: any
93+
const Child = () => {
94+
instance = getCurrentInstance()
95+
return template('child')()
96+
}
97+
98+
const { render } = define({
99+
render() {
100+
return createComponent(Child, {}, { _: 2 as any }, () => [
101+
flag1.value
102+
? { name: 'one', fn: () => template('<span></span>')() }
103+
: { name: 'two', fn: () => template('<div></div>')() },
104+
])
105+
},
106+
})
107+
108+
render()
109+
110+
expect(instance.slots).toHaveProperty('one')
111+
expect(instance.slots).not.toHaveProperty('two')
112+
113+
flag1.value = false
114+
await nextTick()
115+
116+
expect(instance.slots).not.toHaveProperty('one')
117+
expect(instance.slots).toHaveProperty('two')
118+
})
119+
120+
// NOTE: it is not supported
121+
// test('updateSlots: instance.slots should be updated correctly (when slotType is null)', () => {})
122+
123+
// runtime-core's "updateSlots: instance.slots should be update correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)"
124+
test('updateSlots: instance.slots should be update correctly', async () => {
125+
const flag1 = ref(true)
126+
127+
let instance: any
128+
const Child = () => {
129+
instance = getCurrentInstance()
130+
return template('child')()
131+
}
132+
133+
const { render } = define({
134+
setup() {
135+
return createComponent(Child, {}, {}, () => [
136+
flag1.value
137+
? [{ name: 'header', fn: () => template('header')() }]
138+
: [{ name: 'footer', fn: () => template('footer')() }],
139+
])
140+
},
141+
})
142+
render()
143+
144+
expect(instance.slots).toHaveProperty('header')
145+
flag1.value = false
146+
await nextTick()
147+
148+
// expect(
149+
// '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
150+
// ).toHaveBeenWarned()
151+
152+
expect(instance.slots).toHaveProperty('footer')
153+
})
154+
155+
test.todo('should respect $stable flag', async () => {
156+
// TODO: $stable flag?
157+
})
158+
159+
test.todo('should not warn when mounting another app in setup', () => {
160+
// TODO: warning
161+
const Comp = defineComponent({
162+
render() {
163+
const i = getCurrentInstance()
164+
return i!.slots.default!()
165+
},
166+
})
167+
const mountComp = () => {
168+
createVaporApp({
169+
render() {
170+
return createComponent(
171+
Comp,
172+
{},
173+
{ default: () => template('msg')() },
174+
)!
175+
},
176+
})
177+
}
178+
const App = {
179+
setup() {
180+
mountComp()
181+
},
182+
render() {
183+
return null!
184+
},
185+
}
186+
createVaporApp(App).mount(document.createElement('div'))
187+
expect(
188+
'Slot "default" invoked outside of the render function',
189+
).not.toHaveBeenWarned()
190+
})
191+
})

‎packages/runtime-vapor/src/apiCreateComponent.ts

+5
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,22 @@ import {
55
} from './component'
66
import { setupComponent } from './apiRender'
77
import type { RawProps } from './componentProps'
8+
import type { DynamicSlots, Slots } from './componentSlots'
89
import { withAttrs } from './componentAttrs'
910

1011
export function createComponent(
1112
comp: Component,
1213
rawProps: RawProps | null = null,
14+
slots: Slots | null = null,
15+
dynamicSlots: DynamicSlots | null = null,
1316
singleRoot: boolean = false,
1417
) {
1518
const current = currentInstance!
1619
const instance = createComponentInstance(
1720
comp,
1821
singleRoot ? withAttrs(rawProps) : rawProps,
22+
slots,
23+
dynamicSlots,
1924
)
2025
setupComponent(instance, singleRoot)
2126

‎packages/runtime-vapor/src/apiCreateVaporApp.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ export function createVaporApp(
4141

4242
mount(rootContainer): any {
4343
if (!instance) {
44-
instance = createComponentInstance(rootComponent, rootProps, context)
44+
instance = createComponentInstance(
45+
rootComponent,
46+
rootProps,
47+
null,
48+
null,
49+
context,
50+
)
4551
setupComponent(instance)
4652
render(instance, rootContainer)
4753
return instance

‎packages/runtime-vapor/src/component.ts

+32-4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import {
1717
emit,
1818
normalizeEmitsOptions,
1919
} from './componentEmits'
20+
import {
21+
type DynamicSlots,
22+
type InternalSlots,
23+
type Slots,
24+
initSlots,
25+
} from './componentSlots'
2026
import { VaporLifecycleHooks } from './apiLifecycle'
2127
import { warn } from './warning'
2228
import { type AppContext, createAppContext } from './apiCreateVaporApp'
@@ -32,7 +38,7 @@ export type SetupContext<E = EmitsOptions> = E extends any
3238
attrs: Data
3339
emit: EmitFn<E>
3440
expose: (exposed?: Record<string, any>) => void
35-
// TODO slots
41+
slots: Readonly<InternalSlots>
3642
}
3743
: never
3844

@@ -46,6 +52,9 @@ export function createSetupContext(
4652
get attrs() {
4753
return getAttrsProxy(instance)
4854
},
55+
get slots() {
56+
return getSlotsProxy(instance)
57+
},
4958
get emit() {
5059
return (event: string, ...args: any[]) => instance.emit(event, ...args)
5160
},
@@ -57,6 +66,7 @@ export function createSetupContext(
5766
return getAttrsProxy(instance)
5867
},
5968
emit: instance.emit,
69+
slots: instance.slots,
6070
expose: NOOP,
6171
}
6272
}
@@ -102,9 +112,11 @@ export interface ComponentInternalInstance {
102112
emit: EmitFn
103113
emitted: Record<string, boolean> | null
104114
attrs: Data
115+
slots: InternalSlots
105116
refs: Data
106117

107-
attrsProxy: Data | null
118+
attrsProxy?: Data
119+
slotsProxy?: Slots
108120

109121
// lifecycle
110122
isMounted: boolean
@@ -188,6 +200,8 @@ let uid = 0
188200
export function createComponentInstance(
189201
component: ObjectComponent | FunctionalComponent,
190202
rawProps: RawProps | null,
203+
slots: Slots | null = null,
204+
dynamicSlots: DynamicSlots | null = null,
191205
// application root node only
192206
appContext: AppContext | null = null,
193207
): ComponentInternalInstance {
@@ -224,10 +238,9 @@ export function createComponentInstance(
224238
emit: null!,
225239
emitted: null,
226240
attrs: EMPTY_OBJ,
241+
slots: EMPTY_OBJ,
227242
refs: EMPTY_OBJ,
228243

229-
attrsProxy: null,
230-
231244
// lifecycle
232245
isMounted: false,
233246
isUnmounted: false,
@@ -283,6 +296,7 @@ export function createComponentInstance(
283296
// [VaporLifecycleHooks.SERVER_PREFETCH]: null,
284297
}
285298
initProps(instance, rawProps, !isFunction(component))
299+
initSlots(instance, slots, dynamicSlots)
286300
instance.emit = emit.bind(null, instance)
287301

288302
return instance
@@ -315,3 +329,17 @@ function getAttrsProxy(instance: ComponentInternalInstance): Data {
315329
))
316330
)
317331
}
332+
333+
/**
334+
* Dev-only
335+
*/
336+
function getSlotsProxy(instance: ComponentInternalInstance): Slots {
337+
return (
338+
instance.slotsProxy ||
339+
(instance.slotsProxy = new Proxy(instance.slots, {
340+
get(target, key: string) {
341+
return target[key]
342+
},
343+
}))
344+
)
345+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { type IfAny, extend, isArray } from '@vue/shared'
2+
import { baseWatch } from '@vue/reactivity'
3+
import type { ComponentInternalInstance } from './component'
4+
import type { Block } from './apiRender'
5+
import { createVaporPreScheduler } from './scheduler'
6+
7+
// TODO: SSR
8+
9+
export type Slot<T extends any = any> = (
10+
...args: IfAny<T, any[], [T] | (T extends undefined ? [] : never)>
11+
) => Block
12+
13+
export type InternalSlots = {
14+
[name: string]: Slot | undefined
15+
}
16+
17+
export type Slots = Readonly<InternalSlots>
18+
19+
export interface DynamicSlot {
20+
name: string
21+
fn: Slot
22+
key?: string
23+
}
24+
25+
export type DynamicSlots = () => (DynamicSlot | DynamicSlot[])[]
26+
27+
export const initSlots = (
28+
instance: ComponentInternalInstance,
29+
rawSlots: InternalSlots | null = null,
30+
dynamicSlots: DynamicSlots | null = null,
31+
) => {
32+
const slots: InternalSlots = extend({}, rawSlots)
33+
34+
if (dynamicSlots) {
35+
const dynamicSlotKeys: Record<string, true> = {}
36+
baseWatch(
37+
() => {
38+
const _dynamicSlots = dynamicSlots()
39+
for (let i = 0; i < _dynamicSlots.length; i++) {
40+
const slot = _dynamicSlots[i]
41+
// array of dynamic slot generated by <template v-for="..." #[...]>
42+
if (isArray(slot)) {
43+
for (let j = 0; j < slot.length; j++) {
44+
slots[slot[j].name] = slot[j].fn
45+
dynamicSlotKeys[slot[j].name] = true
46+
}
47+
} else if (slot) {
48+
// conditional single slot generated by <template v-if="..." #foo>
49+
slots[slot.name] = slot.key
50+
? (...args: any[]) => {
51+
const res = slot.fn(...args)
52+
// attach branch key so each conditional branch is considered a
53+
// different fragment
54+
if (res) (res as any).key = slot.key
55+
return res
56+
}
57+
: slot.fn
58+
dynamicSlotKeys[slot.name] = true
59+
}
60+
}
61+
// delete stale slots
62+
for (const key in dynamicSlotKeys) {
63+
if (
64+
!_dynamicSlots.some(slot =>
65+
isArray(slot)
66+
? slot.some(s => s.name === key)
67+
: slot?.name === key,
68+
)
69+
) {
70+
delete slots[key]
71+
}
72+
}
73+
},
74+
undefined,
75+
{ scheduler: createVaporPreScheduler(instance) },
76+
)
77+
}
78+
79+
instance.slots = slots
80+
}

‎playground/src/slots.js

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// @ts-check
2+
import {
3+
children,
4+
createComponent,
5+
defineComponent,
6+
insert,
7+
on,
8+
ref,
9+
renderEffect,
10+
setText,
11+
template,
12+
} from '@vue/vapor'
13+
14+
// <template #mySlot="{ message, changeMessage }">
15+
// <div clas="slotted">
16+
// <h1>{{ message }}</h1>
17+
// <button @click="changeMessage">btn parent</button>
18+
// </div>
19+
// </template>
20+
const t1 = template(
21+
'<div class="slotted"><h1><!></h1><button>parent btn</button></div>',
22+
)
23+
24+
const Parent = defineComponent({
25+
vapor: true,
26+
setup() {
27+
return (() => {
28+
/** @type {any} */
29+
const n0 = createComponent(
30+
Child,
31+
{},
32+
{
33+
mySlot: ({ message, changeMessage }) => {
34+
const n1 = t1()
35+
const n2 = /** @type {any} */ (children(n1, 0))
36+
const n3 = /** @type {any} */ (children(n1, 1))
37+
renderEffect(() => setText(n2, message()))
38+
on(n3, 'click', changeMessage)
39+
return n1
40+
},
41+
// e.g. default slot
42+
// default: () => {
43+
// const n1 = t1()
44+
// return n1
45+
// }
46+
},
47+
)
48+
return n0
49+
})()
50+
},
51+
})
52+
53+
const t2 = template(
54+
'<div class="child-container"><button>child btn</button></div>',
55+
)
56+
57+
const Child = defineComponent({
58+
vapor: true,
59+
setup(_, { slots }) {
60+
const message = ref('Hello World!')
61+
function changeMessage() {
62+
message.value += '!'
63+
}
64+
65+
return (() => {
66+
// <div>
67+
// <slot name="mySlot" :message="msg" :changeMessage="changeMessage" />
68+
// <button @click="changeMessage">button in child</button>
69+
// </div>
70+
const n0 = /** @type {any} */ (t2())
71+
const n1 = /** @type {any} */ (children(n0, 0))
72+
on(n1, 'click', () => changeMessage)
73+
const s0 = /** @type {any} */ (
74+
slots.mySlot?.({
75+
message: () => message.value,
76+
changeMessage: () => changeMessage,
77+
})
78+
)
79+
insert(s0, n0, n1)
80+
return n0
81+
})()
82+
},
83+
})
84+
85+
export default Parent

0 commit comments

Comments
 (0)
Please sign in to comment.