rendererSuspense.spec.ts 17 KB

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