| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715 |
- import {
- h,
- ref,
- Suspense,
- ComponentOptions,
- render,
- nodeOps,
- serializeInner,
- nextTick,
- onMounted,
- watch,
- onUnmounted,
- onErrorCaptured
- } from '@vue/runtime-test'
- describe('renderer: suspense', () => {
- const deps: Promise<any>[] = []
- beforeEach(() => {
- deps.length = 0
- })
- // a simple async factory for testing purposes only.
- function createAsyncComponent<T extends ComponentOptions>(
- comp: T,
- delay: number = 0
- ) {
- return {
- setup(props: any, { slots }: any) {
- const p = new Promise(resolve => {
- setTimeout(() => {
- resolve(() => h(comp, props, slots))
- }, delay)
- })
- // in Node 12, due to timer/nextTick mechanism change, we have to wait
- // an extra tick to avoid race conditions
- deps.push(p.then(() => Promise.resolve()))
- return p
- }
- }
- }
- test('fallback content', async () => {
- const Async = createAsyncComponent({
- render() {
- return h('div', 'async')
- }
- })
- const Comp = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: h(Async),
- fallback: h('div', 'fallback')
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- await Promise.all(deps)
- await nextTick()
- expect(serializeInner(root)).toBe(`<div>async</div>`)
- })
- test('nested async deps', async () => {
- const calls: string[] = []
- const AsyncOuter = createAsyncComponent({
- setup() {
- onMounted(() => {
- calls.push('outer mounted')
- })
- return () => h(AsyncInner)
- }
- })
- const AsyncInner = createAsyncComponent(
- {
- setup() {
- onMounted(() => {
- calls.push('inner mounted')
- })
- return () => h('div', 'inner')
- }
- },
- 10
- )
- const Comp = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: h(AsyncOuter),
- fallback: h('div', 'fallback')
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- expect(calls).toEqual([])
- await deps[0]
- await nextTick()
- expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- expect(calls).toEqual([])
- await Promise.all(deps)
- await nextTick()
- expect(calls).toEqual([`outer mounted`, `inner mounted`])
- expect(serializeInner(root)).toBe(`<div>inner</div>`)
- })
- test('onResolve', async () => {
- const Async = createAsyncComponent({
- render() {
- return h('div', 'async')
- }
- })
- const onResolve = jest.fn()
- const Comp = {
- setup() {
- return () =>
- h(
- Suspense,
- {
- onResolve
- },
- {
- default: h(Async),
- fallback: h('div', 'fallback')
- }
- )
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- expect(onResolve).not.toHaveBeenCalled()
- await Promise.all(deps)
- await nextTick()
- expect(serializeInner(root)).toBe(`<div>async</div>`)
- expect(onResolve).toHaveBeenCalled()
- })
- test('buffer mounted/updated hooks & watch callbacks', async () => {
- const deps: Promise<any>[] = []
- const calls: string[] = []
- const toggle = ref(true)
- const Async = {
- async setup() {
- const p = new Promise(r => setTimeout(r, 1))
- // extra tick needed for Node 12+
- deps.push(p.then(() => Promise.resolve()))
- watch(() => {
- calls.push('watch callback')
- })
- onMounted(() => {
- calls.push('mounted')
- })
- onUnmounted(() => {
- calls.push('unmounted')
- })
- await p
- return () => h('div', 'async')
- }
- }
- const Comp = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: toggle.value ? h(Async) : null,
- fallback: h('div', 'fallback')
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- expect(calls).toEqual([])
- await Promise.all(deps)
- await nextTick()
- expect(serializeInner(root)).toBe(`<div>async</div>`)
- expect(calls).toEqual([`watch callback`, `mounted`])
- // effects inside an already resolved suspense should happen at normal timing
- toggle.value = false
- await nextTick()
- expect(serializeInner(root)).toBe(`<!---->`)
- expect(calls).toEqual([`watch callback`, `mounted`, 'unmounted'])
- })
- test('content update before suspense resolve', async () => {
- const Async = createAsyncComponent({
- setup(props: { msg: string }) {
- return () => h('div', props.msg)
- }
- })
- const msg = ref('foo')
- const Comp = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: h(Async, { msg: msg.value }),
- fallback: h('div', `fallback ${msg.value}`)
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback foo</div>`)
- // value changed before resolve
- msg.value = 'bar'
- await nextTick()
- // fallback content should be updated
- expect(serializeInner(root)).toBe(`<div>fallback bar</div>`)
- await Promise.all(deps)
- await nextTick()
- // async component should receive updated props/slots when resolved
- expect(serializeInner(root)).toBe(`<div>bar</div>`)
- })
- // mount/unmount hooks should not even fire
- test('unmount before suspense resolve', async () => {
- const deps: Promise<any>[] = []
- const calls: string[] = []
- const toggle = ref(true)
- const Async = {
- async setup() {
- const p = new Promise(r => setTimeout(r, 1))
- deps.push(p)
- watch(() => {
- calls.push('watch callback')
- })
- onMounted(() => {
- calls.push('mounted')
- })
- onUnmounted(() => {
- calls.push('unmounted')
- })
- await p
- return () => h('div', 'async')
- }
- }
- const Comp = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: toggle.value ? h(Async) : null,
- fallback: h('div', 'fallback')
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- expect(calls).toEqual([])
- // remvoe the async dep before it's resolved
- toggle.value = false
- await nextTick()
- // should cause the suspense to resolve immediately
- expect(serializeInner(root)).toBe(`<!---->`)
- await Promise.all(deps)
- await nextTick()
- expect(serializeInner(root)).toBe(`<!---->`)
- // should discard effects
- expect(calls).toEqual([])
- })
- test('unmount suspense after resolve', async () => {
- const toggle = ref(true)
- const unmounted = jest.fn()
- const Async = createAsyncComponent({
- setup() {
- onUnmounted(unmounted)
- return () => h('div', 'async')
- }
- })
- const Comp = {
- setup() {
- return () =>
- toggle.value
- ? h(Suspense, null, {
- default: h(Async),
- fallback: h('div', 'fallback')
- })
- : null
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- await Promise.all(deps)
- await nextTick()
- expect(serializeInner(root)).toBe(`<div>async</div>`)
- expect(unmounted).not.toHaveBeenCalled()
- toggle.value = false
- await nextTick()
- expect(serializeInner(root)).toBe(`<!---->`)
- expect(unmounted).toHaveBeenCalled()
- })
- test('unmount suspense before resolve', async () => {
- const toggle = ref(true)
- const mounted = jest.fn()
- const unmounted = jest.fn()
- const Async = createAsyncComponent({
- setup() {
- onMounted(mounted)
- onUnmounted(unmounted)
- return () => h('div', 'async')
- }
- })
- const Comp = {
- setup() {
- return () =>
- toggle.value
- ? h(Suspense, null, {
- default: h(Async),
- fallback: h('div', 'fallback')
- })
- : null
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- toggle.value = false
- await nextTick()
- expect(serializeInner(root)).toBe(`<!---->`)
- expect(mounted).not.toHaveBeenCalled()
- expect(unmounted).not.toHaveBeenCalled()
- await Promise.all(deps)
- await nextTick()
- // should not resolve and cause unmount
- expect(mounted).not.toHaveBeenCalled()
- expect(unmounted).not.toHaveBeenCalled()
- })
- test('nested suspense (parent resolves first)', async () => {
- const calls: string[] = []
- const AsyncOuter = createAsyncComponent(
- {
- setup: () => {
- onMounted(() => {
- calls.push('outer mounted')
- })
- return () => h('div', 'async outer')
- }
- },
- 1
- )
- const AsyncInner = createAsyncComponent(
- {
- setup: () => {
- onMounted(() => {
- calls.push('inner mounted')
- })
- return () => h('div', 'async inner')
- }
- },
- 10
- )
- const Inner = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: h(AsyncInner),
- fallback: h('div', 'fallback inner')
- })
- }
- }
- const Comp = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: [h(AsyncOuter), h(Inner)],
- fallback: h('div', 'fallback outer')
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback outer</div>`)
- await deps[0]
- await nextTick()
- expect(serializeInner(root)).toBe(
- `<!----><div>async outer</div><div>fallback inner</div><!---->`
- )
- expect(calls).toEqual([`outer mounted`])
- await Promise.all(deps)
- await nextTick()
- expect(serializeInner(root)).toBe(
- `<!----><div>async outer</div><div>async inner</div><!---->`
- )
- expect(calls).toEqual([`outer mounted`, `inner mounted`])
- })
- test('nested suspense (child resolves first)', async () => {
- const calls: string[] = []
- const AsyncOuter = createAsyncComponent(
- {
- setup: () => {
- onMounted(() => {
- calls.push('outer mounted')
- })
- return () => h('div', 'async outer')
- }
- },
- 10
- )
- const AsyncInner = createAsyncComponent(
- {
- setup: () => {
- onMounted(() => {
- calls.push('inner mounted')
- })
- return () => h('div', 'async inner')
- }
- },
- 1
- )
- const Inner = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: h(AsyncInner),
- fallback: h('div', 'fallback inner')
- })
- }
- }
- const Comp = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: [h(AsyncOuter), h(Inner)],
- fallback: h('div', 'fallback outer')
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback outer</div>`)
- await deps[1]
- await nextTick()
- expect(serializeInner(root)).toBe(`<div>fallback outer</div>`)
- expect(calls).toEqual([])
- await Promise.all(deps)
- await nextTick()
- expect(serializeInner(root)).toBe(
- `<!----><div>async outer</div><div>async inner</div><!---->`
- )
- expect(calls).toEqual([`inner mounted`, `outer mounted`])
- })
- test('error handling', async () => {
- const Async = {
- async setup() {
- throw new Error('oops')
- }
- }
- const Comp = {
- setup() {
- const error = ref<any>(null)
- onErrorCaptured(e => {
- error.value = e
- return true
- })
- return () =>
- error.value
- ? h('div', error.value.message)
- : h(Suspense, null, {
- default: h(Async),
- fallback: h('div', 'fallback')
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- await Promise.all(deps)
- await nextTick()
- expect(serializeInner(root)).toBe(`<div>oops</div>`)
- })
- it('combined usage (nested async + nested suspense + multiple deps)', async () => {
- const msg = ref('nested msg')
- const calls: number[] = []
- const AsyncChildWithSuspense = createAsyncComponent({
- setup(props: { msg: string }) {
- onMounted(() => {
- calls.push(0)
- })
- return () =>
- h(Suspense, null, {
- default: h(AsyncInsideNestedSuspense, { msg: props.msg }),
- fallback: h('div', 'nested fallback')
- })
- }
- })
- const AsyncInsideNestedSuspense = createAsyncComponent(
- {
- setup(props: { msg: string }) {
- onMounted(() => {
- calls.push(2)
- })
- return () => h('div', props.msg)
- }
- },
- 20
- )
- const AsyncChildParent = createAsyncComponent({
- setup(props: { msg: string }) {
- onMounted(() => {
- calls.push(1)
- })
- return () => h(NestedAsyncChild, { msg: props.msg })
- }
- })
- const NestedAsyncChild = createAsyncComponent(
- {
- setup(props: { msg: string }) {
- onMounted(() => {
- calls.push(3)
- })
- return () => h('div', props.msg)
- }
- },
- 10
- )
- const MiddleComponent = {
- setup() {
- return () =>
- h(AsyncChildWithSuspense, {
- msg: msg.value
- })
- }
- }
- const Comp = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: [
- h(MiddleComponent),
- h(AsyncChildParent, {
- msg: 'root async'
- })
- ],
- fallback: h('div', 'root fallback')
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>root fallback</div>`)
- expect(calls).toEqual([])
- /**
- * <Root>
- * <Suspense>
- * <MiddleComponent>
- * <AsyncChildWithSuspense> (0: resolves on macrotask)
- * <Suspense>
- * <AsyncInsideNestedSuspense> (2: resolves on macrotask + 20ms)
- * <AsyncChildParent> (1: resolves on macrotask)
- * <NestedAsyncChild> (3: resolves on macrotask + 10ms)
- */
- // both top level async deps resolved, but there is another nested dep
- // so should still be in fallback state
- await Promise.all([deps[0], deps[1]])
- await nextTick()
- expect(serializeInner(root)).toBe(`<div>root fallback</div>`)
- expect(calls).toEqual([])
- // root suspense all deps resolved. should show root content now
- // with nested suspense showing fallback content
- await deps[3]
- await nextTick()
- expect(serializeInner(root)).toBe(
- `<!----><div>nested fallback</div><div>root async</div><!---->`
- )
- expect(calls).toEqual([0, 1, 3])
- // change state for the nested component before it resolves
- msg.value = 'nested changed'
- // all deps resolved, nested suspense should resolve now
- await Promise.all(deps)
- await nextTick()
- expect(serializeInner(root)).toBe(
- `<!----><div>nested changed</div><div>root async</div><!---->`
- )
- expect(calls).toEqual([0, 1, 3, 2])
- // should update just fine after resolve
- msg.value = 'nested changed again'
- await nextTick()
- expect(serializeInner(root)).toBe(
- `<!----><div>nested changed again</div><div>root async</div><!---->`
- )
- })
- test('new async dep after resolve should cause suspense to restart', async () => {
- const toggle = ref(false)
- const ChildA = createAsyncComponent({
- setup() {
- return () => h('div', 'Child A')
- }
- })
- const ChildB = createAsyncComponent({
- setup() {
- return () => h('div', 'Child B')
- }
- })
- const Comp = {
- setup() {
- return () =>
- h(Suspense, null, {
- default: [h(ChildA), toggle.value ? h(ChildB) : null],
- fallback: h('div', 'root fallback')
- })
- }
- }
- const root = nodeOps.createElement('div')
- render(h(Comp), root)
- expect(serializeInner(root)).toBe(`<div>root fallback</div>`)
- await deps[0]
- await nextTick()
- expect(serializeInner(root)).toBe(`<!----><div>Child A</div><!----><!---->`)
- toggle.value = true
- await nextTick()
- expect(serializeInner(root)).toBe(`<div>root fallback</div>`)
- await deps[1]
- await nextTick()
- expect(serializeInner(root)).toBe(
- `<!----><div>Child A</div><div>Child B</div><!---->`
- )
- })
- test.todo('portal inside suspense')
- })
|