componentEmits.spec.ts 15 KB

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