apiSetupHelpers.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. import {
  2. ComponentInternalInstance,
  3. createApp,
  4. defineComponent,
  5. getCurrentInstance,
  6. h,
  7. nodeOps,
  8. onMounted,
  9. render,
  10. serializeInner,
  11. SetupContext,
  12. Suspense,
  13. computed,
  14. ComputedRef,
  15. shallowReactive,
  16. nextTick,
  17. ref
  18. } from '@vue/runtime-test'
  19. import {
  20. defineEmits,
  21. defineProps,
  22. defineExpose,
  23. withDefaults,
  24. useAttrs,
  25. useSlots,
  26. mergeDefaults,
  27. withAsyncContext,
  28. createPropsRestProxy,
  29. mergeModels,
  30. useModel
  31. } from '../src/apiSetupHelpers'
  32. describe('SFC <script setup> helpers', () => {
  33. test('should warn runtime usage', () => {
  34. defineProps()
  35. expect(`defineProps() is a compiler-hint`).toHaveBeenWarned()
  36. defineEmits()
  37. expect(`defineEmits() is a compiler-hint`).toHaveBeenWarned()
  38. defineExpose()
  39. expect(`defineExpose() is a compiler-hint`).toHaveBeenWarned()
  40. withDefaults({}, {})
  41. expect(`withDefaults() is a compiler-hint`).toHaveBeenWarned()
  42. })
  43. test('useSlots / useAttrs (no args)', () => {
  44. let slots: SetupContext['slots'] | undefined
  45. let attrs: SetupContext['attrs'] | undefined
  46. const Comp = {
  47. setup() {
  48. slots = useSlots()
  49. attrs = useAttrs()
  50. return () => {}
  51. }
  52. }
  53. const passedAttrs = { id: 'foo' }
  54. const passedSlots = {
  55. default: () => {},
  56. x: () => {}
  57. }
  58. render(h(Comp, passedAttrs, passedSlots), nodeOps.createElement('div'))
  59. expect(typeof slots!.default).toBe('function')
  60. expect(typeof slots!.x).toBe('function')
  61. expect(attrs).toMatchObject(passedAttrs)
  62. })
  63. test('useSlots / useAttrs (with args)', () => {
  64. let slots: SetupContext['slots'] | undefined
  65. let attrs: SetupContext['attrs'] | undefined
  66. let ctx: SetupContext | undefined
  67. const Comp = defineComponent({
  68. setup(_, _ctx) {
  69. slots = useSlots()
  70. attrs = useAttrs()
  71. ctx = _ctx
  72. return () => {}
  73. }
  74. })
  75. render(h(Comp), nodeOps.createElement('div'))
  76. expect(slots).toBe(ctx!.slots)
  77. expect(attrs).toBe(ctx!.attrs)
  78. })
  79. describe('mergeDefaults', () => {
  80. test('object syntax', () => {
  81. const merged = mergeDefaults(
  82. {
  83. foo: null,
  84. bar: { type: String, required: false },
  85. baz: String
  86. },
  87. {
  88. foo: 1,
  89. bar: 'baz',
  90. baz: 'qux'
  91. }
  92. )
  93. expect(merged).toMatchObject({
  94. foo: { default: 1 },
  95. bar: { type: String, required: false, default: 'baz' },
  96. baz: { type: String, default: 'qux' }
  97. })
  98. })
  99. test('array syntax', () => {
  100. const merged = mergeDefaults(['foo', 'bar', 'baz'], {
  101. foo: 1,
  102. bar: 'baz',
  103. baz: 'qux'
  104. })
  105. expect(merged).toMatchObject({
  106. foo: { default: 1 },
  107. bar: { default: 'baz' },
  108. baz: { default: 'qux' }
  109. })
  110. })
  111. test('merging with skipFactory', () => {
  112. const fn = () => {}
  113. const merged = mergeDefaults(['foo', 'bar', 'baz'], {
  114. foo: fn,
  115. __skip_foo: true
  116. })
  117. expect(merged).toMatchObject({
  118. foo: { default: fn, skipFactory: true }
  119. })
  120. })
  121. test('should warn missing', () => {
  122. mergeDefaults({}, { foo: 1 })
  123. expect(
  124. `props default key "foo" has no corresponding declaration`
  125. ).toHaveBeenWarned()
  126. })
  127. })
  128. describe('mergeModels', () => {
  129. test('array syntax', () => {
  130. expect(mergeModels(['foo', 'bar'], ['baz'])).toMatchObject([
  131. 'foo',
  132. 'bar',
  133. 'baz'
  134. ])
  135. })
  136. test('object syntax', () => {
  137. expect(
  138. mergeModels({ foo: null, bar: { required: true } }, ['baz'])
  139. ).toMatchObject({
  140. foo: null,
  141. bar: { required: true },
  142. baz: {}
  143. })
  144. expect(
  145. mergeModels(['baz'], { foo: null, bar: { required: true } })
  146. ).toMatchObject({
  147. foo: null,
  148. bar: { required: true },
  149. baz: {}
  150. })
  151. })
  152. test('overwrite', () => {
  153. expect(
  154. mergeModels(
  155. { foo: null, bar: { required: true } },
  156. { bar: {}, baz: {} }
  157. )
  158. ).toMatchObject({
  159. foo: null,
  160. bar: {},
  161. baz: {}
  162. })
  163. })
  164. })
  165. describe('useModel', () => {
  166. test('basic', async () => {
  167. let foo: any
  168. const update = () => {
  169. foo.value = 'bar'
  170. }
  171. const Comp = defineComponent({
  172. props: ['modelValue'],
  173. emits: ['update:modelValue'],
  174. setup(props) {
  175. foo = useModel(props, 'modelValue')
  176. },
  177. render() {}
  178. })
  179. const msg = ref('')
  180. const setValue = vi.fn(v => (msg.value = v))
  181. const root = nodeOps.createElement('div')
  182. createApp(() =>
  183. h(Comp, {
  184. modelValue: msg.value,
  185. 'onUpdate:modelValue': setValue
  186. })
  187. ).mount(root)
  188. expect(foo.value).toBe('')
  189. expect(msg.value).toBe('')
  190. expect(setValue).not.toBeCalled()
  191. // update from child
  192. update()
  193. await nextTick()
  194. expect(msg.value).toBe('bar')
  195. expect(foo.value).toBe('bar')
  196. expect(setValue).toBeCalledTimes(1)
  197. // update from parent
  198. msg.value = 'qux'
  199. await nextTick()
  200. expect(msg.value).toBe('qux')
  201. expect(foo.value).toBe('qux')
  202. expect(setValue).toBeCalledTimes(1)
  203. })
  204. test('local', async () => {
  205. let foo: any
  206. const update = () => {
  207. foo.value = 'bar'
  208. }
  209. const Comp = defineComponent({
  210. props: ['foo'],
  211. emits: ['update:foo'],
  212. setup(props) {
  213. foo = useModel(props, 'foo', { local: true })
  214. },
  215. render() {}
  216. })
  217. const root = nodeOps.createElement('div')
  218. const updateFoo = vi.fn()
  219. render(h(Comp, { 'onUpdate:foo': updateFoo }), root)
  220. expect(foo.value).toBeUndefined()
  221. update()
  222. expect(foo.value).toBe('bar')
  223. await nextTick()
  224. expect(updateFoo).toBeCalledTimes(1)
  225. })
  226. test('default value', async () => {
  227. let count: any
  228. const inc = () => {
  229. count.value++
  230. }
  231. const Comp = defineComponent({
  232. props: { count: { default: 0 } },
  233. emits: ['update:count'],
  234. setup(props) {
  235. count = useModel(props, 'count', { local: true })
  236. },
  237. render() {}
  238. })
  239. const root = nodeOps.createElement('div')
  240. const updateCount = vi.fn()
  241. render(h(Comp, { 'onUpdate:count': updateCount }), root)
  242. expect(count.value).toBe(0)
  243. inc()
  244. expect(count.value).toBe(1)
  245. await nextTick()
  246. expect(updateCount).toBeCalledTimes(1)
  247. })
  248. })
  249. test('createPropsRestProxy', () => {
  250. const original = shallowReactive({
  251. foo: 1,
  252. bar: 2,
  253. baz: 3
  254. })
  255. const rest = createPropsRestProxy(original, ['foo', 'bar'])
  256. expect('foo' in rest).toBe(false)
  257. expect('bar' in rest).toBe(false)
  258. expect(rest.baz).toBe(3)
  259. expect(Object.keys(rest)).toEqual(['baz'])
  260. original.baz = 4
  261. expect(rest.baz).toBe(4)
  262. })
  263. describe('withAsyncContext', () => {
  264. // disable options API because applyOptions() also resets currentInstance
  265. // and we want to ensure the logic works even with Options API disabled.
  266. beforeEach(() => {
  267. __FEATURE_OPTIONS_API__ = false
  268. })
  269. afterEach(() => {
  270. __FEATURE_OPTIONS_API__ = true
  271. })
  272. test('basic', async () => {
  273. const spy = vi.fn()
  274. let beforeInstance: ComponentInternalInstance | null = null
  275. let afterInstance: ComponentInternalInstance | null = null
  276. let resolve: (msg: string) => void
  277. const Comp = defineComponent({
  278. async setup() {
  279. let __temp: any, __restore: any
  280. beforeInstance = getCurrentInstance()
  281. const msg =
  282. (([__temp, __restore] = withAsyncContext(
  283. () =>
  284. new Promise(r => {
  285. resolve = r
  286. })
  287. )),
  288. (__temp = await __temp),
  289. __restore(),
  290. __temp)
  291. // register the lifecycle after an await statement
  292. onMounted(spy)
  293. afterInstance = getCurrentInstance()
  294. return () => msg
  295. }
  296. })
  297. const root = nodeOps.createElement('div')
  298. render(
  299. h(() => h(Suspense, () => h(Comp))),
  300. root
  301. )
  302. expect(spy).not.toHaveBeenCalled()
  303. resolve!('hello')
  304. // wait a macro task tick for all micro ticks to resolve
  305. await new Promise(r => setTimeout(r))
  306. // mount hook should have been called
  307. expect(spy).toHaveBeenCalled()
  308. // should retain same instance before/after the await call
  309. expect(beforeInstance).toBe(afterInstance)
  310. expect(serializeInner(root)).toBe('hello')
  311. })
  312. test('error handling', async () => {
  313. const spy = vi.fn()
  314. let beforeInstance: ComponentInternalInstance | null = null
  315. let afterInstance: ComponentInternalInstance | null = null
  316. let reject: () => void
  317. const Comp = defineComponent({
  318. async setup() {
  319. let __temp: any, __restore: any
  320. beforeInstance = getCurrentInstance()
  321. try {
  322. ;[__temp, __restore] = withAsyncContext(
  323. () =>
  324. new Promise((_, rj) => {
  325. reject = rj
  326. })
  327. )
  328. __temp = await __temp
  329. __restore()
  330. } catch (e: any) {
  331. // ignore
  332. }
  333. // register the lifecycle after an await statement
  334. onMounted(spy)
  335. afterInstance = getCurrentInstance()
  336. return () => ''
  337. }
  338. })
  339. const root = nodeOps.createElement('div')
  340. render(
  341. h(() => h(Suspense, () => h(Comp))),
  342. root
  343. )
  344. expect(spy).not.toHaveBeenCalled()
  345. reject!()
  346. // wait a macro task tick for all micro ticks to resolve
  347. await new Promise(r => setTimeout(r))
  348. // mount hook should have been called
  349. expect(spy).toHaveBeenCalled()
  350. // should retain same instance before/after the await call
  351. expect(beforeInstance).toBe(afterInstance)
  352. })
  353. test('should not leak instance on multiple awaits', async () => {
  354. let resolve: (val?: any) => void
  355. let beforeInstance: ComponentInternalInstance | null = null
  356. let afterInstance: ComponentInternalInstance | null = null
  357. let inBandInstance: ComponentInternalInstance | null = null
  358. let outOfBandInstance: ComponentInternalInstance | null = null
  359. const ready = new Promise(r => {
  360. resolve = r
  361. })
  362. async function doAsyncWork() {
  363. // should still have instance
  364. inBandInstance = getCurrentInstance()
  365. await Promise.resolve()
  366. // should not leak instance
  367. outOfBandInstance = getCurrentInstance()
  368. }
  369. const Comp = defineComponent({
  370. async setup() {
  371. let __temp: any, __restore: any
  372. beforeInstance = getCurrentInstance()
  373. // first await
  374. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  375. __temp = await __temp
  376. __restore()
  377. // setup exit, instance set to null, then resumed
  378. ;[__temp, __restore] = withAsyncContext(() => doAsyncWork())
  379. __temp = await __temp
  380. __restore()
  381. afterInstance = getCurrentInstance()
  382. return () => {
  383. resolve()
  384. return ''
  385. }
  386. }
  387. })
  388. const root = nodeOps.createElement('div')
  389. render(
  390. h(() => h(Suspense, () => h(Comp))),
  391. root
  392. )
  393. await ready
  394. expect(inBandInstance).toBe(beforeInstance)
  395. expect(outOfBandInstance).toBeNull()
  396. expect(afterInstance).toBe(beforeInstance)
  397. expect(getCurrentInstance()).toBeNull()
  398. })
  399. test('should not leak on multiple awaits + error', async () => {
  400. let resolve: (val?: any) => void
  401. const ready = new Promise(r => {
  402. resolve = r
  403. })
  404. const Comp = defineComponent({
  405. async setup() {
  406. let __temp: any, __restore: any
  407. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  408. __temp = await __temp
  409. __restore()
  410. ;[__temp, __restore] = withAsyncContext(() => Promise.reject())
  411. __temp = await __temp
  412. __restore()
  413. },
  414. render() {}
  415. })
  416. const app = createApp(() => h(Suspense, () => h(Comp)))
  417. app.config.errorHandler = () => {
  418. resolve()
  419. return false
  420. }
  421. const root = nodeOps.createElement('div')
  422. app.mount(root)
  423. await ready
  424. expect(getCurrentInstance()).toBeNull()
  425. })
  426. // #4050
  427. test('race conditions', async () => {
  428. const uids = {
  429. one: { before: NaN, after: NaN },
  430. two: { before: NaN, after: NaN }
  431. }
  432. const Comp = defineComponent({
  433. props: ['name'],
  434. async setup(props: { name: 'one' | 'two' }) {
  435. let __temp: any, __restore: any
  436. uids[props.name].before = getCurrentInstance()!.uid
  437. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  438. __temp = await __temp
  439. __restore()
  440. uids[props.name].after = getCurrentInstance()!.uid
  441. return () => ''
  442. }
  443. })
  444. const app = createApp(() =>
  445. h(Suspense, () =>
  446. h('div', [h(Comp, { name: 'one' }), h(Comp, { name: 'two' })])
  447. )
  448. )
  449. const root = nodeOps.createElement('div')
  450. app.mount(root)
  451. await new Promise(r => setTimeout(r))
  452. expect(uids.one.before).not.toBe(uids.two.before)
  453. expect(uids.one.before).toBe(uids.one.after)
  454. expect(uids.two.before).toBe(uids.two.after)
  455. })
  456. test('should teardown in-scope effects', async () => {
  457. let resolve: (val?: any) => void
  458. const ready = new Promise(r => {
  459. resolve = r
  460. })
  461. let c: ComputedRef
  462. const Comp = defineComponent({
  463. async setup() {
  464. let __temp: any, __restore: any
  465. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  466. __temp = await __temp
  467. __restore()
  468. c = computed(() => {})
  469. // register the lifecycle after an await statement
  470. onMounted(resolve)
  471. return () => ''
  472. }
  473. })
  474. const app = createApp(() => h(Suspense, () => h(Comp)))
  475. const root = nodeOps.createElement('div')
  476. app.mount(root)
  477. await ready
  478. expect(c!.effect.active).toBe(true)
  479. app.unmount()
  480. expect(c!.effect.active).toBe(false)
  481. })
  482. })
  483. })