apiSetupHelpers.spec.ts 12 KB

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