diff --git a/packages/runtime-core/__tests__/components/Suspense.spec.ts b/packages/runtime-core/__tests__/components/Suspense.spec.ts
index 203937aa526..1c0b8e98a49 100644
--- a/packages/runtime-core/__tests__/components/Suspense.spec.ts
+++ b/packages/runtime-core/__tests__/components/Suspense.spec.ts
@@ -1641,6 +1641,141 @@ describe('Suspense', () => {
expect(serializeInner(root)).toBe(expected)
})
+ //#8678
+ test('nested suspense (child suspense update before parent suspense resolve)', async () => {
+ const calls: string[] = []
+
+ const InnerA = defineAsyncComponent(
+ {
+ setup: () => {
+ calls.push('innerA created')
+ onMounted(() => {
+ calls.push('innerA mounted')
+ })
+ return () => h('div', 'innerA')
+ },
+ },
+ 10,
+ )
+
+ const InnerB = defineAsyncComponent(
+ {
+ setup: () => {
+ calls.push('innerB created')
+ onMounted(() => {
+ calls.push('innerB mounted')
+ })
+ return () => h('div', 'innerB')
+ },
+ },
+ 10,
+ )
+
+ const OuterA = defineAsyncComponent(
+ {
+ setup: (_, { slots }: any) => {
+ calls.push('outerA created')
+ onMounted(() => {
+ calls.push('outerA mounted')
+ })
+ return () =>
+ h(Fragment, null, [h('div', 'outerA'), slots.default?.()])
+ },
+ },
+ 5,
+ )
+
+ const OuterB = defineAsyncComponent(
+ {
+ setup: (_, { slots }: any) => {
+ calls.push('outerB created')
+ onMounted(() => {
+ calls.push('outerB mounted')
+ })
+ return () =>
+ h(Fragment, null, [h('div', 'outerB'), slots.default?.()])
+ },
+ },
+ 5,
+ )
+
+ const outerToggle = ref(false)
+ const innerToggle = ref(false)
+
+ /**
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+ const Comp = {
+ setup() {
+ return () =>
+ h(Suspense, null, {
+ default: [
+ h(outerToggle.value ? OuterB : OuterA, null, {
+ default: () =>
+ h(Suspense, null, {
+ default: h(innerToggle.value ? InnerB : InnerA),
+ }),
+ }),
+ ],
+ fallback: h('div', 'fallback outer'),
+ })
+ },
+ }
+
+ const root = nodeOps.createElement('div')
+ render(h(Comp), root)
+ expect(serializeInner(root)).toBe(`
fallback outer
`)
+
+ // mount outer component
+ await Promise.all(deps)
+ await nextTick()
+
+ expect(serializeInner(root)).toBe(`outerA
`)
+ expect(calls).toEqual([`outerA created`, `outerA mounted`])
+
+ // mount inner component
+ await Promise.all(deps)
+ await nextTick()
+ expect(serializeInner(root)).toBe(`outerA
innerA
`)
+
+ expect(calls).toEqual([
+ 'outerA created',
+ 'outerA mounted',
+ 'innerA created',
+ 'innerA mounted',
+ ])
+
+ calls.length = 0
+ deps.length = 0
+
+ // toggle both outer and inner components
+ outerToggle.value = true
+ innerToggle.value = true
+ await nextTick()
+
+ await Promise.all(deps)
+ await nextTick()
+ expect(serializeInner(root)).toBe(`outerB
`)
+
+ await Promise.all(deps)
+ await nextTick()
+ expect(serializeInner(root)).toBe(`outerB
innerB
`)
+
+ // innerB only mount once
+ expect(calls).toEqual([
+ 'outerB created',
+ 'outerB mounted',
+ 'innerB created',
+ 'innerB mounted',
+ ])
+ })
+
// #6416
test('KeepAlive with Suspense', async () => {
const Async = defineAsyncComponent({
diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts
index 8d6ee16410a..b9e0ccdd710 100644
--- a/packages/runtime-core/src/components/Suspense.ts
+++ b/packages/runtime-core/src/components/Suspense.ts
@@ -91,6 +91,18 @@ export const SuspenseImpl = {
rendererInternals,
)
} else {
+ // #8678 if the current suspense needs to be patched and parentSuspense has
+ // not been resolved. this means that both the current suspense and parentSuspense
+ // need to be patched. because parentSuspense's pendingBranch includes the
+ // current suspense, it will be processed twice:
+ // 1. current patch
+ // 2. mounting along with the pendingBranch of parentSuspense
+ // it is necessary to skip the current patch to avoid multiple mounts
+ // of inner components.
+ if (parentSuspense && parentSuspense.deps > 0) {
+ n2.suspense = n1.suspense
+ return
+ }
patchSuspense(
n1,
n2,