componentEmits.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. // Note: emits and listener fallthrough is tested in
  2. // ./rendererAttrsFallthrough.spec.ts.
  3. import {
  4. type ComponentPublicInstance,
  5. createApp,
  6. defineComponent,
  7. h,
  8. nextTick,
  9. nodeOps,
  10. render,
  11. toHandlers,
  12. } from '@vue/runtime-test'
  13. import { isEmitListener } from '../src/componentEmits'
  14. describe('component: emit', () => {
  15. test('trigger handlers', () => {
  16. const Foo = defineComponent({
  17. render() {},
  18. created() {
  19. // the `emit` function is bound on component instances
  20. this.$emit('foo')
  21. this.$emit('bar')
  22. this.$emit('!baz')
  23. },
  24. })
  25. const onfoo = vi.fn()
  26. const onBar = vi.fn()
  27. const onBaz = vi.fn()
  28. const Comp = () => h(Foo, { onfoo, onBar, ['on!baz']: onBaz })
  29. render(h(Comp), nodeOps.createElement('div'))
  30. expect(onfoo).not.toHaveBeenCalled()
  31. // only capitalized or special chars are considered event listeners
  32. expect(onBar).toHaveBeenCalled()
  33. expect(onBaz).toHaveBeenCalled()
  34. })
  35. test('trigger camelCase handler', () => {
  36. const Foo = defineComponent({
  37. render() {},
  38. created() {
  39. this.$emit('test-event')
  40. },
  41. })
  42. const fooSpy = vi.fn()
  43. const Comp = () =>
  44. h(Foo, {
  45. onTestEvent: fooSpy,
  46. })
  47. render(h(Comp), nodeOps.createElement('div'))
  48. expect(fooSpy).toHaveBeenCalledTimes(1)
  49. })
  50. test('trigger kebab-case handler', () => {
  51. const Foo = defineComponent({
  52. render() {},
  53. created() {
  54. this.$emit('test-event')
  55. },
  56. })
  57. const fooSpy = vi.fn()
  58. const Comp = () =>
  59. h(Foo, {
  60. 'onTest-event': fooSpy,
  61. })
  62. render(h(Comp), nodeOps.createElement('div'))
  63. expect(fooSpy).toHaveBeenCalledTimes(1)
  64. })
  65. // #3527
  66. test('trigger mixed case handlers', () => {
  67. const Foo = defineComponent({
  68. render() {},
  69. created() {
  70. this.$emit('test-event')
  71. this.$emit('testEvent')
  72. },
  73. })
  74. const fooSpy = vi.fn()
  75. const barSpy = vi.fn()
  76. const Comp = () =>
  77. // simulate v-on="obj" usage
  78. h(
  79. Foo,
  80. toHandlers({
  81. 'test-event': fooSpy,
  82. testEvent: barSpy,
  83. }),
  84. )
  85. render(h(Comp), nodeOps.createElement('div'))
  86. expect(fooSpy).toHaveBeenCalledTimes(1)
  87. expect(barSpy).toHaveBeenCalledTimes(1)
  88. })
  89. // for v-model:foo-bar usage in DOM templates
  90. test('trigger hyphenated events for update:xxx events', () => {
  91. const Foo = defineComponent({
  92. render() {},
  93. created() {
  94. this.$emit('update:fooProp')
  95. this.$emit('update:barProp')
  96. },
  97. })
  98. const fooSpy = vi.fn()
  99. const barSpy = vi.fn()
  100. const Comp = () =>
  101. h(Foo, {
  102. 'onUpdate:fooProp': fooSpy,
  103. 'onUpdate:bar-prop': barSpy,
  104. })
  105. render(h(Comp), nodeOps.createElement('div'))
  106. expect(fooSpy).toHaveBeenCalled()
  107. expect(barSpy).toHaveBeenCalled()
  108. })
  109. test('should trigger array of listeners', async () => {
  110. const Child = defineComponent({
  111. setup(_, { emit }) {
  112. emit('foo', 1)
  113. return () => h('div')
  114. },
  115. })
  116. const fn1 = vi.fn()
  117. const fn2 = vi.fn()
  118. const App = {
  119. setup() {
  120. return () =>
  121. h(Child, {
  122. onFoo: [fn1, fn2],
  123. })
  124. },
  125. }
  126. render(h(App), nodeOps.createElement('div'))
  127. expect(fn1).toHaveBeenCalledTimes(1)
  128. expect(fn1).toHaveBeenCalledWith(1)
  129. expect(fn2).toHaveBeenCalledTimes(1)
  130. expect(fn2).toHaveBeenCalledWith(1)
  131. })
  132. test('warning for undeclared event (array)', () => {
  133. const Foo = defineComponent({
  134. emits: ['foo'],
  135. render() {},
  136. created() {
  137. // @ts-expect-error
  138. this.$emit('bar-baz')
  139. },
  140. })
  141. render(h(Foo), nodeOps.createElement('div'))
  142. expect(
  143. `Component emitted event "bar-baz" but it is neither declared in the emits option nor as an "onBarBaz" prop`,
  144. ).toHaveBeenWarned()
  145. })
  146. test('warning for undeclared event (object)', () => {
  147. const Foo = defineComponent({
  148. emits: {
  149. foo: null,
  150. },
  151. render() {},
  152. created() {
  153. // @ts-expect-error
  154. this.$emit('bar-baz')
  155. },
  156. })
  157. render(h(Foo), nodeOps.createElement('div'))
  158. expect(
  159. `Component emitted event "bar-baz" but it is neither declared in the emits option nor as an "onBarBaz" prop`,
  160. ).toHaveBeenWarned()
  161. })
  162. test('should not warn if has equivalent onXXX prop', () => {
  163. const Foo = defineComponent({
  164. props: ['onFoo'],
  165. emits: [],
  166. render() {},
  167. created() {
  168. // @ts-expect-error
  169. this.$emit('foo')
  170. },
  171. })
  172. render(h(Foo), nodeOps.createElement('div'))
  173. expect(
  174. `Component emitted event "foo" but it is neither declared`,
  175. ).not.toHaveBeenWarned()
  176. })
  177. test('should not warn if has equivalent onXXX prop with kebab-cased event', () => {
  178. const Foo = defineComponent({
  179. props: ['onFooBar'],
  180. emits: [],
  181. render() {},
  182. created() {
  183. // @ts-expect-error
  184. this.$emit('foo-bar')
  185. },
  186. })
  187. render(h(Foo), nodeOps.createElement('div'))
  188. expect(
  189. `Component emitted event "foo-bar" but it is neither declared`,
  190. ).not.toHaveBeenWarned()
  191. })
  192. test('validator warning', () => {
  193. const Foo = defineComponent({
  194. emits: {
  195. foo: (arg: number) => arg > 0,
  196. },
  197. render() {},
  198. created() {
  199. this.$emit('foo', -1)
  200. },
  201. })
  202. render(h(Foo), nodeOps.createElement('div'))
  203. expect(`event validation failed for event "foo"`).toHaveBeenWarned()
  204. })
  205. test('merging from mixins', () => {
  206. const mixin = {
  207. emits: {
  208. foo: (arg: number) => arg > 0,
  209. },
  210. }
  211. const Foo = defineComponent({
  212. mixins: [mixin],
  213. render() {},
  214. created() {
  215. this.$emit('foo', -1)
  216. },
  217. })
  218. render(h(Foo), nodeOps.createElement('div'))
  219. expect(`event validation failed for event "foo"`).toHaveBeenWarned()
  220. })
  221. // #2651
  222. test('should not attach normalized object when mixins do not contain emits', () => {
  223. const Foo = defineComponent({
  224. mixins: [{}],
  225. render() {},
  226. created() {
  227. this.$emit('foo')
  228. },
  229. })
  230. render(h(Foo), nodeOps.createElement('div'))
  231. expect(
  232. `Component emitted event "foo" but it is neither declared`,
  233. ).not.toHaveBeenWarned()
  234. })
  235. test('.once', () => {
  236. const Foo = defineComponent({
  237. render() {},
  238. emits: {
  239. foo: null,
  240. bar: null,
  241. },
  242. created() {
  243. this.$emit('foo')
  244. this.$emit('foo')
  245. this.$emit('bar')
  246. this.$emit('bar')
  247. },
  248. })
  249. const fn = vi.fn()
  250. const barFn = vi.fn()
  251. render(
  252. h(Foo, {
  253. onFooOnce: fn,
  254. onBarOnce: barFn,
  255. }),
  256. nodeOps.createElement('div'),
  257. )
  258. expect(fn).toHaveBeenCalledTimes(1)
  259. expect(barFn).toHaveBeenCalledTimes(1)
  260. })
  261. test('.once with normal listener of the same name', () => {
  262. const Foo = defineComponent({
  263. render() {},
  264. emits: {
  265. foo: null,
  266. },
  267. created() {
  268. this.$emit('foo')
  269. this.$emit('foo')
  270. },
  271. })
  272. const onFoo = vi.fn()
  273. const onFooOnce = vi.fn()
  274. render(
  275. h(Foo, {
  276. onFoo,
  277. onFooOnce,
  278. }),
  279. nodeOps.createElement('div'),
  280. )
  281. expect(onFoo).toHaveBeenCalledTimes(2)
  282. expect(onFooOnce).toHaveBeenCalledTimes(1)
  283. })
  284. test('.number modifier should work with v-model on component', () => {
  285. const Foo = defineComponent({
  286. render() {},
  287. created() {
  288. this.$emit('update:modelValue', '1')
  289. this.$emit('update:foo', '2')
  290. },
  291. })
  292. const fn1 = vi.fn()
  293. const fn2 = vi.fn()
  294. const Comp = () =>
  295. h(Foo, {
  296. modelValue: null,
  297. modelModifiers: { number: true },
  298. 'onUpdate:modelValue': fn1,
  299. foo: null,
  300. fooModifiers: { number: true },
  301. 'onUpdate:foo': fn2,
  302. })
  303. render(h(Comp), nodeOps.createElement('div'))
  304. expect(fn1).toHaveBeenCalledTimes(1)
  305. expect(fn1).toHaveBeenCalledWith(1)
  306. expect(fn2).toHaveBeenCalledTimes(1)
  307. expect(fn2).toHaveBeenCalledWith(2)
  308. })
  309. test('.trim modifier should work with v-model on component', () => {
  310. const Foo = defineComponent({
  311. render() {},
  312. created() {
  313. this.$emit('update:modelValue', ' one ')
  314. this.$emit('update:foo', ' two ')
  315. },
  316. })
  317. const fn1 = vi.fn()
  318. const fn2 = vi.fn()
  319. const Comp = () =>
  320. h(Foo, {
  321. modelValue: null,
  322. modelModifiers: { trim: true },
  323. 'onUpdate:modelValue': fn1,
  324. foo: null,
  325. fooModifiers: { trim: true },
  326. 'onUpdate:foo': fn2,
  327. })
  328. render(h(Comp), nodeOps.createElement('div'))
  329. expect(fn1).toHaveBeenCalledTimes(1)
  330. expect(fn1).toHaveBeenCalledWith('one')
  331. expect(fn2).toHaveBeenCalledTimes(1)
  332. expect(fn2).toHaveBeenCalledWith('two')
  333. })
  334. test('.trim modifier should work with v-model on component for kebab-cased props and camelCased emit', () => {
  335. const Foo = defineComponent({
  336. render() {},
  337. created() {
  338. this.$emit('update:firstName', ' one ')
  339. },
  340. })
  341. const fn1 = vi.fn()
  342. const Comp = () =>
  343. h(Foo, {
  344. 'first-name': null,
  345. 'first-nameModifiers': { trim: true },
  346. 'onUpdate:first-name': fn1,
  347. })
  348. render(h(Comp), nodeOps.createElement('div'))
  349. expect(fn1).toHaveBeenCalledTimes(1)
  350. expect(fn1).toHaveBeenCalledWith('one')
  351. })
  352. test('.trim modifier should work with v-model on component for camelCased props and kebab-cased emit', () => {
  353. const Foo = defineComponent({
  354. render() {},
  355. created() {
  356. this.$emit('update:model-value', ' one ')
  357. this.$emit('update:first-name', ' two ')
  358. },
  359. })
  360. const fn1 = vi.fn()
  361. const fn2 = vi.fn()
  362. const Comp = () =>
  363. h(Foo, {
  364. modelValue: null,
  365. modelModifiers: { trim: true },
  366. 'onUpdate:modelValue': fn1,
  367. firstName: null,
  368. firstNameModifiers: { trim: true },
  369. 'onUpdate:firstName': fn2,
  370. })
  371. render(h(Comp), nodeOps.createElement('div'))
  372. expect(fn1).toHaveBeenCalledTimes(1)
  373. expect(fn1).toHaveBeenCalledWith('one')
  374. expect(fn2).toHaveBeenCalledTimes(1)
  375. expect(fn2).toHaveBeenCalledWith('two')
  376. })
  377. test('.trim modifier should work with v-model on component for mixed cased props and emit', () => {
  378. const Foo = defineComponent({
  379. render() {},
  380. created() {
  381. this.$emit('update:base-URL', ' one ')
  382. },
  383. })
  384. const fn1 = vi.fn()
  385. const Comp = () =>
  386. h(Foo, {
  387. 'base-URL': null,
  388. 'base-URLModifiers': { trim: true },
  389. 'onUpdate:base-URL': fn1,
  390. })
  391. render(h(Comp), nodeOps.createElement('div'))
  392. expect(fn1).toHaveBeenCalledTimes(1)
  393. expect(fn1).toHaveBeenCalledWith('one')
  394. })
  395. test('.trim and .number modifiers should work with v-model on component', () => {
  396. const Foo = defineComponent({
  397. render() {},
  398. created() {
  399. this.$emit('update:modelValue', ' +01.2 ')
  400. this.$emit('update:foo', ' 1 ')
  401. },
  402. })
  403. const fn1 = vi.fn()
  404. const fn2 = vi.fn()
  405. const Comp = () =>
  406. h(Foo, {
  407. modelValue: null,
  408. modelModifiers: { trim: true, number: true },
  409. 'onUpdate:modelValue': fn1,
  410. foo: null,
  411. fooModifiers: { trim: true, number: true },
  412. 'onUpdate:foo': fn2,
  413. })
  414. render(h(Comp), nodeOps.createElement('div'))
  415. expect(fn1).toHaveBeenCalledTimes(1)
  416. expect(fn1).toHaveBeenCalledWith(1.2)
  417. expect(fn2).toHaveBeenCalledTimes(1)
  418. expect(fn2).toHaveBeenCalledWith(1)
  419. })
  420. test('only trim string parameter when work with v-model on component', () => {
  421. const Foo = defineComponent({
  422. render() {},
  423. created() {
  424. this.$emit('update:modelValue', ' foo ', { bar: ' bar ' })
  425. },
  426. })
  427. const fn = vi.fn()
  428. const Comp = () =>
  429. h(Foo, {
  430. modelValue: null,
  431. modelModifiers: { trim: true },
  432. 'onUpdate:modelValue': fn,
  433. })
  434. render(h(Comp), nodeOps.createElement('div'))
  435. expect(fn).toHaveBeenCalledTimes(1)
  436. expect(fn).toHaveBeenCalledWith('foo', { bar: ' bar ' })
  437. })
  438. test('isEmitListener', () => {
  439. const options = {
  440. click: null,
  441. 'test-event': null,
  442. fooBar: null,
  443. FooBaz: null,
  444. }
  445. expect(isEmitListener(options, 'onClick')).toBe(true)
  446. expect(isEmitListener(options, 'onclick')).toBe(false)
  447. expect(isEmitListener(options, 'onBlick')).toBe(false)
  448. // .once listeners
  449. expect(isEmitListener(options, 'onClickOnce')).toBe(true)
  450. expect(isEmitListener(options, 'onclickOnce')).toBe(false)
  451. // kebab-case option
  452. expect(isEmitListener(options, 'onTestEvent')).toBe(true)
  453. // camelCase option
  454. expect(isEmitListener(options, 'onFooBar')).toBe(true)
  455. // PascalCase option
  456. expect(isEmitListener(options, 'onFooBaz')).toBe(true)
  457. })
  458. test('does not emit after unmount', async () => {
  459. const fn = vi.fn()
  460. const Foo = defineComponent({
  461. emits: ['closing'],
  462. async beforeUnmount() {
  463. await this.$nextTick()
  464. this.$emit('closing', true)
  465. },
  466. render() {
  467. return h('div')
  468. },
  469. })
  470. const Comp = () =>
  471. h(Foo, {
  472. onClosing: fn,
  473. })
  474. const el = nodeOps.createElement('div')
  475. render(h(Comp), el)
  476. await nextTick()
  477. render(null, el)
  478. await nextTick()
  479. expect(fn).not.toHaveBeenCalled()
  480. })
  481. test('merge string array emits', async () => {
  482. const ComponentA = defineComponent({
  483. emits: ['one', 'two'],
  484. })
  485. const ComponentB = defineComponent({
  486. emits: ['three'],
  487. })
  488. const renderFn = vi.fn(function (this: ComponentPublicInstance) {
  489. expect(this.$options.emits).toEqual(['one', 'two', 'three'])
  490. return h('div')
  491. })
  492. const ComponentC = defineComponent({
  493. render: renderFn,
  494. mixins: [ComponentA, ComponentB],
  495. })
  496. const el = nodeOps.createElement('div')
  497. expect(renderFn).toHaveBeenCalledTimes(0)
  498. render(h(ComponentC), el)
  499. expect(renderFn).toHaveBeenCalledTimes(1)
  500. })
  501. test('merge object emits', async () => {
  502. const twoFn = vi.fn((v: unknown) => !v)
  503. const ComponentA = defineComponent({
  504. emits: {
  505. one: null,
  506. two: twoFn,
  507. },
  508. })
  509. const ComponentB = defineComponent({
  510. emits: ['three'],
  511. })
  512. const renderFn = vi.fn(function (this: ComponentPublicInstance) {
  513. expect(this.$options.emits).toEqual({
  514. one: null,
  515. two: twoFn,
  516. three: null,
  517. })
  518. expect(this.$options.emits.two).toBe(twoFn)
  519. return h('div')
  520. })
  521. const ComponentC = defineComponent({
  522. render: renderFn,
  523. mixins: [ComponentA, ComponentB],
  524. })
  525. const el = nodeOps.createElement('div')
  526. expect(renderFn).toHaveBeenCalledTimes(0)
  527. render(h(ComponentC), el)
  528. expect(renderFn).toHaveBeenCalledTimes(1)
  529. })
  530. test('merging emits for a component that is also used as a mixin', () => {
  531. const render = () => h('div')
  532. const CompA = {
  533. render,
  534. }
  535. const validateByMixin = vi.fn(() => true)
  536. const validateByGlobalMixin = vi.fn(() => true)
  537. const mixin = {
  538. emits: {
  539. one: validateByMixin,
  540. },
  541. }
  542. const CompB = defineComponent({
  543. mixins: [mixin, CompA],
  544. created(this) {
  545. this.$emit('one', 1)
  546. },
  547. render,
  548. })
  549. const app = createApp({
  550. render() {
  551. return [h(CompA), h(CompB)]
  552. },
  553. })
  554. app.mixin({
  555. emits: {
  556. one: validateByGlobalMixin,
  557. two: null,
  558. },
  559. })
  560. const root = nodeOps.createElement('div')
  561. app.mount(root)
  562. expect(validateByMixin).toHaveBeenCalledTimes(1)
  563. expect(validateByGlobalMixin).not.toHaveBeenCalled()
  564. })
  565. })