diff --git a/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts b/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts
index 958c1274806..efdd6df01e8 100644
--- a/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts
+++ b/packages/runtime-core/__tests__/rendererOptimizedMode.spec.ts
@@ -29,7 +29,7 @@ import {
setBlockTracking,
withCtx,
} from '@vue/runtime-test'
-import { PatchFlags, SlotFlags } from '@vue/shared'
+import { PatchFlags, SlotFlags, toDisplayString } from '@vue/shared'
import { SuspenseImpl } from '../src/components/Suspense'
describe('renderer: optimized mode', () => {
@@ -1294,4 +1294,62 @@ describe('renderer: optimized mode', () => {
expect(inner(root)).toBe('')
expect(beforeUnmountSpy).toHaveBeenCalledTimes(1)
})
+
+ // #12411
+ test('handle patch stable fragment with non-reactive v-for source', async () => {
+ const count = ref(0)
+ const foo: any = []
+ function updateFoo() {
+ for (let n = 0; n < 3; n++) {
+ foo[n] = n + 1 + '_foo'
+ }
+ }
+ const Comp = {
+ setup() {
+ return () => {
+ //
{{ count }}
+ // {{ item }}
+ return (
+ openBlock(),
+ createElementBlock(
+ Fragment,
+ null,
+ [
+ createElementVNode(
+ 'div',
+ null,
+ toDisplayString(count.value),
+ PatchFlags.TEXT,
+ ),
+ (openBlock(),
+ createElementBlock(
+ Fragment,
+ null,
+ renderList(foo, item => {
+ return createElementVNode(
+ 'div',
+ null,
+ toDisplayString(item),
+ PatchFlags.TEXT,
+ )
+ }),
+ PatchFlags.STABLE_FRAGMENT,
+ )),
+ ],
+ PatchFlags.STABLE_FRAGMENT,
+ )
+ )
+ }
+ },
+ }
+
+ render(h(Comp), root)
+ expect(inner(root)).toBe('0
')
+ updateFoo()
+ count.value++
+ await nextTick()
+ expect(inner(root)).toBe(
+ '1
1_foo
2_foo
3_foo
',
+ )
+ })
})
diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts
index 90cc22f5470..98f59f8e107 100644
--- a/packages/runtime-core/src/renderer.ts
+++ b/packages/runtime-core/src/renderer.ts
@@ -1079,7 +1079,8 @@ function baseCreateRenderer(
dynamicChildren &&
// #2715 the previous fragment could've been a BAILed one as a result
// of renderSlot() with no valid children
- n1.dynamicChildren
+ n1.dynamicChildren &&
+ n1.dynamicChildren.length === dynamicChildren.length
) {
// a stable fragment (template root or ) doesn't need to
// patch children order, but it may contain dynamicChildren.