apiSetupHelpers.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  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('should not leak instance to user microtasks after restore', async () => {
  227. let leakedToUserMicrotask = false
  228. const Comp = defineComponent({
  229. async setup() {
  230. let __temp: any, __restore: any
  231. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  232. __temp = await __temp
  233. __restore()
  234. Promise.resolve().then(() => {
  235. leakedToUserMicrotask = getCurrentInstance() !== null
  236. })
  237. return () => ''
  238. },
  239. })
  240. const root = nodeOps.createElement('div')
  241. render(
  242. h(() => h(Suspense, () => h(Comp))),
  243. root,
  244. )
  245. await new Promise(r => setTimeout(r))
  246. expect(leakedToUserMicrotask).toBe(false)
  247. })
  248. test('should not leak sibling instance in concurrent restores', async () => {
  249. let resolveOne: () => void
  250. let resolveTwo: () => void
  251. let done!: () => void
  252. let pending = 2
  253. const ready = new Promise<void>(r => {
  254. done = r
  255. })
  256. const seenUid: Record<'one' | 'two', number | null> = {
  257. one: null,
  258. two: null,
  259. }
  260. const makeComp = (name: 'one' | 'two', wait: Promise<void>) =>
  261. defineComponent({
  262. async setup() {
  263. let __temp: any, __restore: any
  264. ;[__temp, __restore] = withAsyncContext(() => wait)
  265. __temp = await __temp
  266. __restore()
  267. Promise.resolve().then(() => {
  268. seenUid[name] = getCurrentInstance()?.uid ?? null
  269. if (--pending === 0) done()
  270. })
  271. return () => ''
  272. },
  273. })
  274. const oneReady = new Promise<void>(r => {
  275. resolveOne = r
  276. })
  277. const twoReady = new Promise<void>(r => {
  278. resolveTwo = r
  279. })
  280. const CompOne = makeComp('one', oneReady)
  281. const CompTwo = makeComp('two', twoReady)
  282. const root = nodeOps.createElement('div')
  283. render(
  284. h(() => h(Suspense, () => h('div', [h(CompOne), h(CompTwo)]))),
  285. root,
  286. )
  287. resolveOne!()
  288. resolveTwo!()
  289. await ready
  290. expect(seenUid.one).toBeNull()
  291. expect(seenUid.two).toBeNull()
  292. })
  293. test('should not leak currentInstance to sibling slot render', async () => {
  294. let done!: () => void
  295. const ready = new Promise<void>(r => {
  296. done = r
  297. })
  298. let innerUid: number | null = null
  299. let innerRenderUid: number | null = null
  300. const Inner = defineComponent({
  301. setup(_, { slots }) {
  302. innerUid = getCurrentInstance()!.uid
  303. return () => {
  304. innerRenderUid = getCurrentInstance()!.uid
  305. done()
  306. return h('div', slots.default?.())
  307. }
  308. },
  309. })
  310. const Outer = defineComponent({
  311. setup(_, { slots }) {
  312. return () => h(Inner, null, () => [slots.default?.()])
  313. },
  314. })
  315. const AsyncA = defineComponent({
  316. async setup() {
  317. let __temp: any, __restore: any
  318. ;[__temp, __restore] = withAsyncContext(() =>
  319. Promise.resolve()
  320. .then(() => {})
  321. .then(() => {}),
  322. )
  323. __temp = await __temp
  324. __restore()
  325. return () => h('div', 'A')
  326. },
  327. })
  328. const AsyncB = defineComponent({
  329. async setup() {
  330. let __temp: any, __restore: any
  331. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  332. __temp = await __temp
  333. __restore()
  334. return () => h(Outer, null, () => 'B')
  335. },
  336. })
  337. const root = nodeOps.createElement('div')
  338. render(
  339. h(() => h(Suspense, () => h('div', [h(AsyncA), h(AsyncB)]))),
  340. root,
  341. )
  342. await ready
  343. expect(
  344. 'Slot "default" invoked outside of the render function',
  345. ).not.toHaveBeenWarned()
  346. expect(innerRenderUid).toBe(innerUid)
  347. await Promise.resolve()
  348. expect(serializeInner(root)).toBe(`<div><div>A</div><div>B</div></div>`)
  349. })
  350. test('error handling', async () => {
  351. const spy = vi.fn()
  352. let beforeInstance: ComponentInternalInstance | null = null
  353. let afterInstance: ComponentInternalInstance | null = null
  354. let reject: () => void
  355. const Comp = defineComponent({
  356. async setup() {
  357. let __temp: any, __restore: any
  358. beforeInstance = getCurrentInstance()
  359. try {
  360. ;[__temp, __restore] = withAsyncContext(
  361. () =>
  362. new Promise((_, rj) => {
  363. reject = rj
  364. }),
  365. )
  366. __temp = await __temp
  367. __restore()
  368. } catch (e: any) {
  369. // ignore
  370. }
  371. // register the lifecycle after an await statement
  372. onMounted(spy)
  373. afterInstance = getCurrentInstance()
  374. return () => ''
  375. },
  376. })
  377. const root = nodeOps.createElement('div')
  378. render(
  379. h(() => h(Suspense, () => h(Comp))),
  380. root,
  381. )
  382. expect(spy).not.toHaveBeenCalled()
  383. reject!()
  384. // wait a macro task tick for all micro ticks to resolve
  385. await new Promise(r => setTimeout(r))
  386. // mount hook should have been called
  387. expect(spy).toHaveBeenCalled()
  388. // should retain same instance before/after the await call
  389. expect(beforeInstance).toBe(afterInstance)
  390. // instance scope should be fully restored/cleaned after async ticks
  391. expect((beforeInstance!.scope as any)._on).toBe(0)
  392. })
  393. test('should not leak instance on multiple awaits', async () => {
  394. let resolve: (val?: any) => void
  395. let beforeInstance: ComponentInternalInstance | null = null
  396. let afterInstance: ComponentInternalInstance | null = null
  397. let inBandInstance: ComponentInternalInstance | null = null
  398. let outOfBandInstance: ComponentInternalInstance | null = null
  399. const ready = new Promise(r => {
  400. resolve = r
  401. })
  402. async function doAsyncWork() {
  403. // should still have instance
  404. inBandInstance = getCurrentInstance()
  405. await Promise.resolve()
  406. // should not leak instance
  407. outOfBandInstance = getCurrentInstance()
  408. }
  409. const Comp = defineComponent({
  410. async setup() {
  411. let __temp: any, __restore: any
  412. beforeInstance = getCurrentInstance()
  413. // first await
  414. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  415. __temp = await __temp
  416. __restore()
  417. // setup exit, instance set to null, then resumed
  418. ;[__temp, __restore] = withAsyncContext(() => doAsyncWork())
  419. __temp = await __temp
  420. __restore()
  421. afterInstance = getCurrentInstance()
  422. return () => {
  423. resolve()
  424. return ''
  425. }
  426. },
  427. })
  428. const root = nodeOps.createElement('div')
  429. render(
  430. h(() => h(Suspense, () => h(Comp))),
  431. root,
  432. )
  433. await ready
  434. expect(inBandInstance).toBe(beforeInstance)
  435. expect(outOfBandInstance).toBeNull()
  436. expect(afterInstance).toBe(beforeInstance)
  437. expect(getCurrentInstance()).toBeNull()
  438. })
  439. test('should not leak on multiple awaits + error', async () => {
  440. let resolve: (val?: any) => void
  441. const ready = new Promise(r => {
  442. resolve = r
  443. })
  444. const Comp = defineComponent({
  445. async setup() {
  446. let __temp: any, __restore: any
  447. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  448. __temp = await __temp
  449. __restore()
  450. ;[__temp, __restore] = withAsyncContext(() => Promise.reject())
  451. __temp = await __temp
  452. __restore()
  453. },
  454. render() {},
  455. })
  456. const app = createApp(() => h(Suspense, () => h(Comp)))
  457. app.config.errorHandler = () => {
  458. resolve()
  459. return false
  460. }
  461. const root = nodeOps.createElement('div')
  462. app.mount(root)
  463. await ready
  464. expect(getCurrentInstance()).toBeNull()
  465. })
  466. // #4050
  467. test('race conditions', async () => {
  468. const uids = {
  469. one: { before: NaN, after: NaN },
  470. two: { before: NaN, after: NaN },
  471. }
  472. const Comp = defineComponent({
  473. props: ['name'],
  474. async setup(props: { name: 'one' | 'two' }) {
  475. let __temp: any, __restore: any
  476. uids[props.name].before = getCurrentInstance()!.uid
  477. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  478. __temp = await __temp
  479. __restore()
  480. uids[props.name].after = getCurrentInstance()!.uid
  481. return () => ''
  482. },
  483. })
  484. const app = createApp(() =>
  485. h(Suspense, () =>
  486. h('div', [h(Comp, { name: 'one' }), h(Comp, { name: 'two' })]),
  487. ),
  488. )
  489. const root = nodeOps.createElement('div')
  490. app.mount(root)
  491. await new Promise(r => setTimeout(r))
  492. expect(uids.one.before).not.toBe(uids.two.before)
  493. expect(uids.one.before).toBe(uids.one.after)
  494. expect(uids.two.before).toBe(uids.two.after)
  495. })
  496. test('should teardown in-scope effects', async () => {
  497. let resolve: (val?: any) => void
  498. const ready = new Promise(r => {
  499. resolve = r
  500. })
  501. let c: ComputedRefImpl
  502. let e: ReactiveEffectRunner
  503. const Comp = defineComponent({
  504. async setup() {
  505. let __temp: any, __restore: any
  506. ;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
  507. __temp = await __temp
  508. __restore()
  509. c = computed(() => {}) as unknown as ComputedRefImpl
  510. e = effect(() => c.value)
  511. // register the lifecycle after an await statement
  512. onMounted(resolve)
  513. return () => c.value
  514. },
  515. })
  516. const app = createApp(() => h(Suspense, () => h(Comp)))
  517. const root = nodeOps.createElement('div')
  518. app.mount(root)
  519. await ready
  520. expect(e!.effect.flags & EffectFlags.ACTIVE).toBeTruthy()
  521. expect(c!.flags & EffectFlags.TRACKING).toBeTruthy()
  522. app.unmount()
  523. expect(e!.effect.flags & EffectFlags.ACTIVE).toBeFalsy()
  524. expect(c!.flags & EffectFlags.TRACKING).toBeFalsy()
  525. })
  526. })
  527. })