From d62aff87b2d42709e6a17ff3d87bfc7c26ece12e Mon Sep 17 00:00:00 2001 From: linzhe Date: Mon, 8 Dec 2025 22:58:36 +0800 Subject: [PATCH 1/5] fix(runtime-core): ensure correct anchor el for deeper unresolved async components --- packages/runtime-core/src/renderer.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 192bb44474e..877456ed59e 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1996,7 +1996,7 @@ function baseCreateRenderer( const anchor = nextIndex + 1 < l2 ? // #13559, fallback to el placeholder for unresolved async component - anchorVNode.el || anchorVNode.placeholder + anchorVNode.el || resolveAsyncComponentPlaceholder(anchorVNode) : parentAnchor if (newIndexToOldIndexMap[i] === 0) { // mount new @@ -2577,3 +2577,26 @@ export function invalidateMount(hooks: LifecycleHook): void { hooks[i].flags! |= SchedulerJobFlags.DISPOSED } } + +function resolveAsyncComponentPlaceholder(anchorVnode: VNode) { + // anchor vnode is a unresolved async component + if (anchorVnode.placeholder) { + return anchorVnode.placeholder + } + + // anchor vnode maybe is a wrapper component has single unresolved async component + const asyncWrapper = anchorVnode.component + if (asyncWrapper) { + const subTree = asyncWrapper.subTree + + // wrapper that directly contains an unresolved async component + if (subTree.placeholder) { + return subTree.placeholder + } + + // try to locate deeper nested async component placeholder + return resolveAsyncComponentPlaceholder(subTree) + } + + return null +} From d368ab38ac4f3c123e063dded44e61f2e5ef46ab Mon Sep 17 00:00:00 2001 From: linzhe Date: Mon, 8 Dec 2025 23:37:50 +0800 Subject: [PATCH 2/5] chore: add test unit --- .../__tests__/components/Suspense.spec.ts | 95 +++++++++++++++++++ packages/runtime-core/src/renderer.ts | 3 +- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/__tests__/components/Suspense.spec.ts b/packages/runtime-core/__tests__/components/Suspense.spec.ts index 4e8da3288f1..bfcbb2af35b 100644 --- a/packages/runtime-core/__tests__/components/Suspense.spec.ts +++ b/packages/runtime-core/__tests__/components/Suspense.spec.ts @@ -2360,6 +2360,101 @@ describe('Suspense', () => { ) }) + // #14173 + test('renders multiple async component wrappers in Suspense with v-for and updates on items change', async () => { + const CompAsyncSetup = defineAsyncComponent({ + props: ['item', 'id'], + render(ctx: any) { + return h('div', ctx.id + '--' + ctx.item.name) + }, + }) + const items = ref([ + { id: 1, name: '111' }, + { id: 2, name: '222' }, + { id: 3, name: '333' }, + ]) + const Comp = { + props: ['id'], + setup(props: any) { + return () => + h(Suspense, null, { + default: () => + h( + Fragment, + null, + items.value.map(item => + h(CompAsyncSetup, { + item, + key: item.id, + id: 'foo:' + props.id, + }), + ), + ), + }) + }, + } + + const CompAsyncWrapper = defineAsyncComponent({ + props: ['id'], + render(ctx: any) { + return h(Comp, { id: ctx.id }) + }, + }) + const CompWrapper = defineComponent({ + props: ['id'], + render(ctx: any) { + return h(CompAsyncWrapper, { id: ctx.id }) + }, + }) + const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]) + + const App = { + setup() { + return () => + h(Suspense, null, { + default: () => + h( + Fragment, + null, + list.value.map(item => + h(CompWrapper, { id: item.id, key: item.id }), + ), + ), + }) + }, + } + + const root = nodeOps.createElement('div') + render(h(App), root) + await nextTick() + await Promise.all(deps) + await Promise.all(deps) + + expect(serializeInner(root)).toBe( + `
foo:1--111
foo:1--222
foo:1--333
foo:2--111
foo:2--222
foo:2--333
foo:3--111
foo:3--222
foo:3--333
`, + ) + + list.value = [{ id: 4 }, { id: 5 }, { id: 6 }] + await nextTick() + await Promise.all(deps) + await Promise.all(deps) + expect(serializeInner(root)).toBe( + `
foo:4--111
foo:4--222
foo:4--333
foo:5--111
foo:5--222
foo:5--333
foo:6--111
foo:6--222
foo:6--333
`, + ) + + items.value = [ + { id: 4, name: '444' }, + { id: 5, name: '555' }, + { id: 6, name: '666' }, + ] + await nextTick() + await Promise.all(deps) + await Promise.all(deps) + expect(serializeInner(root)).toBe( + `
foo:4--444
foo:4--555
foo:4--666
foo:5--444
foo:5--555
foo:5--666
foo:6--444
foo:6--555
foo:6--666
`, + ) + }) + test('should call unmounted directive once when fallback is replaced by resolved async component', async () => { const Comp = { render() { diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 877456ed59e..a371d125bad 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1995,7 +1995,8 @@ function baseCreateRenderer( const anchorVNode = c2[nextIndex + 1] as VNode const anchor = nextIndex + 1 < l2 - ? // #13559, fallback to el placeholder for unresolved async component + ? // #13559, #14173 + // fallback to el placeholder for unresolved async component anchorVNode.el || resolveAsyncComponentPlaceholder(anchorVNode) : parentAnchor if (newIndexToOldIndexMap[i] === 0) { From d186fd4bb0a0965728d3128f8383b69e868aa790 Mon Sep 17 00:00:00 2001 From: linzhe141 <1572213544@qq.com> Date: Tue, 9 Dec 2025 10:50:57 +0800 Subject: [PATCH 3/5] chore: tweaks --- .../runtime-core/__tests__/components/Suspense.spec.ts | 2 +- packages/runtime-core/src/renderer.ts | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/runtime-core/__tests__/components/Suspense.spec.ts b/packages/runtime-core/__tests__/components/Suspense.spec.ts index bfcbb2af35b..38913558a84 100644 --- a/packages/runtime-core/__tests__/components/Suspense.spec.ts +++ b/packages/runtime-core/__tests__/components/Suspense.spec.ts @@ -2361,7 +2361,7 @@ describe('Suspense', () => { }) // #14173 - test('renders multiple async component wrappers in Suspense with v-for and updates on items change', async () => { + test('nested async components with v-for + only Suspense and async component wrappers', async () => { const CompAsyncSetup = defineAsyncComponent({ props: ['item', 'id'], render(ctx: any) { diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index a371d125bad..6cdffba29b9 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1995,8 +1995,7 @@ function baseCreateRenderer( const anchorVNode = c2[nextIndex + 1] as VNode const anchor = nextIndex + 1 < l2 - ? // #13559, #14173 - // fallback to el placeholder for unresolved async component + ? // #13559, #14173 fallback to el placeholder for unresolved async component anchorVNode.el || resolveAsyncComponentPlaceholder(anchorVNode) : parentAnchor if (newIndexToOldIndexMap[i] === 0) { @@ -2589,12 +2588,6 @@ function resolveAsyncComponentPlaceholder(anchorVnode: VNode) { const asyncWrapper = anchorVnode.component if (asyncWrapper) { const subTree = asyncWrapper.subTree - - // wrapper that directly contains an unresolved async component - if (subTree.placeholder) { - return subTree.placeholder - } - // try to locate deeper nested async component placeholder return resolveAsyncComponentPlaceholder(subTree) } From 13995cb88dda3dc659b582c24b53013c8d42fcfe Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 9 Dec 2025 11:21:01 +0800 Subject: [PATCH 4/5] fix(renderer): simplify async component placeholder resolution logic --- packages/runtime-core/src/renderer.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 6cdffba29b9..5aa12895f36 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -2579,17 +2579,14 @@ export function invalidateMount(hooks: LifecycleHook): void { } function resolveAsyncComponentPlaceholder(anchorVnode: VNode) { - // anchor vnode is a unresolved async component if (anchorVnode.placeholder) { return anchorVnode.placeholder } // anchor vnode maybe is a wrapper component has single unresolved async component - const asyncWrapper = anchorVnode.component - if (asyncWrapper) { - const subTree = asyncWrapper.subTree - // try to locate deeper nested async component placeholder - return resolveAsyncComponentPlaceholder(subTree) + const instance = anchorVnode.component + if (instance) { + return resolveAsyncComponentPlaceholder(instance.subTree) } return null From 82a13bb6faaa9f77a06b57e69e0934b9f620f333 Mon Sep 17 00:00:00 2001 From: linzhe141 <1572213544@qq.com> Date: Wed, 10 Dec 2025 15:04:37 +0800 Subject: [PATCH 5/5] chore: simplify unit test --- .../__tests__/components/Suspense.spec.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/runtime-core/__tests__/components/Suspense.spec.ts b/packages/runtime-core/__tests__/components/Suspense.spec.ts index 38913558a84..056758729ba 100644 --- a/packages/runtime-core/__tests__/components/Suspense.spec.ts +++ b/packages/runtime-core/__tests__/components/Suspense.spec.ts @@ -2365,13 +2365,13 @@ describe('Suspense', () => { const CompAsyncSetup = defineAsyncComponent({ props: ['item', 'id'], render(ctx: any) { - return h('div', ctx.id + '--' + ctx.item.name) + return h('div', ctx.id + '-' + ctx.item.name) }, }) const items = ref([ - { id: 1, name: '111' }, - { id: 2, name: '222' }, - { id: 3, name: '333' }, + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, ]) const Comp = { props: ['id'], @@ -2386,7 +2386,7 @@ describe('Suspense', () => { h(CompAsyncSetup, { item, key: item.id, - id: 'foo:' + props.id, + id: props.id, }), ), ), @@ -2431,7 +2431,7 @@ describe('Suspense', () => { await Promise.all(deps) expect(serializeInner(root)).toBe( - `
foo:1--111
foo:1--222
foo:1--333
foo:2--111
foo:2--222
foo:2--333
foo:3--111
foo:3--222
foo:3--333
`, + `
1-a
1-b
1-c
2-a
2-b
2-c
3-a
3-b
3-c
`, ) list.value = [{ id: 4 }, { id: 5 }, { id: 6 }] @@ -2439,19 +2439,19 @@ describe('Suspense', () => { await Promise.all(deps) await Promise.all(deps) expect(serializeInner(root)).toBe( - `
foo:4--111
foo:4--222
foo:4--333
foo:5--111
foo:5--222
foo:5--333
foo:6--111
foo:6--222
foo:6--333
`, + `
4-a
4-b
4-c
5-a
5-b
5-c
6-a
6-b
6-c
`, ) items.value = [ - { id: 4, name: '444' }, - { id: 5, name: '555' }, - { id: 6, name: '666' }, + { id: 4, name: 'd' }, + { id: 5, name: 'f' }, + { id: 6, name: 'g' }, ] await nextTick() await Promise.all(deps) await Promise.all(deps) expect(serializeInner(root)).toBe( - `
foo:4--444
foo:4--555
foo:4--666
foo:5--444
foo:5--555
foo:5--666
foo:6--444
foo:6--555
foo:6--666
`, + `
4-d
4-f
4-g
5-d
5-f
5-g
6-d
6-f
6-g
`, ) })