Просмотр исходного кода

perf(runtime-vapor): reduce v-if branch scope overhead

daiwei 2 недель назад
Родитель
Сommit
0aa49b39f7

+ 15 - 15
packages/compiler-vapor/__tests__/transforms/__snapshots__/vIf.spec.ts.snap

@@ -36,7 +36,7 @@ export function render(_ctx) {
     const n10 = t3()
     const n11 = t4()
     return [n10, n11]
-  }, 74 /* BLOCK_SHAPE, INDEX_SHIFT */), 37 /* BLOCK_SHAPE, INDEX_SHIFT */)
+  }, 362 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */)
   const n13 = t5()
   const x13 = _txt(n13)
   _renderEffect(() => _setText(x13, _toDisplayString(_ctx.text)))
@@ -65,11 +65,11 @@ export function render(_ctx) {
   const n0 = _createIf(() => (_ctx.ok), () => {
     const n2 = t0()
     return n2
-  })
+  }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */)
   const n3 = _createIf(() => (_ctx.ok), () => {
     const n5 = t0()
     return n5
-  })
+  }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */)
   return [n0, n3]
 }"
 `;
@@ -87,11 +87,11 @@ export function render(_ctx) {
   }, () => _createIf(() => (_ctx.bar), () => {
     const n4 = t1()
     return n4
-  }), 37 /* BLOCK_SHAPE, INDEX_SHIFT */)
+  }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */)
   const n6 = _createIf(() => (_ctx.baz), () => {
     const n8 = t2()
     return n8
-  })
+  }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */)
   return [n0, n6]
 }"
 `;
@@ -106,7 +106,7 @@ export function render(_ctx) {
     const n2 = t0()
     const n3 = t1()
     return [n2, n3]
-  }, null, 2 /* BLOCK_SHAPE */)
+  }, null, 34 /* BLOCK_SHAPE, TRUE_NO_SCOPE */)
   return n0
 }"
 `;
@@ -119,7 +119,7 @@ export function render(_ctx) {
   const n0 = _createIf(() => (_ctx.foo), () => {
     const n2 = t0()
     return n2
-  })
+  }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */)
   return n0
 }"
 `;
@@ -132,7 +132,7 @@ export function render(_ctx) {
   const n0 = _createIf(() => (_ctx.foo), () => {
     const n2 = t0()
     return n2
-  })
+  }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */)
   return n0
 }"
 `;
@@ -185,7 +185,7 @@ export function render(_ctx) {
   }, () => {
     const n5 = t2()
     return n5
-  }, 38 /* BLOCK_SHAPE, INDEX_SHIFT */)
+  }, 230 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */)
   return n0
 }"
 `;
@@ -221,7 +221,7 @@ export function render(_ctx) {
   }, () => {
     const n4 = t1()
     return n4
-  }, 37 /* BLOCK_SHAPE, INDEX_SHIFT */)
+  }, 229 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */)
   return n0
 }"
 `;
@@ -245,7 +245,7 @@ export function render(_ctx) {
   }, () => {
     const n10 = t2()
     return n10
-  }, 21 /* BLOCK_SHAPE, ONCE */), 69 /* BLOCK_SHAPE, INDEX_SHIFT */), 37 /* BLOCK_SHAPE, INDEX_SHIFT */)
+  }, 117 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, ONCE */), 293 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */)
   return n0
 }"
 `;
@@ -262,7 +262,7 @@ export function render(_ctx) {
   }, () => _createIf(() => (_ctx.orNot), () => {
     const n4 = t1()
     return n4
-  }), 37 /* BLOCK_SHAPE, INDEX_SHIFT */)
+  }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */)
   return n0
 }"
 `;
@@ -280,7 +280,7 @@ export function render(_ctx) {
   const n0 = _createIf(() => (_ctx.foo), () => {
     const n2 = t0()
     return n2
-  })
+  }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */)
   _setInsertionState(n8, null, 1)
   const n3 = _createIf(() => (_ctx.bar), () => {
     const n5 = t1()
@@ -288,7 +288,7 @@ export function render(_ctx) {
   }, () => {
     const n7 = t2()
     return n7
-  }, 69 /* BLOCK_SHAPE, INDEX_SHIFT */)
+  }, 357 /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */)
   return n8
 }"
 `;
