componentSlots.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. import {
  2. createApp,
  3. getCurrentInstance,
  4. h,
  5. nextTick,
  6. nodeOps,
  7. ref,
  8. render,
  9. serializeInner,
  10. useSlots,
  11. } from '@vue/runtime-test'
  12. import { createBlock, normalizeVNode } from '../src/vnode'
  13. import { createSlots } from '../src/helpers/createSlots'
  14. import { renderSlot } from '../src/helpers/renderSlot'
  15. import { setCurrentRenderingInstance } from '../src/componentRenderContext'
  16. describe('component: slots', () => {
  17. function renderWithSlots(slots: any): any {
  18. let instance: any
  19. const Comp = {
  20. render() {
  21. instance = getCurrentInstance()
  22. return h('div')
  23. },
  24. }
  25. render(h(Comp, null, slots), nodeOps.createElement('div'))
  26. return instance
  27. }
  28. test('initSlots: instance.slots should be set correctly', () => {
  29. let instance: any
  30. const Comp = {
  31. render() {
  32. instance = getCurrentInstance()
  33. return h('div')
  34. },
  35. }
  36. const slots = { foo: () => {}, _: 1 }
  37. render(createBlock(Comp, null, slots), nodeOps.createElement('div'))
  38. expect(instance.slots).toMatchObject(slots)
  39. })
  40. test('initSlots: instance.slots should remove compiler marker if parent is using manual render function', () => {
  41. const { slots } = renderWithSlots({ _: 1 })
  42. expect(slots).toMatchObject({})
  43. })
  44. test('initSlots: ensure compiler marker non-enumerable', () => {
  45. const Comp = {
  46. render() {
  47. const slots = useSlots()
  48. // Only user-defined slots should be enumerable
  49. expect(Object.keys(slots)).toEqual(['foo'])
  50. // Internal compiler markers must still exist but be non-enumerable
  51. expect(slots).toHaveProperty('_')
  52. expect(Object.getOwnPropertyDescriptor(slots, '_')!.enumerable).toBe(
  53. false,
  54. )
  55. return h('div')
  56. },
  57. }
  58. const slots = { foo: () => {}, _: 1 }
  59. render(createBlock(Comp, null, slots), nodeOps.createElement('div'))
  60. })
  61. test('initSlots: should normalize object slots (when value is null, string, array)', () => {
  62. const { slots } = renderWithSlots({
  63. _inner: '_inner',
  64. foo: null,
  65. header: 'header',
  66. footer: ['f1', 'f2'],
  67. })
  68. expect(
  69. '[Vue warn]: Non-function value encountered for slot "_inner". Prefer function slots for better performance.',
  70. ).toHaveBeenWarned()
  71. expect(
  72. '[Vue warn]: Non-function value encountered for slot "header". Prefer function slots for better performance.',
  73. ).toHaveBeenWarned()
  74. expect(
  75. '[Vue warn]: Non-function value encountered for slot "footer". Prefer function slots for better performance.',
  76. ).toHaveBeenWarned()
  77. expect(slots).not.toHaveProperty('foo')
  78. expect(slots._inner()).toMatchObject([normalizeVNode('_inner')])
  79. expect(slots.header()).toMatchObject([normalizeVNode('header')])
  80. expect(slots.footer()).toMatchObject([
  81. normalizeVNode('f1'),
  82. normalizeVNode('f2'),
  83. ])
  84. })
  85. test('initSlots: should normalize object slots (when value is function)', () => {
  86. let proxy: any
  87. const Comp = {
  88. render() {
  89. proxy = getCurrentInstance()
  90. return h('div')
  91. },
  92. }
  93. render(
  94. h(Comp, null, {
  95. header: () => 'header',
  96. }),
  97. nodeOps.createElement('div'),
  98. )
  99. expect(proxy.slots.header()).toMatchObject([normalizeVNode('header')])
  100. })
  101. test('initSlots: instance.slots should be set correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)', () => {
  102. const { slots } = renderWithSlots([h('span')])
  103. expect(
  104. '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
  105. ).toHaveBeenWarned()
  106. expect(slots.default()).toMatchObject([normalizeVNode(h('span'))])
  107. })
  108. test('updateSlots: instance.slots should be updated correctly (when slotType is number)', async () => {
  109. const flag1 = ref(true)
  110. let instance: any
  111. const Child = () => {
  112. instance = getCurrentInstance()
  113. return 'child'
  114. }
  115. const Comp = {
  116. setup() {
  117. return () => [
  118. h(
  119. Child,
  120. null,
  121. createSlots({ _: 2 as any }, [
  122. flag1.value
  123. ? {
  124. name: 'one',
  125. fn: () => [h('span')],
  126. }
  127. : {
  128. name: 'two',
  129. fn: () => [h('div')],
  130. },
  131. ]),
  132. ),
  133. ]
  134. },
  135. }
  136. render(h(Comp), nodeOps.createElement('div'))
  137. expect(instance.slots).toHaveProperty('one')
  138. expect(instance.slots).not.toHaveProperty('two')
  139. flag1.value = false
  140. await nextTick()
  141. expect(instance.slots).not.toHaveProperty('one')
  142. expect(instance.slots).toHaveProperty('two')
  143. })
  144. test('updateSlots: instance.slots should be updated correctly (when slotType is null)', async () => {
  145. const flag1 = ref(true)
  146. let instance: any
  147. const Child = () => {
  148. instance = getCurrentInstance()
  149. return 'child'
  150. }
  151. const oldSlots = {
  152. header: 'header',
  153. footer: undefined,
  154. }
  155. const newSlots = {
  156. header: undefined,
  157. footer: 'footer',
  158. }
  159. const Comp = {
  160. setup() {
  161. return () => [
  162. h(Child, { n: flag1.value }, flag1.value ? oldSlots : newSlots),
  163. ]
  164. },
  165. }
  166. render(h(Comp), nodeOps.createElement('div'))
  167. expect(instance.slots).toHaveProperty('header')
  168. expect(instance.slots).not.toHaveProperty('footer')
  169. flag1.value = false
  170. await nextTick()
  171. expect(
  172. '[Vue warn]: Non-function value encountered for slot "header". Prefer function slots for better performance.',
  173. ).toHaveBeenWarned()
  174. expect(
  175. '[Vue warn]: Non-function value encountered for slot "footer". Prefer function slots for better performance.',
  176. ).toHaveBeenWarned()
  177. expect(instance.slots).not.toHaveProperty('header')
  178. expect(instance.slots.footer()).toMatchObject([normalizeVNode('footer')])
  179. })
  180. test('updateSlots: instance.slots should be update correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)', async () => {
  181. const flag1 = ref(true)
  182. let instance: any
  183. const Child = () => {
  184. instance = getCurrentInstance()
  185. return 'child'
  186. }
  187. const Comp = {
  188. setup() {
  189. return () => [
  190. h(Child, { n: flag1.value }, flag1.value ? ['header'] : ['footer']),
  191. ]
  192. },
  193. }
  194. render(h(Comp), nodeOps.createElement('div'))
  195. expect(instance.slots.default()).toMatchObject([normalizeVNode('header')])
  196. flag1.value = false
  197. await nextTick()
  198. expect(
  199. '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
  200. ).toHaveBeenWarned()
  201. expect(instance.slots.default()).toMatchObject([normalizeVNode('footer')])
  202. })
  203. test('should respect $stable flag with a value of true', async () => {
  204. const flag1 = ref(1)
  205. const flag2 = ref(2)
  206. const spy = vi.fn()
  207. const Child = () => {
  208. spy()
  209. return 'child'
  210. }
  211. const App = {
  212. setup() {
  213. return () => [
  214. flag1.value,
  215. h(
  216. Child,
  217. { n: flag2.value },
  218. {
  219. foo: () => 'foo',
  220. $stable: true,
  221. },
  222. ),
  223. ]
  224. },
  225. }
  226. render(h(App), nodeOps.createElement('div'))
  227. expect(spy).toHaveBeenCalledTimes(1)
  228. // parent re-render, props didn't change, slots are stable
  229. // -> child should not update
  230. flag1.value++
  231. await nextTick()
  232. expect(spy).toHaveBeenCalledTimes(1)
  233. // parent re-render, props changed
  234. // -> child should update
  235. flag2.value++
  236. await nextTick()
  237. expect(spy).toHaveBeenCalledTimes(2)
  238. })
  239. test('should respect $stable flag with a value of false', async () => {
  240. const flag1 = ref(1)
  241. const flag2 = ref(2)
  242. const spy = vi.fn()
  243. const Child = () => {
  244. spy()
  245. return 'child'
  246. }
  247. const App = {
  248. setup() {
  249. return () => [
  250. flag1.value,
  251. h(
  252. Child,
  253. { n: flag2.value },
  254. {
  255. foo: () => 'foo',
  256. $stable: false,
  257. },
  258. ),
  259. ]
  260. },
  261. }
  262. render(h(App), nodeOps.createElement('div'))
  263. expect(spy).toHaveBeenCalledTimes(1)
  264. // parent re-render, props didn't change, slots are not stable
  265. // -> child should update
  266. flag1.value++
  267. await nextTick()
  268. expect(spy).toHaveBeenCalledTimes(2)
  269. // parent re-render, props changed
  270. // -> child should update
  271. flag2.value++
  272. await nextTick()
  273. expect(spy).toHaveBeenCalledTimes(3)
  274. })
  275. test('should not warn when mounting another app in setup', () => {
  276. const Comp = {
  277. setup(_: any, { slots }: any) {
  278. return () => slots.default?.()
  279. },
  280. }
  281. const mountComp = () => {
  282. createApp({
  283. setup() {
  284. return () => h(Comp, () => 'msg')
  285. },
  286. }).mount(nodeOps.createElement('div'))
  287. }
  288. const App = {
  289. setup() {
  290. mountComp()
  291. return () => null
  292. },
  293. }
  294. createApp(App).mount(nodeOps.createElement('div'))
  295. expect(
  296. 'Slot "default" invoked outside of the render function',
  297. ).not.toHaveBeenWarned()
  298. })
  299. test('basic warn', () => {
  300. const Comp = {
  301. setup(_: any, { slots }: any) {
  302. slots.default && slots.default()
  303. return () => null
  304. },
  305. }
  306. const App = {
  307. setup() {
  308. return () => h(Comp, () => h('div'))
  309. },
  310. }
  311. createApp(App).mount(nodeOps.createElement('div'))
  312. expect(
  313. 'Slot "default" invoked outside of the render function',
  314. ).toHaveBeenWarned()
  315. })
  316. test('basic warn when mounting another app in setup', () => {
  317. const Comp = {
  318. setup(_: any, { slots }: any) {
  319. slots.default?.()
  320. return () => null
  321. },
  322. }
  323. const mountComp = () => {
  324. createApp({
  325. setup() {
  326. return () => h(Comp, () => 'msg')
  327. },
  328. }).mount(nodeOps.createElement('div'))
  329. }
  330. const App = {
  331. setup() {
  332. mountComp()
  333. return () => null
  334. },
  335. }
  336. createApp(App).mount(nodeOps.createElement('div'))
  337. expect(
  338. 'Slot "default" invoked outside of the render function',
  339. ).toHaveBeenWarned()
  340. })
  341. test('should not warn when render in setup', () => {
  342. const container = {
  343. setup(_: any, { slots }: any) {
  344. return () => slots.default && slots.default()
  345. },
  346. }
  347. const comp = h(container, null, () => h('div'))
  348. const App = {
  349. setup() {
  350. render(h(comp), nodeOps.createElement('div'))
  351. return () => null
  352. },
  353. }
  354. createApp(App).mount(nodeOps.createElement('div'))
  355. expect(
  356. 'Slot "default" invoked outside of the render function',
  357. ).not.toHaveBeenWarned()
  358. })
  359. test('basic warn when render in setup', () => {
  360. const container = {
  361. setup(_: any, { slots }: any) {
  362. slots.default && slots.default()
  363. return () => null
  364. },
  365. }
  366. const comp = h(container, null, () => h('div'))
  367. const App = {
  368. setup() {
  369. render(h(comp), nodeOps.createElement('div'))
  370. return () => null
  371. },
  372. }
  373. createApp(App).mount(nodeOps.createElement('div'))
  374. expect(
  375. 'Slot "default" invoked outside of the render function',
  376. ).toHaveBeenWarned()
  377. })
  378. test('slot name starts with underscore', () => {
  379. const Comp = {
  380. setup(_: any, { slots }: any) {
  381. return () => slots._foo()
  382. },
  383. }
  384. const App = {
  385. setup() {
  386. return () => h(Comp, null, { _foo: () => 'foo' })
  387. },
  388. }
  389. const root = nodeOps.createElement('div')
  390. createApp(App).mount(root)
  391. expect(serializeInner(root)).toBe('foo')
  392. })
  393. // in-DOM templates use kebab-case slot names
  394. describe('in-DOM template kebab-case slot name resolution', () => {
  395. beforeEach(() => {
  396. __BROWSER__ = true
  397. })
  398. afterEach(() => {
  399. __BROWSER__ = false
  400. })
  401. test('should resolve camelCase slot access to kebab-case via slots', () => {
  402. const Comp = {
  403. setup(_: any, { slots }: any) {
  404. // Access with camelCase, but slot is passed with kebab-case
  405. return () => slots.dropdownRender()
  406. },
  407. }
  408. const App = {
  409. setup() {
  410. // Parent passes slot with kebab-case name (simulating in-DOM template)
  411. return () =>
  412. h(Comp, null, { 'dropdown-render': () => 'dropdown content' })
  413. },
  414. }
  415. const root = nodeOps.createElement('div')
  416. createApp(App).mount(root)
  417. expect(serializeInner(root)).toBe('dropdown content')
  418. })
  419. test('should resolve camelCase slot access to kebab-case via slots (PROD)', () => {
  420. __DEV__ = false
  421. try {
  422. const Comp = {
  423. setup(_: any, { slots }: any) {
  424. // Access with camelCase, but slot is passed with kebab-case
  425. return () => slots.dropdownRender()
  426. },
  427. }
  428. const App = {
  429. setup() {
  430. // Parent passes slot with kebab-case name (simulating in-DOM template)
  431. return () =>
  432. h(Comp, null, { 'dropdown-render': () => 'dropdown content' })
  433. },
  434. }
  435. const root = nodeOps.createElement('div')
  436. createApp(App).mount(root)
  437. expect(serializeInner(root)).toBe('dropdown content')
  438. } finally {
  439. __DEV__ = true
  440. }
  441. })
  442. test('should prefer exact match over kebab-case conversion via slots', () => {
  443. const Comp = {
  444. setup(_: any, { slots }: any) {
  445. return () => slots.dropdownRender()
  446. },
  447. }
  448. const App = {
  449. setup() {
  450. // Both exact match and kebab-case exist
  451. return () =>
  452. h(Comp, null, {
  453. 'dropdown-render': () => 'kebab',
  454. dropdownRender: () => 'exact',
  455. })
  456. },
  457. }
  458. const root = nodeOps.createElement('div')
  459. createApp(App).mount(root)
  460. // exact match should take priority
  461. expect(serializeInner(root)).toBe('exact')
  462. })
  463. // renderSlot tests
  464. describe('renderSlot', () => {
  465. beforeEach(() => {
  466. setCurrentRenderingInstance({ type: {} } as any)
  467. })
  468. afterEach(() => {
  469. setCurrentRenderingInstance(null)
  470. })
  471. test('should resolve camelCase slot name to kebab-case via renderSlot', () => {
  472. let child: any
  473. const vnode = renderSlot(
  474. { 'dropdown-render': () => [(child = h('child'))] },
  475. 'dropdownRender',
  476. )
  477. expect(vnode.children).toEqual([child])
  478. })
  479. test('should prefer exact match over kebab-case conversion via renderSlot', () => {
  480. let exactChild: any
  481. const vnode = renderSlot(
  482. {
  483. 'dropdown-render': () => [h('kebab')],
  484. dropdownRender: () => [(exactChild = h('exact'))],
  485. },
  486. 'dropdownRender',
  487. )
  488. expect(vnode.children).toEqual([exactChild])
  489. })
  490. })
  491. })
  492. })