rendererSuspense.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. import {
  2. h,
  3. ref,
  4. Suspense,
  5. ComponentOptions,
  6. render,
  7. nodeOps,
  8. serializeInner,
  9. nextTick,
  10. onMounted,
  11. watch,
  12. onUnmounted,
  13. onErrorCaptured
  14. } from '@vue/runtime-test'
  15. describe('renderer: suspense', () => {
  16. const deps: Promise<any>[] = []
  17. beforeEach(() => {
  18. deps.length = 0
  19. })
  20. // a simple async factory for testing purposes only.
  21. function createAsyncComponent<T extends ComponentOptions>(
  22. comp: T,
  23. delay: number = 0
  24. ) {
  25. return {
  26. setup(props: any, { slots }: any) {
  27. const p = new Promise(resolve => {
  28. setTimeout(() => {
  29. resolve(() => h(comp, props, slots))
  30. }, delay)
  31. })
  32. // in Node 12, due to timer/nextTick mechanism change, we have to wait
  33. // an extra tick to avoid race conditions
  34. deps.push(p.then(() => Promise.resolve()))
  35. return p
  36. }
  37. }
  38. }
  39. test('fallback content', async () => {
  40. const Async = createAsyncComponent({
  41. render() {
  42. return h('div', 'async')
  43. }
  44. })
  45. const Comp = {
  46. setup() {
  47. return () =>
  48. h(Suspense, null, {
  49. default: h(Async),
  50. fallback: h('div', 'fallback')
  51. })
  52. }
  53. }
  54. const root = nodeOps.createElement('div')
  55. render(h(Comp), root)
  56. expect(serializeInner(root)).toBe(`<div>fallback</div>`)
  57. await Promise.all(deps)
  58. await nextTick()
  59. expect(serializeInner(root)).toBe(`<div>async</div>`)
  60. })
  61. test('nested async deps', async () => {
  62. const calls: string[] = []
  63. const AsyncOuter = createAsyncComponent({
  64. setup() {
  65. onMounted(() => {
  66. calls.push('outer mounted')
  67. })
  68. return () => h(AsyncInner)
  69. }
  70. })
  71. const AsyncInner = createAsyncComponent(
  72. {
  73. setup() {
  74. onMounted(() => {
  75. calls.push('inner mounted')
  76. })
  77. return () => h('div', 'inner')
  78. }
  79. },
  80. 10
  81. )
  82. const Comp = {
  83. setup() {
  84. return () =>
  85. h(Suspense, null, {
  86. default: h(AsyncOuter),
  87. fallback: h('div', 'fallback')
  88. })
  89. }
  90. }
  91. const root = nodeOps.createElement('div')
  92. render(h(Comp), root)
  93. expect(serializeInner(root)).toBe(`<div>fallback</div>`)
  94. expect(calls).toEqual([])
  95. await deps[0]
  96. await nextTick()
  97. expect(serializeInner(root)).toBe(`<div>fallback</div>`)
  98. expect(calls).toEqual([])
  99. await Promise.all(deps)
  100. await nextTick()
  101. expect(calls).toEqual([`outer mounted`, `inner mounted`])
  102. expect(serializeInner(root)).toBe(`<div>inner</div>`)
  103. })
  104. test('onResolve', async () => {
  105. const Async = createAsyncComponent({
  106. render() {
  107. return h('div', 'async')
  108. }
  109. })
  110. const onResolve = jest.fn()
  111. const Comp = {
  112. setup() {
  113. return () =>
  114. h(
  115. Suspense,
  116. {
  117. onResolve
  118. },
  119. {
  120. default: h(Async),
  121. fallback: h('div', 'fallback')
  122. }
  123. )
  124. }
  125. }
  126. const root = nodeOps.createElement('div')
  127. render(h(Comp), root)
  128. expect(serializeInner(root)).toBe(`<div>fallback</div>`)
  129. expect(onResolve).not.toHaveBeenCalled()
  130. await Promise.all(deps)
  131. await nextTick()
  132. expect(serializeInner(root)).toBe(`<div>async</div>`)
  133. expect(onResolve).toHaveBeenCalled()
  134. })
  135. test('buffer mounted/updated hooks & watch callbacks', async () => {
  136. const deps: Promise<any>[] = []
  137. const calls: string[] = []
  138. const toggle = ref(true)
  139. const Async = {
  140. async setup() {
  141. const p = new Promise(r => setTimeout(r, 1))
  142. // extra tick needed for Node 12+
  143. deps.push(p.then(() => Promise.resolve()))
  144. watch(() => {
  145. calls.push('watch callback')
  146. })
  147. onMounted(() => {
  148. calls.push('mounted')
  149. })
  150. onUnmounted(() => {
  151. calls.push('unmounted')
  152. })
  153. await p
  154. return () => h('div', 'async')
  155. }
  156. }
  157. const Comp = {
  158. setup() {
  159. return () =>
  160. h(Suspense, null, {
  161. default: toggle.value ? h(Async) : null,
  162. fallback: h('div', 'fallback')
  163. })
  164. }
  165. }
  166. const root = nodeOps.createElement('div')
  167. render(h(Comp), root)
  168. expect(serializeInner(root)).toBe(`<div>fallback</div>`)
  169. expect(calls).toEqual([])
  170. await Promise.all(deps)
  171. await nextTick()
  172. expect(serializeInner(root)).toBe(`<div>async</div>`)
  173. expect(calls).toEqual([`watch callback`, `mounted`])
  174. // effects inside an already resolved suspense should happen at normal timing
  175. toggle.value = false
  176. await nextTick()
  177. expect(serializeInner(root)).toBe(`<!---->`)
  178. expect(calls).toEqual([`watch callback`, `mounted`, 'unmounted'])
  179. })
  180. test('content update before suspense resolve', async () => {
  181. const Async = createAsyncComponent({
  182. setup(props: { msg: string }) {
  183. return () => h('div', props.msg)
  184. }
  185. })
  186. const msg = ref('foo')
  187. const Comp = {
  188. setup() {
  189. return () =>
  190. h(Suspense, null, {
  191. default: h(Async, { msg: msg.value }),
  192. fallback: h('div', `fallback ${msg.value}`)
  193. })
  194. }
  195. }
  196. const root = nodeOps.createElement('div')
  197. render(h(Comp), root)
  198. expect(serializeInner(root)).toBe(`<div>fallback foo</div>`)
  199. // value changed before resolve
  200. msg.value = 'bar'
  201. await nextTick()
  202. // fallback content should be updated
  203. expect(serializeInner(root)).toBe(`<div>fallback bar</div>`)
  204. await Promise.all(deps)
  205. await nextTick()
  206. // async component should receive updated props/slots when resolved
  207. expect(serializeInner(root)).toBe(`<div>bar</div>`)
  208. })
  209. // mount/unmount hooks should not even fire
  210. test('unmount before suspense resolve', async () => {
  211. const deps: Promise<any>[] = []
  212. const calls: string[] = []
  213. const toggle = ref(true)
  214. const Async = {
  215. async setup() {
  216. const p = new Promise(r => setTimeout(r, 1))
  217. deps.push(p)
  218. watch(() => {
  219. calls.push('watch callback')
  220. })
  221. onMounted(() => {
  222. calls.push('mounted')
  223. })
  224. onUnmounted(() => {
  225. calls.push('unmounted')
  226. })
  227. await p
  228. return () => h('div', 'async')
  229. }
  230. }
  231. const Comp = {
  232. setup() {
  233. return () =>
  234. h(Suspense, null, {
  235. default: toggle.value ? h(Async) : null,
  236. fallback: h('div', 'fallback')
  237. })
  238. }
  239. }
  240. const root = nodeOps.createElement('div')
  241. render(h(Comp), root)
  242. expect(serializeInner(root)).toBe(`<div>fallback</div>`)
  243. expect(calls).toEqual([])
  244. // remvoe the async dep before it's resolved
  245. toggle.value = false
  246. await nextTick()
  247. // should cause the suspense to resolve immediately
  248. expect(serializeInner(root)).toBe(`<!---->`)
  249. await Promise.all(deps)
  250. await nextTick()
  251. expect(serializeInner(root)).toBe(`<!---->`)
  252. // should discard effects
  253. expect(calls).toEqual([])
  254. })
  255. test('unmount suspense after resolve', async () => {
  256. const toggle = ref(true)
  257. const unmounted = jest.fn()
  258. const Async = createAsyncComponent({
  259. setup() {
  260. onUnmounted(unmounted)
  261. return () => h('div', 'async')
  262. }
  263. })
  264. const Comp = {
  265. setup() {
  266. return () =>
  267. toggle.value
  268. ? h(Suspense, null, {
  269. default: h(Async),
  270. fallback: h('div', 'fallback')
  271. })
  272. : null
  273. }
  274. }
  275. const root = nodeOps.createElement('div')
  276. render(h(Comp), root)
  277. expect(serializeInner(root)).toBe(`<div>fallback</div>`)
  278. await Promise.all(deps)
  279. await nextTick()
  280. expect(serializeInner(root)).toBe(`<div>async</div>`)
  281. expect(unmounted).not.toHaveBeenCalled()
  282. toggle.value = false
  283. await nextTick()
  284. expect(serializeInner(root)).toBe(`<!---->`)
  285. expect(unmounted).toHaveBeenCalled()
  286. })
  287. test('unmount suspense before resolve', async () => {
  288. const toggle = ref(true)
  289. const mounted = jest.fn()
  290. const unmounted = jest.fn()
  291. const Async = createAsyncComponent({
  292. setup() {
  293. onMounted(mounted)
  294. onUnmounted(unmounted)
  295. return () => h('div', 'async')
  296. }
  297. })
  298. const Comp = {
  299. setup() {
  300. return () =>
  301. toggle.value
  302. ? h(Suspense, null, {
  303. default: h(Async),
  304. fallback: h('div', 'fallback')
  305. })
  306. : null
  307. }
  308. }
  309. const root = nodeOps.createElement('div')
  310. render(h(Comp), root)
  311. expect(serializeInner(root)).toBe(`<div>fallback</div>`)
  312. toggle.value = false
  313. await nextTick()
  314. expect(serializeInner(root)).toBe(`<!---->`)
  315. expect(mounted).not.toHaveBeenCalled()
  316. expect(unmounted).not.toHaveBeenCalled()
  317. await Promise.all(deps)
  318. await nextTick()
  319. // should not resolve and cause unmount
  320. expect(mounted).not.toHaveBeenCalled()
  321. expect(unmounted).not.toHaveBeenCalled()
  322. })
  323. test('nested suspense (parent resolves first)', async () => {
  324. const calls: string[] = []
  325. const AsyncOuter = createAsyncComponent(
  326. {
  327. setup: () => {
  328. onMounted(() => {
  329. calls.push('outer mounted')
  330. })
  331. return () => h('div', 'async outer')
  332. }
  333. },
  334. 1
  335. )
  336. const AsyncInner = createAsyncComponent(
  337. {
  338. setup: () => {
  339. onMounted(() => {
  340. calls.push('inner mounted')
  341. })
  342. return () => h('div', 'async inner')
  343. }
  344. },
  345. 10
  346. )
  347. const Inner = {
  348. setup() {
  349. return () =>
  350. h(Suspense, null, {
  351. default: h(AsyncInner),
  352. fallback: h('div', 'fallback inner')
  353. })
  354. }
  355. }
  356. const Comp = {
  357. setup() {
  358. return () =>
  359. h(Suspense, null, {
  360. default: [h(AsyncOuter), h(Inner)],
  361. fallback: h('div', 'fallback outer')
  362. })
  363. }
  364. }
  365. const root = nodeOps.createElement('div')
  366. render(h(Comp), root)
  367. expect(serializeInner(root)).toBe(`<div>fallback outer</div>`)
  368. await deps[0]
  369. await nextTick()
  370. expect(serializeInner(root)).toBe(
  371. `<!----><div>async outer</div><div>fallback inner</div><!---->`
  372. )
  373. expect(calls).toEqual([`outer mounted`])
  374. await Promise.all(deps)
  375. await nextTick()
  376. expect(serializeInner(root)).toBe(
  377. `<!----><div>async outer</div><div>async inner</div><!---->`
  378. )
  379. expect(calls).toEqual([`outer mounted`, `inner mounted`])
  380. })
  381. test('nested suspense (child resolves first)', async () => {
  382. const calls: string[] = []
  383. const AsyncOuter = createAsyncComponent(
  384. {
  385. setup: () => {
  386. onMounted(() => {
  387. calls.push('outer mounted')
  388. })
  389. return () => h('div', 'async outer')
  390. }
  391. },
  392. 10
  393. )
  394. const AsyncInner = createAsyncComponent(
  395. {
  396. setup: () => {
  397. onMounted(() => {
  398. calls.push('inner mounted')
  399. })
  400. return () => h('div', 'async inner')
  401. }
  402. },
  403. 1
  404. )
  405. const Inner = {
  406. setup() {
  407. return () =>
  408. h(Suspense, null, {
  409. default: h(AsyncInner),
  410. fallback: h('div', 'fallback inner')
  411. })
  412. }
  413. }
  414. const Comp = {
  415. setup() {
  416. return () =>
  417. h(Suspense, null, {
  418. default: [h(AsyncOuter), h(Inner)],
  419. fallback: h('div', 'fallback outer')
  420. })
  421. }
  422. }
  423. const root = nodeOps.createElement('div')
  424. render(h(Comp), root)
  425. expect(serializeInner(root)).toBe(`<div>fallback outer</div>`)
  426. await deps[1]
  427. await nextTick()
  428. expect(serializeInner(root)).toBe(`<div>fallback outer</div>`)
  429. expect(calls).toEqual([])
  430. await Promise.all(deps)
  431. await nextTick()
  432. expect(serializeInner(root)).toBe(
  433. `<!----><div>async outer</div><div>async inner</div><!---->`
  434. )
  435. expect(calls).toEqual([`inner mounted`, `outer mounted`])
  436. })
  437. test('error handling', async () => {
  438. const Async = {
  439. async setup() {
  440. throw new Error('oops')
  441. }
  442. }
  443. const Comp = {
  444. setup() {
  445. const error = ref<any>(null)
  446. onErrorCaptured(e => {
  447. error.value = e
  448. return true
  449. })
  450. return () =>
  451. error.value
  452. ? h('div', error.value.message)
  453. : h(Suspense, null, {
  454. default: h(Async),
  455. fallback: h('div', 'fallback')
  456. })
  457. }
  458. }
  459. const root = nodeOps.createElement('div')
  460. render(h(Comp), root)
  461. expect(serializeInner(root)).toBe(`<div>fallback</div>`)
  462. await Promise.all(deps)
  463. await nextTick()
  464. expect(serializeInner(root)).toBe(`<div>oops</div>`)
  465. })
  466. it('combined usage (nested async + nested suspense + multiple deps)', async () => {
  467. const msg = ref('nested msg')
  468. const calls: number[] = []
  469. const AsyncChildWithSuspense = createAsyncComponent({
  470. setup(props: { msg: string }) {
  471. onMounted(() => {
  472. calls.push(0)
  473. })
  474. return () =>
  475. h(Suspense, null, {
  476. default: h(AsyncInsideNestedSuspense, { msg: props.msg }),
  477. fallback: h('div', 'nested fallback')
  478. })
  479. }
  480. })
  481. const AsyncInsideNestedSuspense = createAsyncComponent(
  482. {
  483. setup(props: { msg: string }) {
  484. onMounted(() => {
  485. calls.push(2)
  486. })
  487. return () => h('div', props.msg)
  488. }
  489. },
  490. 20
  491. )
  492. const AsyncChildParent = createAsyncComponent({
  493. setup(props: { msg: string }) {
  494. onMounted(() => {
  495. calls.push(1)
  496. })
  497. return () => h(NestedAsyncChild, { msg: props.msg })
  498. }
  499. })
  500. const NestedAsyncChild = createAsyncComponent(
  501. {
  502. setup(props: { msg: string }) {
  503. onMounted(() => {
  504. calls.push(3)
  505. })
  506. return () => h('div', props.msg)
  507. }
  508. },
  509. 10
  510. )
  511. const MiddleComponent = {
  512. setup() {
  513. return () =>
  514. h(AsyncChildWithSuspense, {
  515. msg: msg.value
  516. })
  517. }
  518. }
  519. const Comp = {
  520. setup() {
  521. return () =>
  522. h(Suspense, null, {
  523. default: [
  524. h(MiddleComponent),
  525. h(AsyncChildParent, {
  526. msg: 'root async'
  527. })
  528. ],
  529. fallback: h('div', 'root fallback')
  530. })
  531. }
  532. }
  533. const root = nodeOps.createElement('div')
  534. render(h(Comp), root)
  535. expect(serializeInner(root)).toBe(`<div>root fallback</div>`)
  536. expect(calls).toEqual([])
  537. /**
  538. * <Root>
  539. * <Suspense>
  540. * <MiddleComponent>
  541. * <AsyncChildWithSuspense> (0: resolves on macrotask)
  542. * <Suspense>
  543. * <AsyncInsideNestedSuspense> (2: resolves on macrotask + 20ms)
  544. * <AsyncChildParent> (1: resolves on macrotask)
  545. * <NestedAsyncChild> (3: resolves on macrotask + 10ms)
  546. */
  547. // both top level async deps resolved, but there is another nested dep
  548. // so should still be in fallback state
  549. await Promise.all([deps[0], deps[1]])
  550. await nextTick()
  551. expect(serializeInner(root)).toBe(`<div>root fallback</div>`)
  552. expect(calls).toEqual([])
  553. // root suspense all deps resolved. should show root content now
  554. // with nested suspense showing fallback content
  555. await deps[3]
  556. await nextTick()
  557. expect(serializeInner(root)).toBe(
  558. `<!----><div>nested fallback</div><div>root async</div><!---->`
  559. )
  560. expect(calls).toEqual([0, 1, 3])
  561. // change state for the nested component before it resolves
  562. msg.value = 'nested changed'
  563. // all deps resolved, nested suspense should resolve now
  564. await Promise.all(deps)
  565. await nextTick()
  566. expect(serializeInner(root)).toBe(
  567. `<!----><div>nested changed</div><div>root async</div><!---->`
  568. )
  569. expect(calls).toEqual([0, 1, 3, 2])
  570. // should update just fine after resolve
  571. msg.value = 'nested changed again'
  572. await nextTick()
  573. expect(serializeInner(root)).toBe(
  574. `<!----><div>nested changed again</div><div>root async</div><!---->`
  575. )
  576. })
  577. test('new async dep after resolve should cause suspense to restart', async () => {
  578. const toggle = ref(false)
  579. const ChildA = createAsyncComponent({
  580. setup() {
  581. return () => h('div', 'Child A')
  582. }
  583. })
  584. const ChildB = createAsyncComponent({
  585. setup() {
  586. return () => h('div', 'Child B')
  587. }
  588. })
  589. const Comp = {
  590. setup() {
  591. return () =>
  592. h(Suspense, null, {
  593. default: [h(ChildA), toggle.value ? h(ChildB) : null],
  594. fallback: h('div', 'root fallback')
  595. })
  596. }
  597. }
  598. const root = nodeOps.createElement('div')
  599. render(h(Comp), root)
  600. expect(serializeInner(root)).toBe(`<div>root fallback</div>`)
  601. await deps[0]
  602. await nextTick()
  603. expect(serializeInner(root)).toBe(`<!----><div>Child A</div><!----><!---->`)
  604. toggle.value = true
  605. await nextTick()
  606. expect(serializeInner(root)).toBe(`<div>root fallback</div>`)
  607. await deps[1]
  608. await nextTick()
  609. expect(serializeInner(root)).toBe(
  610. `<!----><div>Child A</div><div>Child B</div><!---->`
  611. )
  612. })
  613. test.todo('portal inside suspense')
  614. })