wip(suspense): discard side effects when content is unmounted before resolve

This commit is contained in:
Evan You 2019-09-11 13:22:18 -04:00
parent ccfcdb8746
commit 6dc91971d1
4 changed files with 332 additions and 142 deletions

View File

@ -164,13 +164,7 @@ describe('renderer: suspense', () => {
}) })
await p await p
// test resume for returning bindings return () => h('div', 'async')
return {
msg: 'async'
}
},
render(this: any) {
return h('div', this.msg)
} }
} }
@ -201,11 +195,100 @@ describe('renderer: suspense', () => {
expect(calls).toEqual([`watch callback`, `mounted`, 'unmounted']) expect(calls).toEqual([`watch callback`, `mounted`, 'unmounted'])
}) })
// should receive updated props/slots when resolved test('content update before suspense resolve', async () => {
test.todo('content update before suspense resolve') 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 // mount/unmount hooks should not even fire
test.todo('unmount before suspense resolve') 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.todo('unmount suspense after resolve')
test.todo('unmount suspense before resolve')
test.todo('nested suspense') test.todo('nested suspense')

View File

@ -113,6 +113,9 @@ function doWatch(
} else { } else {
// no cb -> simple effect // no cb -> simple effect
getter = () => { getter = () => {
if (instance && instance.isUnmounted) {
return
}
if (cleanup) { if (cleanup) {
cleanup() cleanup()
} }
@ -141,6 +144,9 @@ function doWatch(
let oldValue = isArray(source) ? [] : undefined let oldValue = isArray(source) ? [] : undefined
const applyCb = cb const applyCb = cb
? () => { ? () => {
if (instance && instance.isUnmounted) {
return
}
const newValue = runner() const newValue = runner()
if (deep || newValue !== oldValue) { if (deep || newValue !== oldValue) {
// cleanup before running cb again // cleanup before running cb again
@ -157,11 +163,11 @@ function doWatch(
} }
: void 0 : void 0
const scheduler = let scheduler: (job: () => any) => void
flush === 'sync' if (flush === 'sync') {
? invoke scheduler = invoke
: flush === 'pre' } else if (flush === 'pre') {
? (job: () => any) => { scheduler = job => {
if (!instance || instance.vnode.el != null) { if (!instance || instance.vnode.el != null) {
queueJob(job) queueJob(job)
} else { } else {
@ -170,7 +176,11 @@ function doWatch(
job() job()
} }
} }
: (job: () => any) => queuePostRenderEffect(job, suspense) } else {
scheduler = job => {
queuePostRenderEffect(job, suspense)
}
}
const runner = effect(getter, { const runner = effect(getter, {
lazy: true, lazy: true,

View File

@ -690,6 +690,37 @@ export function createRenderer<
optimized: boolean optimized: boolean
) { ) {
if (n1 == null) { if (n1 == null) {
mountSuspense(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
} else {
patchSuspense(
n1,
n2,
container,
anchor,
parentComponent,
isSVG,
optimized
)
}
}
function mountSuspense(
n2: HostVNode,
container: HostElement,
anchor: HostNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: HostSuspsenseBoundary | null,
isSVG: boolean,
optimized: boolean
) {
const suspense = (n2.suspense = createSuspenseBoundary( const suspense = (n2.suspense = createSuspenseBoundary(
n2, n2,
parentSuspense, parentSuspense,
@ -698,9 +729,21 @@ export function createRenderer<
)) ))
function resolveSuspense() { function resolveSuspense() {
if (__DEV__) {
if (suspense.isResolved) {
throw new Error(
`suspense.resolve() is called when it's already resolved`
)
}
if (suspense.isUnmounted) {
throw new Error(
`suspense.resolve() is called when it's already unmounted`
)
}
}
const { subTree, fallbackTree, effects, vnode } = suspense const { subTree, fallbackTree, effects, vnode } = suspense
// unmount fallback tree // unmount fallback tree
if (fallback.el) { if (fallbackTree.el) {
unmount(fallbackTree as HostVNode, parentComponent, suspense, true) unmount(fallbackTree as HostVNode, parentComponent, suspense, true)
} }
// move content from off-dom container to actual container // move content from off-dom container to actual container
@ -740,7 +783,6 @@ export function createRenderer<
suspense.fallbackTree = fallback suspense.fallbackTree = fallback
// start mounting the content subtree in an off-dom container // start mounting the content subtree in an off-dom container
// TODO should buffer postQueue jobs on current boundary
patch( patch(
null, null,
content, content,
@ -760,7 +802,7 @@ export function createRenderer<
container, container,
anchor, anchor,
parentComponent, parentComponent,
suspense, null, // fallback tree will not have suspense context
isSVG, isSVG,
optimized optimized
) )
@ -769,14 +811,22 @@ export function createRenderer<
// Suspense has no async deps. Just resolve. // Suspense has no async deps. Just resolve.
suspense.resolve() suspense.resolve()
} }
} else { }
function patchSuspense(
n1: HostVNode,
n2: HostVNode,
container: HostElement,
anchor: HostNode | null,
parentComponent: ComponentInternalInstance | null,
isSVG: boolean,
optimized: boolean
) {
const suspense = (n2.suspense = n1.suspense) as HostSuspsenseBoundary const suspense = (n2.suspense = n1.suspense) as HostSuspsenseBoundary
suspense.vnode = n2 suspense.vnode = n2
const { content, fallback } = normalizeSuspenseChildren(n2) const { content, fallback } = normalizeSuspenseChildren(n2)
const oldSubTree = (suspense.oldSubTree = suspense.subTree) const oldSubTree = suspense.subTree
suspense.subTree = content const oldFallbackTree = suspense.fallbackTree
const oldFallbackTree = (suspense.oldFallbackTree = suspense.fallbackTree)
suspense.fallbackTree = fallback
if (!suspense.isResolved) { if (!suspense.isResolved) {
patch( patch(
oldSubTree, oldSubTree,
@ -796,14 +846,15 @@ export function createRenderer<
container, container,
anchor, anchor,
parentComponent, parentComponent,
suspense, null, // fallback tree will not have suspense context
isSVG, isSVG,
optimized optimized
) )
n2.el = fallback.el n2.el = fallback.el
} else {
suspense.resolve()
} }
// If deps somehow becomes 0 after the patch it means the patch caused an
// async dep component to unmount and removed its dep. It will cause the
// suspense to resolve and we don't need to do anything here.
} else { } else {
// just normal patch inner content as a fragment // just normal patch inner content as a fragment
patch( patch(
@ -818,7 +869,8 @@ export function createRenderer<
) )
n2.el = content.el n2.el = content.el
} }
} suspense.subTree = content
suspense.fallbackTree = fallback
} }
function processComponent( function processComponent(
@ -913,10 +965,19 @@ export function createRenderer<
// before proceeding // before proceeding
if (__FEATURE_SUSPENSE__ && instance.asyncDep) { if (__FEATURE_SUSPENSE__ && instance.asyncDep) {
if (!parentSuspense) { if (!parentSuspense) {
// TODO handle this properly
throw new Error('Async component without a suspense boundary!') throw new Error('Async component without a suspense boundary!')
} }
if (parentSuspense.isResolved) {
// TODO if parentSuspense is already resolved it needs to enter waiting
// state again
}
parentSuspense.deps++ parentSuspense.deps++
instance.asyncDep.then(asyncSetupResult => { instance.asyncDep.then(asyncSetupResult => {
// unmounted before resolve
if (instance.isUnmounted || parentSuspense.isUnmounted) {
return
}
parentSuspense.deps-- parentSuspense.deps--
// retry from this component // retry from this component
instance.asyncResolved = true instance.asyncResolved = true
@ -1481,12 +1542,7 @@ export function createRenderer<
} }
if (__FEATURE_SUSPENSE__ && suspense != null) { if (__FEATURE_SUSPENSE__ && suspense != null) {
unmount( unmountSuspense(suspense, parentComponent, parentSuspense, doRemove)
suspense.subTree as HostVNode,
parentComponent,
parentSuspense,
doRemove
)
return return
} }
@ -1538,15 +1594,58 @@ export function createRenderer<
stop(effects[i]) stop(effects[i])
} }
} }
// update may be null if a component is unmounted before its async
// setup has resolved.
if (update !== null) {
stop(update) stop(update)
unmount(subTree, instance, parentSuspense, doRemove) unmount(subTree, instance, parentSuspense, doRemove)
}
// unmounted hook // unmounted hook
if (um !== null) { if (um !== null) {
queuePostRenderEffect(um, parentSuspense) queuePostRenderEffect(um, parentSuspense)
// set unmounted after unmounted hooks are fired }
queuePostRenderEffect(() => { queuePostFlushCb(() => {
instance.isUnmounted = true instance.isUnmounted = true
}, parentSuspense) })
// A component with async dep inside a pending suspense is unmounted before
// its async dep resolves. This should remove the dep from the suspense, and
// cause the suspense to resolve immediately if that was the last dep.
if (
__FEATURE_SUSPENSE__ &&
parentSuspense !== null &&
!parentSuspense.isResolved &&
!parentSuspense.isUnmounted &&
instance.asyncDep !== null &&
!instance.asyncResolved
) {
parentSuspense.deps--
if (parentSuspense.deps === 0) {
parentSuspense.resolve()
}
}
}
function unmountSuspense(
suspense: HostSuspsenseBoundary,
parentComponent: ComponentInternalInstance | null,
parentSuspense: HostSuspsenseBoundary | null,
doRemove?: boolean
) {
suspense.isUnmounted = true
unmount(
suspense.subTree as HostVNode,
parentComponent,
parentSuspense,
doRemove
)
if (!suspense.isResolved) {
unmount(
suspense.fallbackTree as HostVNode,
parentComponent,
parentSuspense,
doRemove
)
} }
} }

View File

@ -12,12 +12,11 @@ export interface SuspenseBoundary<
vnode: HostVNode vnode: HostVNode
parent: SuspenseBoundary<HostNode, HostElement> | null parent: SuspenseBoundary<HostNode, HostElement> | null
container: HostElement container: HostElement
subTree: HostVNode | null subTree: HostVNode
oldSubTree: HostVNode | null fallbackTree: HostVNode
fallbackTree: HostVNode | null
oldFallbackTree: HostVNode | null
deps: number deps: number
isResolved: boolean isResolved: boolean
isUnmounted: boolean
effects: Function[] effects: Function[]
resolve(): void resolve(): void
} }
@ -33,11 +32,10 @@ export function createSuspenseBoundary<HostNode, HostElement>(
parent, parent,
container, container,
deps: 0, deps: 0,
subTree: null, subTree: null as any, // will be set immediately after creation
oldSubTree: null, fallbackTree: null as any, // will be set immediately after creation
fallbackTree: null,
oldFallbackTree: null,
isResolved: false, isResolved: false,
isUnmounted: false,
effects: [], effects: [],
resolve resolve
} }