diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index e9723f23652..47f9e86fd6d 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -418,6 +418,7 @@ export interface SuspenseBoundary { container: RendererElement hiddenContainer: RendererElement activeBranch: VNode | null + initialContent: VNode | null pendingBranch: VNode | null deps: number pendingId: number @@ -502,6 +503,7 @@ function createSuspenseBoundary( pendingId: suspenseId++, timeout: typeof timeout === 'number' ? timeout : -1, activeBranch: null, + initialContent: null, pendingBranch: null, isInFallback: !isHydrating, isHydrating, @@ -554,7 +556,11 @@ function createSuspenseBoundary( } } // unmount current active tree - if (activeBranch) { + // #7966 when Suspense is wrapped in Transition, the fallback node will be mounted + // in the afterLeave of Transition. This means that when Suspense is resolved, + // the activeBranch is not the fallback node but the initialContent. + // so avoid unmounting the activeBranch again. + if (activeBranch && activeBranch !== suspense.initialContent) { // if the fallback tree was mounted, it may have been moved // as part of a parent suspense. get the latest anchor for insertion // #8105 if `delayEnter` is true, it means that the mounting of @@ -631,6 +637,7 @@ function createSuspenseBoundary( const anchor = next(activeBranch!) const mountFallback = () => { + suspense.initialContent = null if (!suspense.isInFallback) { return } @@ -652,6 +659,7 @@ function createSuspenseBoundary( const delayEnter = fallbackVNode.transition && fallbackVNode.transition.mode === 'out-in' if (delayEnter) { + suspense.initialContent = activeBranch! activeBranch!.transition!.afterLeave = mountFallback } suspense.isInFallback = true diff --git a/packages/vue/__tests__/e2e/Transition.spec.ts b/packages/vue/__tests__/e2e/Transition.spec.ts index 46d865f890e..b7c17fcadd9 100644 --- a/packages/vue/__tests__/e2e/Transition.spec.ts +++ b/packages/vue/__tests__/e2e/Transition.spec.ts @@ -1769,6 +1769,74 @@ describe('e2e: Transition', () => { E2E_TIMEOUT, ) + test( + 'avoid unmount activeBranch twice with Suspense (out-in mode + timeout="0")', + async () => { + const unmountSpy = vi.fn() + await page().exposeFunction('unmountSpy', unmountSpy) + await page().evaluate(() => { + const { createApp, shallowRef, h } = (window as any).Vue + const One = { + setup() { + return () => + h( + 'div', + { + onVnodeBeforeUnmount: () => unmountSpy(), + }, + 'one', + ) + }, + } + const Two = { + async setup() { + return () => h('div', null, 'two') + }, + } + createApp({ + template: ` +
+ + + + + + +
+ + `, + setup: () => { + const view = shallowRef(One) + const click = () => { + view.value = view.value === One ? Two : One + } + return { view, click } + }, + }).mount('#app') + }) + + expect(await html('#container')).toBe('
one
') + + // leave + await classWhenTransitionStart() + await nextFrame() + expect(await html('#container')).toBe( + '
two
', + ) + + await transitionFinish() + expect(await html('#container')).toBe('
two
') + + // should only call unmount once + expect(unmountSpy).toBeCalledTimes(1) + }, + E2E_TIMEOUT, + ) + // #5844 test('children mount should be called after html changes', async () => { const fooMountSpy = vi.fn()