wip(suspense): discard side effects when content is unmounted before resolve
This commit is contained in:
parent
ccfcdb8746
commit
6dc91971d1
@ -164,13 +164,7 @@ describe('renderer: suspense', () => {
|
||||
})
|
||||
|
||||
await p
|
||||
// test resume for returning bindings
|
||||
return {
|
||||
msg: 'async'
|
||||
}
|
||||
},
|
||||
render(this: any) {
|
||||
return h('div', this.msg)
|
||||
return () => h('div', 'async')
|
||||
}
|
||||
}
|
||||
|
||||
@ -201,11 +195,100 @@ describe('renderer: suspense', () => {
|
||||
expect(calls).toEqual([`watch callback`, `mounted`, 'unmounted'])
|
||||
})
|
||||
|
||||
// should receive updated props/slots when resolved
|
||||
test.todo('content update before suspense resolve')
|
||||
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.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')
|
||||
|
||||
|
@ -113,6 +113,9 @@ function doWatch(
|
||||
} else {
|
||||
// no cb -> simple effect
|
||||
getter = () => {
|
||||
if (instance && instance.isUnmounted) {
|
||||
return
|
||||
}
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
}
|
||||
@ -141,6 +144,9 @@ function doWatch(
|
||||
let oldValue = isArray(source) ? [] : undefined
|
||||
const applyCb = cb
|
||||
? () => {
|
||||
if (instance && instance.isUnmounted) {
|
||||
return
|
||||
}
|
||||
const newValue = runner()
|
||||
if (deep || newValue !== oldValue) {
|
||||
// cleanup before running cb again
|
||||
@ -157,11 +163,11 @@ function doWatch(
|
||||
}
|
||||
: void 0
|
||||
|
||||
const scheduler =
|
||||
flush === 'sync'
|
||||
? invoke
|
||||
: flush === 'pre'
|
||||
? (job: () => any) => {
|
||||
let scheduler: (job: () => any) => void
|
||||
if (flush === 'sync') {
|
||||
scheduler = invoke
|
||||
} else if (flush === 'pre') {
|
||||
scheduler = job => {
|
||||
if (!instance || instance.vnode.el != null) {
|
||||
queueJob(job)
|
||||
} else {
|
||||
@ -170,7 +176,11 @@ function doWatch(
|
||||
job()
|
||||
}
|
||||
}
|
||||
: (job: () => any) => queuePostRenderEffect(job, suspense)
|
||||
} else {
|
||||
scheduler = job => {
|
||||
queuePostRenderEffect(job, suspense)
|
||||
}
|
||||
}
|
||||
|
||||
const runner = effect(getter, {
|
||||
lazy: true,
|
||||
|
@ -690,6 +690,37 @@ export function createRenderer<
|
||||
optimized: boolean
|
||||
) {
|
||||
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(
|
||||
n2,
|
||||
parentSuspense,
|
||||
@ -698,9 +729,21 @@ export function createRenderer<
|
||||
))
|
||||
|
||||
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
|
||||
// unmount fallback tree
|
||||
if (fallback.el) {
|
||||
if (fallbackTree.el) {
|
||||
unmount(fallbackTree as HostVNode, parentComponent, suspense, true)
|
||||
}
|
||||
// move content from off-dom container to actual container
|
||||
@ -740,7 +783,6 @@ export function createRenderer<
|
||||
suspense.fallbackTree = fallback
|
||||
|
||||
// start mounting the content subtree in an off-dom container
|
||||
// TODO should buffer postQueue jobs on current boundary
|
||||
patch(
|
||||
null,
|
||||
content,
|
||||
@ -760,7 +802,7 @@ export function createRenderer<
|
||||
container,
|
||||
anchor,
|
||||
parentComponent,
|
||||
suspense,
|
||||
null, // fallback tree will not have suspense context
|
||||
isSVG,
|
||||
optimized
|
||||
)
|
||||
@ -769,14 +811,22 @@ export function createRenderer<
|
||||
// Suspense has no async deps. Just 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
|
||||
suspense.vnode = n2
|
||||
const { content, fallback } = normalizeSuspenseChildren(n2)
|
||||
const oldSubTree = (suspense.oldSubTree = suspense.subTree)
|
||||
suspense.subTree = content
|
||||
const oldFallbackTree = (suspense.oldFallbackTree = suspense.fallbackTree)
|
||||
suspense.fallbackTree = fallback
|
||||
const oldSubTree = suspense.subTree
|
||||
const oldFallbackTree = suspense.fallbackTree
|
||||
if (!suspense.isResolved) {
|
||||
patch(
|
||||
oldSubTree,
|
||||
@ -796,14 +846,15 @@ export function createRenderer<
|
||||
container,
|
||||
anchor,
|
||||
parentComponent,
|
||||
suspense,
|
||||
null, // fallback tree will not have suspense context
|
||||
isSVG,
|
||||
optimized
|
||||
)
|
||||
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 {
|
||||
// just normal patch inner content as a fragment
|
||||
patch(
|
||||
@ -818,7 +869,8 @@ export function createRenderer<
|
||||
)
|
||||
n2.el = content.el
|
||||
}
|
||||
}
|
||||
suspense.subTree = content
|
||||
suspense.fallbackTree = fallback
|
||||
}
|
||||
|
||||
function processComponent(
|
||||
@ -913,10 +965,19 @@ export function createRenderer<
|
||||
// before proceeding
|
||||
if (__FEATURE_SUSPENSE__ && instance.asyncDep) {
|
||||
if (!parentSuspense) {
|
||||
// TODO handle this properly
|
||||
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++
|
||||
instance.asyncDep.then(asyncSetupResult => {
|
||||
// unmounted before resolve
|
||||
if (instance.isUnmounted || parentSuspense.isUnmounted) {
|
||||
return
|
||||
}
|
||||
parentSuspense.deps--
|
||||
// retry from this component
|
||||
instance.asyncResolved = true
|
||||
@ -1481,12 +1542,7 @@ export function createRenderer<
|
||||
}
|
||||
|
||||
if (__FEATURE_SUSPENSE__ && suspense != null) {
|
||||
unmount(
|
||||
suspense.subTree as HostVNode,
|
||||
parentComponent,
|
||||
parentSuspense,
|
||||
doRemove
|
||||
)
|
||||
unmountSuspense(suspense, parentComponent, parentSuspense, doRemove)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1538,15 +1594,58 @@ export function createRenderer<
|
||||
stop(effects[i])
|
||||
}
|
||||
}
|
||||
// update may be null if a component is unmounted before its async
|
||||
// setup has resolved.
|
||||
if (update !== null) {
|
||||
stop(update)
|
||||
unmount(subTree, instance, parentSuspense, doRemove)
|
||||
}
|
||||
// unmounted hook
|
||||
if (um !== null) {
|
||||
queuePostRenderEffect(um, parentSuspense)
|
||||
// set unmounted after unmounted hooks are fired
|
||||
queuePostRenderEffect(() => {
|
||||
}
|
||||
queuePostFlushCb(() => {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,12 +12,11 @@ export interface SuspenseBoundary<
|
||||
vnode: HostVNode
|
||||
parent: SuspenseBoundary<HostNode, HostElement> | null
|
||||
container: HostElement
|
||||
subTree: HostVNode | null
|
||||
oldSubTree: HostVNode | null
|
||||
fallbackTree: HostVNode | null
|
||||
oldFallbackTree: HostVNode | null
|
||||
subTree: HostVNode
|
||||
fallbackTree: HostVNode
|
||||
deps: number
|
||||
isResolved: boolean
|
||||
isUnmounted: boolean
|
||||
effects: Function[]
|
||||
resolve(): void
|
||||
}
|
||||
@ -33,11 +32,10 @@ export function createSuspenseBoundary<HostNode, HostElement>(
|
||||
parent,
|
||||
container,
|
||||
deps: 0,
|
||||
subTree: null,
|
||||
oldSubTree: null,
|
||||
fallbackTree: null,
|
||||
oldFallbackTree: null,
|
||||
subTree: null as any, // will be set immediately after creation
|
||||
fallbackTree: null as any, // will be set immediately after creation
|
||||
isResolved: false,
|
||||
isUnmounted: false,
|
||||
effects: [],
|
||||
resolve
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user