import { h, ref, Suspense, ComponentOptions, render, nodeOps, serializeInner, nextTick, onMounted, watch, onUnmounted, onErrorCaptured } from '@vue/runtime-test' describe('renderer: suspense', () => { const deps: Promise[] = [] beforeEach(() => { deps.length = 0 }) // a simple async factory for testing purposes only. function createAsyncComponent( 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(`
fallback
`) await Promise.all(deps) await nextTick() expect(serializeInner(root)).toBe(`
async
`) }) 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(`
fallback
`) expect(calls).toEqual([]) await deps[0] await nextTick() expect(serializeInner(root)).toBe(`
fallback
`) expect(calls).toEqual([]) await Promise.all(deps) await nextTick() expect(calls).toEqual([`outer mounted`, `inner mounted`]) expect(serializeInner(root)).toBe(`
inner
`) }) 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(`
fallback
`) expect(onResolve).not.toHaveBeenCalled() await Promise.all(deps) await nextTick() expect(serializeInner(root)).toBe(`
async
`) expect(onResolve).toHaveBeenCalled() }) test('buffer mounted/updated hooks & watch callbacks', async () => { const deps: Promise[] = [] 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(`
fallback
`) expect(calls).toEqual([]) await Promise.all(deps) await nextTick() expect(serializeInner(root)).toBe(`
async
`) 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(`
fallback foo
`) // value changed before resolve msg.value = 'bar' await nextTick() // fallback content should be updated expect(serializeInner(root)).toBe(`
fallback bar
`) await Promise.all(deps) await nextTick() // async component should receive updated props/slots when resolved expect(serializeInner(root)).toBe(`
bar
`) }) // mount/unmount hooks should not even fire test('unmount before suspense resolve', async () => { const deps: Promise[] = [] 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(`
fallback
`) expect(calls).toEqual([]) // remove 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(`
fallback
`) await Promise.all(deps) await nextTick() expect(serializeInner(root)).toBe(`
async
`) 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(`
fallback
`) 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(`
fallback outer
`) await deps[0] await nextTick() expect(serializeInner(root)).toBe( `
async outer
fallback inner
` ) expect(calls).toEqual([`outer mounted`]) await Promise.all(deps) await nextTick() expect(serializeInner(root)).toBe( `
async outer
async inner
` ) 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(`
fallback outer
`) await deps[1] await nextTick() expect(serializeInner(root)).toBe(`
fallback outer
`) expect(calls).toEqual([]) await Promise.all(deps) await nextTick() expect(serializeInner(root)).toBe( `
async outer
async inner
` ) 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(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(`
fallback
`) await Promise.all(deps) await nextTick() expect(serializeInner(root)).toBe(`
oops
`) }) 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(`
root fallback
`) expect(calls).toEqual([]) /** * * * * (0: resolves on macrotask) * * (2: resolves on macrotask + 20ms) * (1: resolves on macrotask) * (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(`
root fallback
`) 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( `
nested fallback
root async
` ) 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( `
nested changed
root async
` ) expect(calls).toEqual([0, 1, 3, 2]) // should update just fine after resolve msg.value = 'nested changed again' await nextTick() expect(serializeInner(root)).toBe( `
nested changed again
root async
` ) }) 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(`
root fallback
`) await deps[0] await nextTick() expect(serializeInner(root)).toBe(`
Child A
`) toggle.value = true await nextTick() expect(serializeInner(root)).toBe(`
root fallback
`) await deps[1] await nextTick() expect(serializeInner(root)).toBe( `
Child A
Child B
` ) }) test.todo('portal inside suspense') })