diff --git a/packages/runtime-core/__tests__/components/Suspense.spec.ts b/packages/runtime-core/__tests__/components/Suspense.spec.ts
index 4e8da3288f1..056758729ba 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('nested async components with v-for + only Suspense and async component wrappers', async () => {
+ const CompAsyncSetup = defineAsyncComponent({
+ props: ['item', 'id'],
+ render(ctx: any) {
+ return h('div', ctx.id + '-' + ctx.item.name)
+ },
+ })
+ const items = ref([
+ { id: 1, name: 'a' },
+ { id: 2, name: 'b' },
+ { id: 3, name: 'c' },
+ ])
+ 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: 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(
+ `
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 }]
+ await nextTick()
+ await Promise.all(deps)
+ await Promise.all(deps)
+ expect(serializeInner(root)).toBe(
+ `4-a
4-b
4-c
5-a
5-b
5-c
6-a
6-b
6-c
`,
+ )
+
+ items.value = [
+ { 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(
+ `4-d
4-f
4-g
5-d
5-f
5-g
6-d
6-f
6-g
`,
+ )
+ })
+
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 192bb44474e..5aa12895f36 100644
--- a/packages/runtime-core/src/renderer.ts
+++ b/packages/runtime-core/src/renderer.ts
@@ -1995,8 +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
- anchorVNode.el || anchorVNode.placeholder
+ ? // #13559, #14173 fallback to el placeholder for unresolved async component
+ anchorVNode.el || resolveAsyncComponentPlaceholder(anchorVNode)
: parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// mount new
@@ -2577,3 +2577,17 @@ export function invalidateMount(hooks: LifecycleHook): void {
hooks[i].flags! |= SchedulerJobFlags.DISPOSED
}
}
+
+function resolveAsyncComponentPlaceholder(anchorVnode: VNode) {
+ if (anchorVnode.placeholder) {
+ return anchorVnode.placeholder
+ }
+
+ // anchor vnode maybe is a wrapper component has single unresolved async component
+ const instance = anchorVnode.component
+ if (instance) {
+ return resolveAsyncComponentPlaceholder(instance.subTree)
+ }
+
+ return null
+}