@@ -306,7 +306,7 @@ export function render(_ctx) {
   }, () => _createIf(() => (_ctx.bar), () => {
     const n4 = t1()
     return n4
-  }), 37 /* BLOCK_SHAPE, INDEX_SHIFT */)
+  }, null, 33 /* BLOCK_SHAPE, TRUE_NO_SCOPE */), 165 /* BLOCK_SHAPE, TRUE_NO_SCOPE, INDEX_SHIFT */)
   const n6 = t2()
   return [n0, n6]
 }"

+ 70 - 12
packages/compiler-vapor/__tests__/transforms/vIf.spec.ts

@@ -5,6 +5,7 @@ import {
   transformChildren,
   transformComment,
   transformElement,
+  transformTemplateRef,
   transformText,
   transformVFor,
   transformVIf,
@@ -12,7 +13,15 @@ import {
   transformVText,
 } from '../../src'
 import { ErrorCodes, NodeTypes, type RootNode } from '@vue/compiler-dom'
-import { VaporBlockShape } from '@vue/shared'
+import { VaporBlockShape, VaporIfFlags } from '@vue/shared'
+
+const singleRootNoScope =
+  VaporBlockShape.SINGLE_ROOT | VaporIfFlags.TRUE_NO_SCOPE
+const singleRootIfElseNoScope =
+  VaporBlockShape.SINGLE_ROOT |
+  (VaporBlockShape.SINGLE_ROOT << 2) |
+  VaporIfFlags.TRUE_NO_SCOPE |
+  VaporIfFlags.FALSE_NO_SCOPE
 
 const compileWithVIf = makeCompile({
   nodeTransforms: [
@@ -20,6 +29,7 @@ const compileWithVIf = makeCompile({
     transformVIf,
     transformVFor,
     transformText,
+    transformTemplateRef,
     transformElement,
     transformComment,
     transformChildren,
@@ -66,13 +76,31 @@ describe('compiler: v-if', () => {
     expect(code).matchSnapshot()
   })
 
-  test('omits default single-root flags', () => {
-    const { code } = compileWithVIf(`<div v-if="ok" />`)
+  test('omits default single-root flags when branch needs scope', () => {
+    const { code } = compileWithVIf(`<div v-if="ok">{{ msg }}</div>`)
 
     expect(code).contains(`const n0 = _createIf(() => (_ctx.ok), () => {`)
     expect(code).not.contains(`}, null, 1)`)
   })
 
+  test('marks pure static single-root branch as no-scope', () => {
+    const { code } = compileWithVIf(`<div v-if="ok">static</div>`)
+
+    expect(code).contains(
+      `}, null, ${singleRootNoScope} /* BLOCK_SHAPE, TRUE_NO_SCOPE */)`,
+    )
+  })
+
+  test('marks pure static multi-root branch as no-scope', () => {
+    const { code } = compileWithVIf(
+      `<template v-if="ok"><div>one</div><p>two</p></template>`,
+    )
+
+    expect(code).contains(
+      `}, null, ${VaporBlockShape.MULTI_ROOT | VaporIfFlags.TRUE_NO_SCOPE} /* BLOCK_SHAPE, TRUE_NO_SCOPE */)`,
+    )
+  })
+
   test('packs once flag', () => {
     const { code } = compileWithVIf(`<div v-if="ok" v-once />`)
 
@@ -85,10 +113,30 @@ describe('compiler: v-if', () => {
       `<div v-if="foo">foo</div><div v-else>bar</div>`,
     )
 
-    expect(code).contains(`}, 37 /* BLOCK_SHAPE, INDEX_SHIFT */)`)
+    expect(code).contains(
+      `}, ${singleRootIfElseNoScope | (1 << VaporIfFlags.INDEX_SHIFT)} /* BLOCK_SHAPE, TRUE_NO_SCOPE, FALSE_NO_SCOPE, INDEX_SHIFT */)`,
+    )
     expect(code).not.contains(`}, 5, null, 0)`)
   })
 
+  test('does not mark scoped branches as no-scope', () => {
+    const cases = [
+      `<div v-if="ok">{{ msg }}</div>`,
+      `<div v-if="ok" :class="foo"></div>`,
+      `<div v-if="ok" @click="foo"></div>`,
+      `<Comp v-if="ok" />`,
+      `<div v-if="ok" ref="el"></div>`,
+      `<template v-if="ok"><div/>{{ msg }}</template>`,
+      `<div v-if="ok"><span v-if="bar" /></div>`,
+    ]
+
+    for (const source of cases) {
+      const { code } = compileWithVIf(source)
+      expect(code.includes('NO_SCOPE'), source).toBe(false)
+      expect(code.includes(`, ${singleRootNoScope} /*`), source).toBe(false)
+    }
+  })
+
   test('multiple v-if at root', () => {
     const { code, ir } = compileWithVIf(
       `<div v-if="foo">foo</div><div v-else-if="bar">bar</div><div v-if="baz">baz</div>`,
@@ -161,6 +209,9 @@ describe('compiler: v-if', () => {
     const { code, ir } = compileWithVIf(`<template v-if="foo">hello</template>`)
 
     expect(code).toMatchSnapshot()
+    expect(code).contains(
+      `}, null, ${singleRootNoScope} /* BLOCK_SHAPE, TRUE_NO_SCOPE */)`,
+    )
     expect(code).toContain('_template("hello", 2)')
     expect([...ir.template.keys()]).toMatchObject(['hello'])
   })
@@ -187,7 +238,7 @@ describe('compiler: v-if', () => {
     expect([...ir.template.keys()]).toMatchObject(['<div>hi', '<div>ho'])
     expect(
       (ir.block.dynamic.children[0].operation as IfIRNode).blockShape,
-    ).toBe(VaporBlockShape.MULTI_ROOT)
+    ).toBe(VaporBlockShape.MULTI_ROOT | VaporIfFlags.TRUE_NO_SCOPE)
   })
 
   test('template v-if (with v-for inside)', () => {
@@ -257,7 +308,12 @@ describe('compiler: v-if', () => {
     ])
     expect(
       (ir.block.dynamic.children[0].operation as IfIRNode).blockShape,
-    ).toBe(VaporBlockShape.MULTI_ROOT | (VaporBlockShape.SINGLE_ROOT << 2))
+    ).toBe(
+      VaporBlockShape.MULTI_ROOT |
+        (VaporBlockShape.SINGLE_ROOT << 2) |
+        VaporIfFlags.TRUE_NO_SCOPE |
+        VaporIfFlags.FALSE_NO_SCOPE,
+    )
   })
 
   test('dedupe same template', () => {
@@ -421,12 +477,14 @@ describe('compiler: v-if', () => {
     const op = ir.block.dynamic.children[0].operation as IfIRNode
     const nested = op.negative as IfIRNode
     const innermost = nested.negative as IfIRNode
-    const singleOrSingle =
-      VaporBlockShape.SINGLE_ROOT | (VaporBlockShape.SINGLE_ROOT << 2)
-
-    expect(op.blockShape).toBe(singleOrSingle)
-    expect(nested.blockShape).toBe(singleOrSingle)
-    expect(innermost.blockShape).toBe(singleOrSingle)
+    const noScopeOrSingle =
+      VaporBlockShape.SINGLE_ROOT |
+      (VaporBlockShape.SINGLE_ROOT << 2) |
+      VaporIfFlags.TRUE_NO_SCOPE
+
+    expect(op.blockShape).toBe(noScopeOrSingle)
+    expect(nested.blockShape).toBe(noScopeOrSingle)
+    expect(innermost.blockShape).toBe(singleRootIfElseNoScope)
   })
 
   test('v-if + v-if / v-else[-if]', () => {

+ 9 - 1
packages/compiler-vapor/src/generators/if.ts

@@ -67,16 +67,24 @@ function genIfFlags(
   }
 
   return __DEV__
-    ? `${flags} /* ${genIfFlagNames(once, index)} */`
+    ? `${flags} /* ${genIfFlagNames(once, index, blockShape)} */`
     : String(flags)
 }
 
 function genIfFlagNames(
   once: boolean | undefined,
   index: number | undefined,
+  blockShape: number,
 ): string {
   const names = ['BLOCK_SHAPE']
 
+  if (blockShape & VaporIfFlags.TRUE_NO_SCOPE) {
+    names.push('TRUE_NO_SCOPE')
+  }
+  if (blockShape & VaporIfFlags.FALSE_NO_SCOPE) {
+    names.push('FALSE_NO_SCOPE')
+  }
+
   if (once) {
     names.push('ONCE')
   } else if (index !== undefined) {

+ 96 - 4
packages/compiler-vapor/src/transforms/vIf.ts

@@ -3,6 +3,7 @@ import {
   ElementTypes,
   ErrorCodes,
   NodeTypes,
+  type TemplateChildNode,
   createCompilerError,
   createSimpleExpression,
 } from '@vue/compiler-dom'
@@ -14,11 +15,12 @@ import {
 import {
   type BlockIRNode,
   DynamicFlag,
+  type IRDynamicInfo,
   IRNodeTypes,
   type IfIRNode,
   type VaporDirectiveNode,
 } from '../ir'
-import { VaporBlockShape, extend } from '@vue/shared'
+import { VaporBlockShape, VaporIfFlags, extend } from '@vue/shared'
 import { newBlock, wrapTemplate } from './utils'
 import { getSiblingIf } from './transformComment'
 import { getBlockShape, isStaticExpression } from '../utils'
@@ -43,6 +45,9 @@ export function processIf(
 
   context.dynamic.flags |= DynamicFlag.NON_TEMPLATE
   const forceMultiRoot = shouldForceMultiRoot(context)
+  // Nested dynamic units are owned by an enclosing branch scope, so only mark
+  // root-block branches with the compiler-proven no-scope flag.
+  const allowNoScope = context.block === context.root.block
   if (dir.name === 'if') {
     const id = context.reference()
     context.dynamic.flags |= DynamicFlag.INSERT
@@ -54,7 +59,12 @@ export function processIf(
         type: IRNodeTypes.IF,
         id,
         ...context.effectBoundary(),
-        blockShape: encodeIfBlockShape(branch, forceMultiRoot),
+        blockShape: encodeIfBlockShape(
+          branch,
+          forceMultiRoot,
+          undefined,
+          allowNoScope,
+        ),
         condition: dir.exp!,
         positive: branch,
         index: context.root.nextIfIndex(),
@@ -139,12 +149,15 @@ export function processIf(
         lastIfNode.negative.blockShape = encodeIfBlockShape(
           lastIfNode.negative.positive,
           forceMultiRoot,
+          undefined,
+          allowNoScope,
         )
       }
       lastIfNode.blockShape = encodeIfBlockShape(
         lastIfNode.positive,
         forceMultiRoot,
         lastIfNode.negative,
+        allowNoScope,
       )
     }
   }
@@ -166,22 +179,101 @@ function encodeIfBlockShape(
   positive: BlockIRNode,
   forceMultiRoot: boolean = false,
   negative?: BlockIRNode | IfIRNode,
+  allowNoScope: boolean = true,
 ): number {
   // Pack the true/false branch shapes into one integer so runtime `createIf()`
   // can decode the selected branch with a single bit-mask operation.
   if (forceMultiRoot) {
     return VaporBlockShape.MULTI_ROOT | (VaporBlockShape.MULTI_ROOT << 2)
   }
-  return getBlockShape(positive) | (getNegativeBlockShape(negative) << 2)
+
+  const positiveNoScope = allowNoScope && canSkipIfBranchScope(positive)
+  const negativeNoScope =
+    allowNoScope &&
+    negative &&
+    negative.type !== IRNodeTypes.IF &&
+    canSkipIfBranchScope(negative)
+
+  return (
+    getBlockShape(positive) |
+    (getNegativeIfBranchShape(negative) << 2) |
+    (positiveNoScope ? VaporIfFlags.TRUE_NO_SCOPE : 0) |
+    (negativeNoScope ? VaporIfFlags.FALSE_NO_SCOPE : 0)
+  )
 }
 
-function getNegativeBlockShape(negative?: BlockIRNode | IfIRNode) {
+function getNegativeIfBranchShape(
+  negative?: BlockIRNode | IfIRNode,
+): VaporBlockShape {
   if (!negative) return VaporBlockShape.EMPTY
   return negative.type === IRNodeTypes.IF
     ? VaporBlockShape.SINGLE_ROOT
     : getBlockShape(negative)
 }
 
+function canSkipIfBranchScope(block: BlockIRNode): boolean {
+  if (block.effect.length || block.operation.length) {
+    return false
+  }
+  if (!isStaticBranch(block.node)) {
+    return false
+  }
+
+  if (
+    block.returns.length === 0 ||
+    block.dynamic.children.length !== block.returns.length
+  ) {
+    return false
+  }
+
+  return block.returns.every(id => {
+    const returned = findReturnedDynamic(block, id)
+    return !!(
+      returned &&
+      returned.template != null &&
+      !returned.operation &&
+      !returned.hasDynamicChild &&
+      !(returned.flags & (DynamicFlag.INSERT | DynamicFlag.NON_TEMPLATE))
+    )
+  })
+}
+
+function findReturnedDynamic(
+  block: BlockIRNode,
+  id: number,
+): IRDynamicInfo | undefined {
+  return block.dynamic.children.find(child => child.id === id)
+}
+
+function isStaticBranch(node: BlockIRNode['node']): boolean {
+  if (
+    node.type !== NodeTypes.ELEMENT ||
+    node.tagType !== ElementTypes.TEMPLATE ||
+    node.children.length === 0
+  ) {
+    return false
+  }
+  return node.children.every(child => isStaticTemplateNode(child))
+}
+
+function isStaticTemplateNode(node: TemplateChildNode): boolean {
+  if (node.type === NodeTypes.TEXT || node.type === NodeTypes.COMMENT) {
+    return true
+  }
+  if (
+    node.type !== NodeTypes.ELEMENT ||
+    node.tagType !== ElementTypes.ELEMENT
+  ) {
+    return false
+  }
+  for (const prop of node.props) {
+    if (prop.type === NodeTypes.DIRECTIVE || prop.name === 'ref') {
+      return false
+    }
+  }
+  return node.children.every(child => isStaticTemplateNode(child))
+}
+
 // SSR renders `v-if` inside `<template v-for>` always output <!--[-->...<!--]-->.
 // should mark the block as multi-root
 function shouldForceMultiRoot(context: TransformContext<ElementNode>): boolean {

+ 179 - 1
packages/runtime-vapor/__tests__/if.spec.ts

@@ -1,8 +1,10 @@
 import {
+  VaporKeepAlive,
   VaporTransition,
   child,
   createComponent,
   createIf,
+  defineVaporComponent,
   insert,
   renderEffect,
   template,
@@ -10,7 +12,7 @@ import {
   withDirectives,
 } from '../src'
 import { nextTick, ref } from '@vue/runtime-dom'
-import { VaporBlockShape } from '@vue/shared'
+import { VaporBlockShape, VaporIfFlags } from '@vue/shared'
 import type { Mock } from 'vitest'
 import { ifFlags, makeRender } from './_utils'
 import { unmountComponent } from '../src/component'
@@ -20,6 +22,10 @@ import type { DynamicFragment } from '../src/fragment'
 const define = makeRender()
 const singleRootIfElse =
   VaporBlockShape.SINGLE_ROOT | (VaporBlockShape.SINGLE_ROOT << 2)
+const singleRootNoScope =
+  VaporBlockShape.SINGLE_ROOT | VaporIfFlags.TRUE_NO_SCOPE
+const singleRootNoScopeIfElse =
+  singleRootIfElse | VaporIfFlags.TRUE_NO_SCOPE | VaporIfFlags.FALSE_NO_SCOPE
 
 describe('createIf', () => {
   test('basic', async () => {
@@ -189,6 +195,178 @@ describe('createIf', () => {
     expect(onUpdated).toHaveBeenCalledTimes(2)
   })
 
+  test('should skip branch scope for compiler-proven static single-root branch', async () => {
+    const show = ref(true)
+    const t0 = template('<div>foo</div>')
+    let frag!: DynamicFragment
+
+    const { host } = define(() => {
+      frag = createIf(
+        () => show.value,
+        () => t0(),
+        undefined,
+        singleRootNoScope,
+      ) as DynamicFragment
+      return frag
+    }).render()
+
+    expect(host.innerHTML).toBe('<div>foo</div><!--if-->')
+    expect(frag.scope).toBeUndefined()
+
+    show.value = false
+    await nextTick()
+    expect(host.innerHTML).toBe('<!--if-->')
+    expect(frag.scope).toBeUndefined()
+
+    show.value = true
+    await nextTick()
+    expect(host.innerHTML).toBe('<div>foo</div><!--if-->')
+    expect(frag.scope).toBeUndefined()
+  })
+
+  test('should skip branch scope for compiler-proven static multi-root branch', async () => {
+    const show = ref(true)
+    const t0 = template('<div>foo</div>')
+    const t1 = template('<p>bar</p>')
+    let frag!: DynamicFragment
+
+    const { host } = define(() => {
+      frag = createIf(
+        () => show.value,
+        () => [t0(), t1()],
+        undefined,
+        VaporBlockShape.MULTI_ROOT | VaporIfFlags.TRUE_NO_SCOPE,
+      ) as DynamicFragment
+      return frag
+    }).render()
+
+    expect(host.innerHTML).toBe('<div>foo</div><p>bar</p><!--if-->')
+    expect(frag.scope).toBeUndefined()
+
+    show.value = false
+    await nextTick()
+    expect(host.innerHTML).toBe('<!--if-->')
+    expect(frag.scope).toBeUndefined()
+  })
+
+  test('should replace no-scope static if and else branches', async () => {
+    const show = ref(true)
+    const t0 = template('<div>foo</div>')
+    const t1 = template('<p>bar</p>')
+    let frag!: DynamicFragment
+
+    const { host } = define(() => {
+      frag = createIf(
+        () => show.value,
+        () => t0(),
+        () => t1(),
+        singleRootNoScopeIfElse,
+      ) as DynamicFragment
+      return frag
+    }).render()
+
+    expect(host.innerHTML).toBe('<div>foo</div><!--if-->')
+    expect(frag.scope).toBeUndefined()
+
+    show.value = false
+    await nextTick()
+    expect(host.innerHTML).toBe('<p>bar</p><!--if-->')
+    expect(frag.scope).toBeUndefined()
+  })
+
+  test('should preserve no-scope pending branch during out-in transition', async () => {
+    const show = ref(true)
+    const onLeave = vi.fn((_: Element, done: () => void) => setTimeout(done, 0))
+    const t0 = template('<div>foo</div>')
+    const t1 = template('<p>bar</p>')
+    let frag!: DynamicFragment
+
+    const { host } = define(() =>
+      createComponent(
+        VaporTransition,
+        { mode: () => 'out-in', onLeave: () => onLeave },
+        {
+          default: () =>
+            (frag = createIf(
+              () => show.value,
+              () => t0(),
+              () => t1(),
+              ifFlags(singleRootNoScopeIfElse, false, 0),
+            ) as DynamicFragment),
+        },
+        true,
+      ),
+    ).render()
+
+    expect(host.innerHTML).toBe('<div>foo</div><!--if-->')
+    expect(frag.scope).toBeUndefined()
+
+    show.value = false
+    await nextTick()
+    expect(host.textContent).toContain('foo')
+    expect(host.textContent).not.toContain('bar')
+    expect(onLeave).toHaveBeenCalledTimes(1)
+
+    await new Promise(r => setTimeout(r, 0))
+    await nextTick()
+    expect(host.innerHTML).toContain('bar')
+    expect(host.innerHTML).not.toContain('foo')
+    expect(frag.scope).toBeUndefined()
+  })
+
+  test('should skip no-scope static branch under KeepAlive', async () => {
+    const show = ref(false)
+    const childSetup = vi.fn()
+    const t0 = template('<p>static</p>')
+    const t1 = template('<div>child</div>')
+    const Child = defineVaporComponent({
+      name: 'Child',
+      setup() {
+        childSetup()
+        return t1()
+      },
+    })
+    let frag!: DynamicFragment
+    const flags = ifFlags(
+      singleRootIfElse | VaporIfFlags.FALSE_NO_SCOPE,
+      false,
+      0,
+    )
+
+    const { host } = define(() =>
+      createComponent(VaporKeepAlive, null, {
+        default: () =>
+          (frag = createIf(
+            () => show.value,
+            () => createComponent(Child),
+            () => t0(),
+            flags,
+          ) as DynamicFragment),
+      }),
+    ).render()
+
+    expect(host.innerHTML).toBe('<p>static</p><!--if-->')
+    expect(frag.scope).toBeUndefined()
+
+    show.value = true
+    await nextTick()
+    expect(host.innerHTML).toBe('<div>child</div><!--if-->')
+    expect(frag.scope).toBeDefined()
+    expect(childSetup).toHaveBeenCalledTimes(1)
+    const componentScope = frag.scope
+
+    show.value = false
+    await nextTick()
+    expect(host.innerHTML).toBe('<p>static</p><!--if-->')
+    expect(frag.scope).toBeUndefined()
+
+    show.value = true
+    await nextTick()
+    expect(host.innerHTML).toBe('<div>child</div><!--if-->')
+    expect(frag.scope).toBe(componentScope)
+    expect(childSetup).toHaveBeenCalledTimes(1)
+  })
+
   test('should not set branch block key without Transition or KeepAlive', async () => {
     const show = ref(true)
     const t0 = template('<div>foo</div>')

+ 10 - 3
packages/runtime-vapor/src/apiCreateIf.ts

@@ -67,6 +67,7 @@ export function createIf(
       ;(frag as DynamicFragment).update(
         ok ? b1 : b2,
         keyed ? keyBase + (ok ? 0 : 1) : undefined,
+        isNoScopeBranch(flags, ok),
       )
     })
   }
@@ -93,7 +94,7 @@ export function createIf(
   return frag
 }
 
-// The compiler packs the true/false branch shapes into one integer:
+// The compiler packs the true/false branch shapes into the low bits:
 //   packed = trueShape | (falseShape << 2)
 //
 // Each branch shape fits in 2 bits:
@@ -110,8 +111,14 @@ export function createIf(
 // - true branch:  shift by 0, then keep the low 2 bits -> 0b10
 // - false branch: shift by 2, then keep the low 2 bits -> 0b01
 //
-// `0b11` is the binary mask for the low 2 bits (decimal `3`).
-// `value & 0b11` clears everything except the active branch shape.
+// `0b11` clears everything except the active branch shape; no-scope and index
+// metadata live in higher bits and are decoded separately.
 function decodeIfShape(shape: number, ok: boolean): VaporBlockShape {
   return ((shape >> (ok ? 0 : 2)) & 0b11) as VaporBlockShape
 }
+
+function isNoScopeBranch(flags: number, ok: boolean): boolean {
+  return !!(
+    flags & (ok ? VaporIfFlags.TRUE_NO_SCOPE : VaporIfFlags.FALSE_NO_SCOPE)
+  )
+}

+ 33 - 16
packages/runtime-vapor/src/fragment.ts

@@ -241,7 +241,7 @@ export class DynamicFragment extends VaporFragment {
   anchor: Node
   scope: EffectScope | undefined
   current?: BlockFn
-  pending?: { render?: BlockFn; key: any }
+  pending?: { render?: BlockFn; key: any; noScope: boolean }
   anchorLabel?: string
   keyed?: boolean
   inTransition?: boolean
@@ -274,7 +274,7 @@ export class DynamicFragment extends VaporFragment {
     if (trackSlotBoundary) trackSlotBoundaryDirtying(this)
   }
 
-  update(render?: BlockFn, key: any = render): void {
+  update(render?: BlockFn, key: any = render, noScope: boolean = false): void {
     if (key === this.current) {
       // On initial hydration, `key === current` means `render` is empty,
       // so this fragment hydrates as empty content.
@@ -294,8 +294,9 @@ export class DynamicFragment extends VaporFragment {
       if (pending) {
         pending.render = render
         pending.key = key
+        pending.noScope = noScope
       } else {
-        this.pending = { render, key }
+        this.pending = { render, key, noScope }
       }
       return
     }
@@ -304,8 +305,8 @@ export class DynamicFragment extends VaporFragment {
     const prevSub = setActiveSub()
     const parent = isHydrating ? null : this.anchor.parentNode
     // teardown previous branch
-    if (this.scope) {
-      if (isKeepAliveEnabled) {
+    if (this.current !== undefined) {
+      if (this.scope && isKeepAliveEnabled) {
         let retainScope = false
         const keepAliveCtx = this.keepAliveCtx
 
@@ -326,7 +327,7 @@ export class DynamicFragment extends VaporFragment {
         if (!retainScope) {
           this.scope.stop()
         }
-      } else {
+      } else if (this.scope) {
         this.scope.stop()
       }
       const mode = transition && transition.mode
@@ -349,9 +350,15 @@ export class DynamicFragment extends VaporFragment {
             const pending = this.pending
             if (pending) {
               this.pending = undefined
-              this.renderBranch(pending.render, transition, parent, pending.key)
+              this.renderBranch(
+                pending.render,
+                transition,
+                parent,
+                pending.key,
+                pending.noScope,
+              )
             } else {
-              this.renderBranch(render, transition, parent, key)
+              this.renderBranch(render, transition, parent, key, noScope)
             }
           } finally {
             setCurrentInstance(...prevInstance)
@@ -394,7 +401,7 @@ export class DynamicFragment extends VaporFragment {
       }
     }
 
-    this.renderBranch(render, transition, parent, key)
+    this.renderBranch(render, transition, parent, key, noScope)
     setActiveSub(prevSub)
 
     if (isHydrating && this.anchorLabel !== 'slot' && !reusingDeferredAnchor) {
@@ -407,22 +414,32 @@ export class DynamicFragment extends VaporFragment {
     transition: VaporTransitionHooks | undefined,
     parent: ParentNode | null,
     key: any,
+    noScope: boolean = false,
   ): void {
     this.current = key
     if (render) {
       const keepAliveCtx = isKeepAliveEnabled ? this.keepAliveCtx : null
-      // try to reuse the kept-alive scope
-      const scope = keepAliveCtx && keepAliveCtx.getScope(this.current)
-      if (scope) {
-        this.scope = scope
+      // A compiler-proven static branch can skip its own EffectScope, but attrs
+      // fallthrough still registers branch-owned cleanup.
+      const useScope = !noScope || !!this.attrs
+      if (useScope) {
+        // try to reuse the kept-alive scope
+        const scope = keepAliveCtx && keepAliveCtx.getScope(this.current)
+        if (scope) {
+          this.scope = scope
+        } else {
+          this.scope = new EffectScope()
+        }
       } else {
-        this.scope = new EffectScope()
+        this.scope = undefined
       }
 
       const renderBranch = () => {
         try {
           this.nodes = this.runWithRenderCtx(
-            () => this.scope!.run(render) || [],
+            () =>
+              (useScope ? this.scope!.run(render) : render()) || EMPTY_BLOCK,
+            this.scope,
           )
         } finally {
           // propagate the fragment key onto freshly rendered nodes.
@@ -460,7 +477,7 @@ export class DynamicFragment extends VaporFragment {
         if (this.attrs) {
           if (this.nodes instanceof Element) {
             // ensure render effect is cleaned up when scope is stopped
-            this.scope.run(() => {
+            this.scope!.run(() => {
               renderEffect(() =>
                 applyFallthroughProps(this.nodes as Element, this.attrs!),
               )

+ 15 - 3
packages/shared/src/vaporFlags.ts

@@ -42,11 +42,13 @@ export enum VaporBlockShape {
  * - bits 0-1: true branch VaporBlockShape
  * - bits 2-3: false branch VaporBlockShape
  * - bit 4: v-once
- * - bits 5+: branch index + 1 for keyed dynamic fragments
+ * - bit 5: true branch does not need EffectScope
+ * - bit 6: false branch does not need EffectScope
+ * - bits 7+: branch index + 1 for keyed dynamic fragments
  *
  * Examples:
  * - v-once, true single-root, no false branch: 1 | ONCE = 17
- * - keyed index 0, true/false single-root: 1 | (1 << 2) | (1 << 5) = 37
+ * - keyed index 0, true/false single-root: 1 | (1 << 2) | (1 << 7) = 133
  */
 export enum VaporIfFlags {
   /**
@@ -58,11 +60,21 @@ export enum VaporIfFlags {
    * Marks a branch that is created once and never updated.
    */
   ONCE = 1 << 4,
+  /**
+   * The compiler proved that the true branch does not create branch-owned
+   * effects or disposers.
+   */
+  TRUE_NO_SCOPE = 1 << 5,
+  /**
+   * The compiler proved that the false branch does not create branch-owned
+   * effects or disposers.
+   */
+  FALSE_NO_SCOPE = 1 << 6,
   /**
    * Shift for keyed branch index. The encoded value is index + 1, so decoded
    * zero means "not keyed" and source index 0 still round-trips.
    */
-  INDEX_SHIFT = 5,
+  INDEX_SHIFT = 7,
 }
 
 /**