Explorar o código

perf(vapor): add fast path for static class names (#14803)

Specialize simple class object and empty-string ternary bindings into flag-based setClassName calls, avoiding normalizeClass on the pure Vapor class hot path while preserving static class ordering and falling back for unsafe shapes.

Browser bench shows ~2.6x faster stable single-key ternary updates, ~9-21x faster stable single-key object updates depending on static base classes, ~30-37x faster stable multi-key object updates, and ~5-8x faster sparse multi-key churn.
edison hai 1 mes
pai
achega
1292bc491b

+ 2 - 2
packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap

@@ -615,12 +615,12 @@ export function render(_ctx) {
 `;
 
 exports[`compiler: element transform > props merging: class 1`] = `
-"import { setClass as _setClass, renderEffect as _renderEffect, template as _template } from 'vue';
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>", true)
 
 export function render(_ctx) {
   const n0 = t0()
-  _renderEffect(() => _setClass(n0, ["foo", { bar: _ctx.isBar }]))
+  _renderEffect(() => _setClassName(n0, (_ctx.isBar ? 1 : 0), " bar", "foo"))
   return n0
 }"
 `;

+ 187 - 0
packages/compiler-vapor/__tests__/transforms/__snapshots__/vBind.spec.ts.snap

@@ -330,6 +330,17 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler v-bind > array class falls back to setClass 1`] = `
+"import { setClass as _setClass, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClass(n0, [_ctx.foo, { danger: _ctx.active }]))
+  return n0
+}"
+`;
+
 exports[`compiler v-bind > attributes must be set as attribute 1`] = `
 "import { setAttr as _setAttr, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>")
@@ -381,6 +392,50 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler v-bind > class with v-bind object falls back to dynamic props 1`] = `
+"import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setDynamicProps(n0, [{ class: ["foo", { bar: _ctx.isBar }] }, _ctx.mayBeHasClass]))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > className helper falls back when bit flags are exhausted 1`] = `
+"import { setClass as _setClass, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClass(n0, { c0: _ctx.a0, c1: _ctx.a1, c2: _ctx.a2, c3: _ctx.a3, c4: _ctx.a4, c5: _ctx.a5, c6: _ctx.a6, c7: _ctx.a7, c8: _ctx.a8, c9: _ctx.a9, c10: _ctx.a10, c11: _ctx.a11, c12: _ctx.a12, c13: _ctx.a13, c14: _ctx.a14, c15: _ctx.a15, c16: _ctx.a16, c17: _ctx.a17, c18: _ctx.a18, c19: _ctx.a19, c20: _ctx.a20, c21: _ctx.a21, c22: _ctx.a22, c23: _ctx.a23, c24: _ctx.a24, c25: _ctx.a25, c26: _ctx.a26, c27: _ctx.a27, c28: _ctx.a28, c29: _ctx.a29, c30: _ctx.a30, c31: _ctx.a31 }))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > className helper normalizes static and string class values 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.ok ? 1 : 0), " baz", "foo bar"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > computed object class key falls back to setClass 1`] = `
+"import { setClass as _setClass, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClass(n0, { [_ctx.name]: _ctx.active }))
+  return n0
+}"
+`;
+
 exports[`compiler v-bind > dynamic arg 1`] = `
 "import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>", true)
@@ -410,6 +465,17 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler v-bind > multiple simple object className helper 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.ok ? 1 : 0) | (_ctx.bar ? 2 : 0), [" active", " foo"]))
+  return n0
+}"
+`;
+
 exports[`compiler v-bind > no expression (shorthand) 1`] = `
 "import { setAttr as _setAttr, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>", true)
@@ -442,6 +508,28 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler v-bind > object class with multi-token key 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.isActive ? 1 : 0), "foo bar"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > reverse ternary string className helper 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.selected === _ctx.row.id ? 0 : 1), "danger"))
+  return n0
+}"
+`;
+
 exports[`compiler v-bind > should error if empty expression 1`] = `
 "import { template as _template } from 'vue';
 const t0 = _template("<div arg>", true, true)
@@ -452,6 +540,105 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler v-bind > simple object className helper 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.isActive ? 1 : 0), "active"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > static class after conditional uses className helper with suffix 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.selected === _ctx.row.id ? 1 : 0), "danger", "", "foo"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > static class after multiple object className helper uses suffix 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.ok ? 1 : 0) | (_ctx.bar ? 2 : 0), [" active", " foo"], "", "tail"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > static class in reverse order uses className helper with suffix 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.isBar ? 1 : 0), "bar", "", "foo"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > static class with multiple object className helper 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.selected === _ctx.row.id ? 1 : 0) | (_ctx.active ? 2 : 0), [" danger", " is-active"], "foo"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > static class with overlapping multi-token object class 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.isActive ? 1 : 0), " foo bar", "foo"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > static class with overlapping object class 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.isBar ? 1 : 0), " bar", "bar"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > static class with simple object className helper 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.isBar ? 1 : 0), " bar", "foo"))
+  return n0
+}"
+`;
+
+exports[`compiler v-bind > ternary string className helper 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", true)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setClassName(n0, (_ctx.selected === _ctx.row.id ? 1 : 0), "danger"))
+  return n0
+}"
+`;
+
 exports[`compiler v-bind > v-bind w/ svg elements 1`] = `
 "import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<svg>", true, false, 1)

+ 23 - 6
packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap

@@ -62,6 +62,23 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: v-for > multi className helper with repeated v-for value 1`] = `
+"import { setClassName as _setClassName, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue';
+const t0 = _template("<div>")
+
+export function render(_ctx) {
+  const n0 = _createFor(() => (_ctx.todos), (_for_item0) => {
+    const n2 = t0()
+    _renderEffect(() => {
+      const _todo = _for_item0.value
+      _setClassName(n2, (_todo.completed ? 1 : 0) | (_todo === _ctx.editedTodo ? 2 : 0), [" completed", " editing"])
+    })
+    return n2
+  }, (todo) => (todo.id))
+  return n0
+}"
+`;
+
 exports[`compiler: v-for > multi effect 1`] = `
 "import { setProp as _setProp, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue';
 const t0 = _template("<div>")
@@ -166,7 +183,7 @@ export function render(_ctx) {
 `;
 
 exports[`compiler: v-for > selector pattern 2`] = `
-"import { setClass as _setClass, createFor as _createFor, template as _template } from 'vue';
+"import { setClassName as _setClassName, createFor as _createFor, template as _template } from 'vue';
 const t0 = _template("<tr>")
 
 export function render(_ctx) {
@@ -174,7 +191,7 @@ export function render(_ctx) {
   const n0 = _createFor(() => (_ctx.rows), (_for_item0) => {
     const n2 = t0()
     _selector0_0(() => {
-      _setClass(n2, _ctx.selected === _for_item0.value.id ? 'danger' : '')
+      _setClassName(n2, (_ctx.selected === _for_item0.value.id ? 1 : 0), "danger")
     })
     return n2
   }, (row) => (row.id), undefined, ({ createSelector }) => {
@@ -185,7 +202,7 @@ export function render(_ctx) {
 `;
 
 exports[`compiler: v-for > selector pattern 3`] = `
-"import { setClass as _setClass, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue';
+"import { setClassName as _setClassName, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue';
 const t0 = _template("<tr>")
 
 export function render(_ctx) {
@@ -193,7 +210,7 @@ export function render(_ctx) {
     const n2 = t0()
     _renderEffect(() => {
       const _row = _for_item0.value
-      _setClass(n2, _row.label === _row.id ? 'danger' : '')
+      _setClassName(n2, (_row.label === _row.id ? 1 : 0), "danger")
     })
     return n2
   }, (row) => (row.id))
@@ -202,7 +219,7 @@ export function render(_ctx) {
 `;
 
 exports[`compiler: v-for > selector pattern 4`] = `
-"import { setClass as _setClass, createFor as _createFor, template as _template } from 'vue';
+"import { setClassName as _setClassName, createFor as _createFor, template as _template } from 'vue';
 const t0 = _template("<tr>")
 
 export function render(_ctx) {
@@ -210,7 +227,7 @@ export function render(_ctx) {
   const n0 = _createFor(() => (_ctx.rows), (_for_item0) => {
     const n2 = t0()
     _selector0_0(() => {
-      _setClass(n2, { danger: _for_item0.value.id === _ctx.selected })
+      _setClassName(n2, (_for_item0.value.id === _ctx.selected ? 1 : 0), "danger")
     })
     return n2
   }, (row) => (row.id), undefined, ({ createSelector }) => {

+ 182 - 0
packages/compiler-vapor/__tests__/transforms/vBind.spec.ts

@@ -721,6 +721,188 @@ describe('compiler v-bind', () => {
     expect(code).contains('_setClass(n0, _ctx.cls, true))')
   })
 
+  test('simple object className helper', () => {
+    const { code } = compileWithVBind(`
+      <div :class="{ active: isActive }"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains('_setClassName(n0, (_ctx.isActive ? 1 : 0)')
+    expect(code).contains('"active"')
+    expect(code).not.contains('{ active:')
+  })
+
+  test('ternary string className helper', () => {
+    const { code } = compileWithVBind(`
+      <div :class="selected === row.id ? 'danger' : ''"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      '_setClassName(n0, (_ctx.selected === _ctx.row.id ? 1 : 0), "danger")',
+    )
+  })
+
+  test('reverse ternary string className helper', () => {
+    const { code } = compileWithVBind(`
+      <div :class="selected === row.id ? '' : 'danger'"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      '_setClassName(n0, (_ctx.selected === _ctx.row.id ? 0 : 1), "danger")',
+    )
+  })
+
+  test('static class after conditional uses className helper with suffix', () => {
+    const { code } = compileWithVBind(`
+      <div :class="selected === row.id ? 'danger' : ''" class="foo"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      `_setClassName(n0, (_ctx.selected === _ctx.row.id ? 1 : 0), "danger", "", "foo")`,
+    )
+  })
+
+  test('static class with simple object className helper', () => {
+    const { code } = compileWithVBind(`
+      <div class="foo" :class="{ bar: isBar }"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains('_setClassName(n0, (_ctx.isBar ? 1 : 0)')
+    expect(code).contains('" bar", "foo"')
+    expect(code).not.contains('{ bar:')
+  })
+
+  test('static class in reverse order uses className helper with suffix', () => {
+    const { code } = compileWithVBind(`
+      <div :class="{ bar: isBar }" class="foo"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      '_setClassName(n0, (_ctx.isBar ? 1 : 0), "bar", "", "foo")',
+    )
+  })
+
+  test('static class after multiple object className helper uses suffix', () => {
+    const { code } = compileWithVBind(`
+      <div :class="{ active: ok, foo: bar }" class="tail"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      '_setClassName(n0, (_ctx.ok ? 1 : 0) | (_ctx.bar ? 2 : 0), [" active", " foo"], "", "tail")',
+    )
+  })
+
+  test('multiple simple object className helper', () => {
+    const { code } = compileWithVBind(`
+      <div :class="{ active: ok, foo: bar }"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      '_setClassName(n0, (_ctx.ok ? 1 : 0) | (_ctx.bar ? 2 : 0)',
+    )
+    expect(code).contains('[" active", " foo"]')
+    expect(code).not.contains('{ active:')
+  })
+
+  test('static class with multiple object className helper', () => {
+    const { code } = compileWithVBind(`
+      <div class="foo" :class="{ danger: selected === row.id, 'is-active': active }"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      '_setClassName(n0, (_ctx.selected === _ctx.row.id ? 1 : 0) | (_ctx.active ? 2 : 0), [" danger", " is-active"], "foo")',
+    )
+    expect(code).not.contains('{ danger:')
+  })
+
+  test('object class with multi-token key', () => {
+    const { code } = compileWithVBind(`
+      <div :class="{ 'foo bar': isActive }"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains('_setClassName(n0, (_ctx.isActive ? 1 : 0)')
+    expect(code).contains('"foo bar"')
+    expect(code).not.contains("'foo bar':")
+  })
+
+  test('static class with overlapping object class', () => {
+    const { code } = compileWithVBind(`
+      <div class="bar" :class="{ bar: isBar }"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains('_setClassName(n0, (_ctx.isBar ? 1 : 0)')
+    expect(code).contains('" bar", "bar"')
+    expect(code).not.contains('{ bar:')
+  })
+
+  test('static class with overlapping multi-token object class', () => {
+    const { code } = compileWithVBind(`
+      <div class="foo" :class="{ 'foo bar': isActive }"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains('_setClassName(n0, (_ctx.isActive ? 1 : 0)')
+    expect(code).contains('" foo bar", "foo"')
+    expect(code).not.contains("'foo bar':")
+  })
+
+  test('className helper normalizes static and string class values', () => {
+    const { code } = compileWithVBind(`
+      <div class=" foo  bar " :class="ok ? ' baz ' : ''"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      '_setClassName(n0, (_ctx.ok ? 1 : 0), " baz", "foo bar")',
+    )
+  })
+
+  test('className helper falls back when bit flags are exhausted', () => {
+    const entries = Array.from({ length: 32 }, (_, i) => `c${i}: a${i}`).join(
+      ', ',
+    )
+    const { code } = compileWithVBind(`<div :class="{ ${entries} }"/>`)
+    expect(code).matchSnapshot()
+    expect(code).contains('_setClass(n0, {')
+    expect(code).not.contains('_setClassName')
+  })
+
+  test('className helper supports the max safe bit flag', () => {
+    const entries = Array.from({ length: 31 }, (_, i) => `c${i}: a${i}`).join(
+      ', ',
+    )
+    const { code } = compileWithVBind(`<div :class="{ ${entries} }"/>`)
+    expect(code).contains('_setClassName')
+    expect(code).contains('(_ctx.a30 ? 1073741824 : 0)')
+    expect(code).not.contains('_setClass(n0, {')
+  })
+
+  test('computed object class key falls back to setClass', () => {
+    const { code } = compileWithVBind(`
+      <div :class="{ [name]: active }"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains('_setClass(n0, { [_ctx.name]: _ctx.active })')
+    expect(code).not.contains('_setClassName')
+  })
+
+  test('array class falls back to setClass', () => {
+    const { code } = compileWithVBind(`
+      <div :class="[foo, { danger: active }]"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains('_setClass(n0, [_ctx.foo, { danger: _ctx.active }])')
+    expect(code).not.contains('_setClassName')
+  })
+
+  test('class with v-bind object falls back to dynamic props', () => {
+    const { code } = compileWithVBind(`
+      <div class="foo" :class="{ bar: isBar }" v-bind="mayBeHasClass"/>
+    `)
+    expect(code).matchSnapshot()
+    expect(code).contains(
+      '_setDynamicProps(n0, [{ class: ["foo", { bar: _ctx.isBar }] }, _ctx.mayBeHasClass])',
+    )
+    expect(code).not.contains('_setClassName')
+  })
+
   test(':style w/ svg elements', () => {
     const { code } = compileWithVBind(`
       <svg :style="style"/>

+ 11 - 0
packages/compiler-vapor/__tests__/transforms/vFor.spec.ts

@@ -141,6 +141,17 @@ describe('compiler: v-for', () => {
     expect(code).matchSnapshot()
   })
 
+  test('multi className helper with repeated v-for value', () => {
+    const { code } = compileWithVFor(
+      `<div v-for="todo of todos" :key="todo.id" :class="{ completed: todo.completed, editing: todo === editedTodo }" />`,
+    )
+    expect(code).matchSnapshot()
+    expect(code).contains(`const _todo = _for_item0.value`)
+    expect(code).contains(
+      `_setClassName(n2, (_todo.completed ? 1 : 0) | (_todo === _ctx.editedTodo ? 2 : 0), [" completed", " editing"])`,
+    )
+  })
+
   test('w/o value', () => {
     const { code } = compileWithVFor(`<div v-for=" of items">item</div>`)
     expect(code).matchSnapshot()

+ 265 - 0
packages/compiler-vapor/src/generators/prop.ts

@@ -1,6 +1,8 @@
 import {
   NewlineType,
   type SimpleExpressionNode,
+  advancePositionWithClone,
+  createSimpleExpression,
   isSimpleIdentifier,
 } from '@vue/compiler-dom'
 import type { CodegenContext } from '../generate'
@@ -26,9 +28,18 @@ import {
   capitalize,
   extend,
   isSVGTag,
+  normalizeClass,
   shouldSetAsAttr,
   toHandlerKey,
 } from '@vue/shared'
+import { getLiteralExpressionValue } from '../utils'
+import { type ParserOptions, parseExpression } from '@babel/parser'
+import type {
+  ConditionalExpression,
+  Expression,
+  ObjectExpression,
+  ObjectProperty,
+} from '@babel/types'
 
 export type HelperConfig = {
   name: VaporHelper
@@ -42,6 +53,7 @@ const helpers = {
   setText: { name: 'setText' },
   setHtml: { name: 'setHtml' },
   setClass: { name: 'setClass' },
+  setClassName: { name: 'setClassName' },
   setStyle: { name: 'setStyle' },
   setValue: { name: 'setValue' },
   setAttr: { name: 'setAttr', needKey: true },
@@ -60,6 +72,14 @@ export function genSetProp(
     tag,
   } = oper
   const resolvedHelper = getRuntimeHelper(tag, key.content, modifier)
+  if (
+    key.content === 'class' &&
+    !resolvedHelper.isSVG &&
+    resolvedHelper.name === 'setClass'
+  ) {
+    const className = genSetClassName(oper, context)
+    if (className) return className
+  }
   const propValue = genPropValue(values, context)
   return [
     NEWLINE,
@@ -73,6 +93,251 @@ export function genSetProp(
   ]
 }
 
+interface ClassNameEntry {
+  className: string
+  condition?: SimpleExpressionNode
+  negate?: boolean
+  value?: boolean
+}
+
+interface ClassNameInfo {
+  prefix: string
+  suffix: string
+  entries: ClassNameEntry[]
+}
+
+// Runtime uses signed bitwise shifts when iterating fragments, so 31 entries
+// is the largest safe flag set (1 << 30).
+const MAX_CLASS_NAME_ENTRIES = 31
+
+function genSetClassName(
+  oper: SetPropIRNode,
+  context: CodegenContext,
+): CodeFragment[] | undefined {
+  const info = resolveClassName(oper.prop.values, context)
+  if (!info) return
+
+  const { helper } = context
+  const flags = genClassFlags(info.entries, context)
+  const classFragments = info.entries.map(entry =>
+    JSON.stringify(
+      !info.prefix && info.entries.length === 1
+        ? entry.className
+        : ` ${entry.className}`,
+    ),
+  )
+  const fragments =
+    classFragments.length === 1
+      ? classFragments[0]
+      : genMulti(DELIMITERS_ARRAY, ...classFragments)
+
+  return [
+    NEWLINE,
+    ...genCall(
+      // Use an empty prefix placeholder so suffix can be emitted alone.
+      [helper('setClassName'), '""'],
+      `n${oper.element}`,
+      flags,
+      fragments,
+      info.prefix && JSON.stringify(info.prefix),
+      info.suffix && JSON.stringify(info.suffix),
+    ),
+  ]
+}
+
+function resolveClassName(
+  values: SimpleExpressionNode[],
+  context: CodegenContext,
+): ClassNameInfo | undefined {
+  let prefix = ''
+  let suffix = ''
+  const entries: ClassNameEntry[] = []
+  let sawDynamic = false
+  let sawSuffix = false
+
+  for (const value of values) {
+    const staticValue = getLiteralExpressionValue(value, true)
+    if (staticValue != null) {
+      const normalized = normalizeClass(staticValue)
+      if (normalized) {
+        if (sawSuffix) {
+          suffix = appendClass(suffix, normalized)
+        } else if (sawDynamic) {
+          sawSuffix = true
+          suffix = appendClass(suffix, normalized)
+        } else {
+          prefix = appendClass(prefix, normalized)
+        }
+      }
+      continue
+    }
+
+    const ast = value.ast
+    if (!ast || sawSuffix) return
+    sawDynamic = true
+
+    if (ast.type === 'ObjectExpression') {
+      if (!resolveObjectClassName(value, ast, entries, context)) return
+    } else if (ast.type === 'ConditionalExpression') {
+      if (!resolveConditionalClassName(value, ast, entries, context)) return
+    } else {
+      return
+    }
+  }
+
+  return entries.length && entries.length <= MAX_CLASS_NAME_ENTRIES
+    ? { prefix, suffix, entries }
+    : undefined
+}
+
+function resolveObjectClassName(
+  source: SimpleExpressionNode,
+  ast: ObjectExpression,
+  entries: ClassNameEntry[],
+  context: CodegenContext,
+): boolean {
+  for (const prop of ast.properties) {
+    if (prop.type !== 'ObjectProperty' || prop.computed) {
+      return false
+    }
+
+    const rawClassName = getObjectPropertyName(prop)
+    if (rawClassName == null) return false
+
+    const className = normalizeClass(rawClassName)
+    // Empty normalized keys contribute no class and no flag bit.
+    if (!className) continue
+
+    const value = getBooleanValue(prop.value)
+    entries.push({
+      className,
+      value,
+      condition:
+        value == null
+          ? createSubExpression(source, prop.value as Expression, context)
+          : undefined,
+    })
+  }
+  return true
+}
+
+function resolveConditionalClassName(
+  source: SimpleExpressionNode,
+  ast: ConditionalExpression,
+  entries: ClassNameEntry[],
+  context: CodegenContext,
+): boolean {
+  const consequent = getStringClassValue(ast.consequent)
+  const alternate = getStringClassValue(ast.alternate)
+
+  if (consequent && alternate === '') {
+    entries.push({
+      className: consequent,
+      condition: createSubExpression(source, ast.test, context),
+    })
+    return true
+  } else if (alternate && consequent === '') {
+    entries.push({
+      className: alternate,
+      condition: createSubExpression(source, ast.test, context),
+      negate: true,
+    })
+    return true
+  }
+
+  return false
+}
+
+function genClassFlags(
+  entries: ClassNameEntry[],
+  context: CodegenContext,
+): CodeFragment[] {
+  const values: CodeFragment[] = []
+
+  entries.forEach((entry, index) => {
+    if (index) values.push(' | ')
+
+    const bit = 1 << index
+    if (entry.value != null) {
+      values.push(entry.value ? String(bit) : '0')
+      return
+    }
+
+    values.push(
+      '(',
+      ...genExpression(entry.condition!, context),
+      entry.negate ? ` ? 0 : ${bit}` : ` ? ${bit} : 0`,
+      ')',
+    )
+  })
+
+  return values
+}
+
+function appendClass(base: string, value: string): string {
+  return base ? (value ? `${base} ${value}` : base) : value
+}
+
+function getObjectPropertyName(prop: ObjectProperty): string | undefined {
+  const key = prop.key
+  if (key.type === 'Identifier') {
+    return key.name
+  } else if (key.type === 'StringLiteral') {
+    return key.value
+  } else if (key.type === 'NumericLiteral') {
+    return String(key.value)
+  }
+}
+
+function getStringClassValue(node: Expression): string | undefined {
+  if (node.type === 'StringLiteral') {
+    return normalizeClass(node.value)
+  } else if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
+    return normalizeClass(node.quasis[0].value.cooked || '')
+  } else if (
+    node.type === 'NullLiteral' ||
+    (node.type === 'BooleanLiteral' && !node.value)
+  ) {
+    return ''
+  }
+}
+
+function getBooleanValue(node: ObjectProperty['value']): boolean | undefined {
+  if (node.type === 'BooleanLiteral') {
+    return node.value
+  }
+}
+
+function createSubExpression(
+  source: SimpleExpressionNode,
+  node: Expression,
+  context: CodegenContext,
+): SimpleExpressionNode {
+  const start = node.start == null ? 0 : node.start - 1
+  const end = node.end == null ? source.content.length : node.end - 1
+  const content = source.content.slice(start, end)
+  const expression = createSimpleExpression(content, false, {
+    start: advancePositionWithClone(source.loc.start, source.content, start),
+    end: advancePositionWithClone(source.loc.start, source.content, end),
+    source: content,
+  })
+  expression.ast = isSimpleIdentifier(content)
+    ? null
+    : parseExpression(`(${content})`, getParserOptions(context))
+  return expression
+}
+
+function getParserOptions(context: CodegenContext): ParserOptions {
+  const plugins = context.options.expressionPlugins
+  return {
+    plugins: plugins
+      ? plugins.some(plugin => plugin === 'typescript')
+        ? plugins
+        : [...plugins, 'typescript']
+      : ['typescript'],
+  }
+}
+
 // dynamic key props and v-bind="{}" will reach here
 export function genDynamicProps(
   oper: SetDynamicPropsIRNode,

+ 449 - 0
packages/runtime-vapor/__tests__/bench/setClass.bench.ts

@@ -0,0 +1,449 @@
+import { bench, describe } from 'vitest'
+import { setClass, setClassName } from '../../src/dom/prop'
+
+type TargetElement = HTMLElement & {
+  $root?: true
+  $cls?: string
+  $clsFlags?: number
+}
+
+const BATCH = 100
+
+const stable2 = [3, 3, 3, 3]
+const stable4 = [15, 15, 15, 15]
+const stable8 = [255, 255, 255, 255]
+
+const toggle = (i: number, all: number): number => (i & 1 ? all : 0)
+
+const sparse = (i: number, all: number): number => {
+  const phase = i & 7
+  return phase < 6 ? all : 0
+}
+
+function createEl(): TargetElement {
+  const el = document.createElement('div') as TargetElement
+  el.className = 'base'
+  return el
+}
+
+function createEmptyEl(): TargetElement {
+  return document.createElement('div') as TargetElement
+}
+
+function createRootEl(): TargetElement {
+  const el = document.createElement('div') as TargetElement
+  el.$root = true
+  el.className = 'fallthrough'
+  return el
+}
+
+function currentSetClassTernary1(el: TargetElement, state: number): void {
+  setClass(el, state ? 'danger' : '')
+}
+
+function currentSetClassObject1(el: TargetElement, state: number): void {
+  setClass(el, { danger: state })
+}
+
+function currentSetClassObject1WithBase(
+  el: TargetElement,
+  state: number,
+): void {
+  setClass(el, ['base', { danger: state }])
+}
+
+// Mirrors generated output for a single dynamic class fragment. The compiler
+// passes a string here so stable updates allocate no fragment array.
+function currentSetClassName1(el: TargetElement, state: number): void {
+  setClassName(el, state ? 1 : 0, 'danger')
+}
+
+// Static base classes are folded into the prefix argument. The dynamic fragment
+// keeps its leading space so the runtime can concatenate without joining arrays.
+function currentSetClassName1WithBase(el: TargetElement, state: number): void {
+  setClassName(el, state ? 1 : 0, ' danger', 'base')
+}
+
+function currentSetClass2(el: TargetElement, state: number): void {
+  setClass(el, [
+    'base',
+    {
+      c0: state & 1,
+      c1: state & 2,
+    },
+  ])
+}
+
+function currentSetClass4(el: TargetElement, state: number): void {
+  setClass(el, [
+    'base',
+    {
+      c0: state & 1,
+      c1: state & 2,
+      c2: state & 4,
+      c3: state & 8,
+    },
+  ])
+}
+
+function currentSetClass8(el: TargetElement, state: number): void {
+  setClass(el, [
+    'base',
+    {
+      c0: state & 1,
+      c1: state & 2,
+      c2: state & 4,
+      c3: state & 8,
+      c4: state & 16,
+      c5: state & 32,
+      c6: state & 64,
+      c7: state & 128,
+    },
+  ])
+}
+
+function currentSetClassName2(el: TargetElement, state: number): void {
+  setClassName(
+    el,
+    (state & 1 ? 1 : 0) | (state & 2 ? 2 : 0),
+    [' c0', ' c1'],
+    'base',
+  )
+}
+
+function currentSetClassName4(el: TargetElement, state: number): void {
+  setClassName(
+    el,
+    (state & 1 ? 1 : 0) |
+      (state & 2 ? 2 : 0) |
+      (state & 4 ? 4 : 0) |
+      (state & 8 ? 8 : 0),
+    [' c0', ' c1', ' c2', ' c3'],
+    'base',
+  )
+}
+
+function currentSetClassName8(el: TargetElement, state: number): void {
+  setClassName(
+    el,
+    (state & 1 ? 1 : 0) |
+      (state & 2 ? 2 : 0) |
+      (state & 4 ? 4 : 0) |
+      (state & 8 ? 8 : 0) |
+      (state & 16 ? 16 : 0) |
+      (state & 32 ? 32 : 0) |
+      (state & 64 ? 64 : 0) |
+      (state & 128 ? 128 : 0),
+    [' c0', ' c1', ' c2', ' c3', ' c4', ' c5', ' c6', ' c7'],
+    'base',
+  )
+}
+
+describe('setClass', () => {
+  describe('1 key without base', () => {
+    {
+      const el = createEmptyEl()
+      let i = 0
+      bench('setClass ternary stable', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassTernary1(el, stable2[i++ & 3] & 1)
+        }
+      })
+    }
+
+    {
+      const el = createEmptyEl()
+      let i = 0
+      bench('setClass object stable', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassObject1(el, stable2[i++ & 3] & 1)
+        }
+      })
+    }
+
+    {
+      const el = createEmptyEl()
+      let i = 0
+      bench('setClassName stable', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassName1(el, stable2[i++ & 3] & 1)
+        }
+      })
+    }
+
+    {
+      const el = createEmptyEl()
+      let i = 0
+      bench('setClass ternary toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassTernary1(el, toggle(i++, 1))
+        }
+      })
+    }
+
+    {
+      const el = createEmptyEl()
+      let i = 0
+      bench('setClass object toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassObject1(el, toggle(i++, 1))
+        }
+      })
+    }
+
+    {
+      const el = createEmptyEl()
+      let i = 0
+      bench('setClassName toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassName1(el, toggle(i++, 1))
+        }
+      })
+    }
+  })
+
+  describe('1 key with base', () => {
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass object stable', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassObject1WithBase(el, stable2[i++ & 3] & 1)
+        }
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName stable', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassName1WithBase(el, stable2[i++ & 3] & 1)
+        }
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass object toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassObject1WithBase(el, toggle(i++, 1))
+        }
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassName1WithBase(el, toggle(i++, 1))
+        }
+      })
+    }
+  })
+
+  describe('1 key root without base', () => {
+    {
+      const el = createRootEl()
+      let i = 0
+      bench('setClass ternary stable', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassTernary1(el, stable2[i++ & 3] & 1)
+        }
+      })
+    }
+
+    {
+      const el = createRootEl()
+      let i = 0
+      bench('setClass object stable', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassObject1(el, stable2[i++ & 3] & 1)
+        }
+      })
+    }
+
+    {
+      const el = createRootEl()
+      let i = 0
+      bench('setClassName stable', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassName1(el, stable2[i++ & 3] & 1)
+        }
+      })
+    }
+
+    {
+      const el = createRootEl()
+      let i = 0
+      bench('setClass ternary toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassTernary1(el, toggle(i++, 1))
+        }
+      })
+    }
+
+    {
+      const el = createRootEl()
+      let i = 0
+      bench('setClass object toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassObject1(el, toggle(i++, 1))
+        }
+      })
+    }
+
+    {
+      const el = createRootEl()
+      let i = 0
+      bench('setClassName toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) {
+          currentSetClassName1(el, toggle(i++, 1))
+        }
+      })
+    }
+  })
+
+  describe('2 keys', () => {
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass stable', () => {
+        for (let j = 0; j < BATCH; j++) currentSetClass2(el, stable2[i++ & 3])
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName stable', () => {
+        for (let j = 0; j < BATCH; j++)
+          currentSetClassName2(el, stable2[i++ & 3])
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) currentSetClass2(el, toggle(i++, 3))
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) currentSetClassName2(el, toggle(i++, 3))
+      })
+    }
+  })
+
+  describe('4 keys', () => {
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass stable', () => {
+        for (let j = 0; j < BATCH; j++) currentSetClass4(el, stable4[i++ & 3])
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName stable', () => {
+        for (let j = 0; j < BATCH; j++)
+          currentSetClassName4(el, stable4[i++ & 3])
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) currentSetClass4(el, toggle(i++, 15))
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName toggles every update', () => {
+        for (let j = 0; j < BATCH; j++)
+          currentSetClassName4(el, toggle(i++, 15))
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass sparse churn', () => {
+        for (let j = 0; j < BATCH; j++) currentSetClass4(el, sparse(i++, 15))
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName sparse churn', () => {
+        for (let j = 0; j < BATCH; j++)
+          currentSetClassName4(el, sparse(i++, 15))
+      })
+    }
+  })
+
+  describe('8 keys', () => {
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass stable', () => {
+        for (let j = 0; j < BATCH; j++) currentSetClass8(el, stable8[i++ & 3])
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName stable', () => {
+        for (let j = 0; j < BATCH; j++)
+          currentSetClassName8(el, stable8[i++ & 3])
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass toggles every update', () => {
+        for (let j = 0; j < BATCH; j++) currentSetClass8(el, toggle(i++, 255))
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName toggles every update', () => {
+        for (let j = 0; j < BATCH; j++)
+          currentSetClassName8(el, toggle(i++, 255))
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClass sparse churn', () => {
+        for (let j = 0; j < BATCH; j++) currentSetClass8(el, sparse(i++, 255))
+      })
+    }
+
+    {
+      const el = createEl()
+      let i = 0
+      bench('setClassName sparse churn', () => {
+        for (let j = 0; j < BATCH; j++)
+          currentSetClassName8(el, sparse(i++, 255))
+      })
+    }
+  })
+})

+ 75 - 0
packages/runtime-vapor/__tests__/dom/prop.spec.ts

@@ -5,6 +5,7 @@ import {
   setBlockHtml,
   setBlockText,
   setClass,
+  setClassName,
   setDynamicProps,
   setElementText,
   setHtml,
@@ -51,6 +52,80 @@ describe('patchProp', () => {
       setClass(el, { a: true, b: false })
       expect(el.className).toBe('a')
     })
+
+    test('should set class with flags', () => {
+      const el = document.createElement('div')
+
+      setClassName(el, 1, ['danger'])
+      expect(el.className).toBe('danger')
+
+      setClassName(el, 0, ['danger'])
+      expect(el.className).toBe('')
+
+      const string = document.createElement('div')
+      setClassName(string, 1, 'danger')
+      expect(string.className).toBe('danger')
+
+      setClassName(el, 1, [' danger'])
+      expect(el.className).toBe('danger')
+
+      const multi = document.createElement('div')
+      setClassName(multi, 3, [' danger', ' active'])
+      expect(multi.className).toBe('danger active')
+
+      setClassName(el, 3, [' danger', ' active'], 'base')
+      expect(el.className).toBe('base danger active')
+
+      const stringWithBase = document.createElement('div')
+      setClassName(stringWithBase, 1, ' danger', 'base')
+      expect(stringWithBase.className).toBe('base danger')
+
+      setClassName(el, 1, ['danger'], '', 'tail')
+      expect(el.className).toBe('danger tail')
+
+      setClassName(el, 0, ['danger'], '', 'tail')
+      expect(el.className).toBe('tail')
+    })
+
+    test('should refresh after generic class writes', () => {
+      const el = document.createElement('div')
+      setClassName(el, 1, ['danger'])
+      expect(el.className).toBe('danger')
+
+      setClass(el, 'fallthrough')
+      expect(el.className).toBe('fallthrough')
+
+      setClassName(el, 1, ['danger'])
+      expect(el.className).toBe('danger')
+    })
+
+    test('should support the max className flag bit', () => {
+      const el = document.createElement('div')
+      const classes = Array.from({ length: 31 }, (_, i) => ` c${i}`)
+
+      setClassName(el, 0x7fffffff, classes, 'base')
+      expect(el.className).toBe(
+        `base ${Array.from({ length: 31 }, (_, i) => `c${i}`).join(' ')}`,
+      )
+    })
+
+    test('should set root class with flags incrementally', () => {
+      const el = document.createElement('div')
+      el.className = 'fallthrough'
+      ;(el as any).$root = true
+
+      setClassName(el, 1, [' danger'], 'base')
+      expect(el.className).toBe('fallthrough base danger')
+
+      setClassName(el, 0, [' danger'], 'base')
+      expect(el.className).toBe('fallthrough base')
+
+      setClassName(el, 1, ['danger'], '', 'tail')
+      expect(el.className).toBe('fallthrough danger tail')
+
+      setClassName(el, 0, ['danger'], '', 'tail')
+      expect(el.className).toBe('fallthrough tail')
+    })
   })
 
   describe('setStyle', () => {

+ 53 - 4
packages/runtime-vapor/src/dom/prop.ts

@@ -50,6 +50,7 @@ type TargetElement = Element & {
   $root?: true
   $html?: string
   $cls?: string
+  $clsFlags?: number
   $sty?: NormalizedStyle | string | undefined
   value?: string
   _value?: any
@@ -178,11 +179,13 @@ export function setClass(
   el: TargetElement,
   value: any,
   isSVG: boolean = false,
+  isNormalized: boolean = false,
 ): void {
+  if (el.$clsFlags !== undefined) el.$clsFlags = undefined
   if (el.$root) {
-    setClassIncremental(el, value)
+    setClassIncremental(el, value, isNormalized)
   } else {
-    value = normalizeClass(value)
+    if (!isNormalized) value = normalizeClass(value)
     if (
       (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
       isHydrating &&
@@ -202,9 +205,54 @@ export function setClass(
   }
 }
 
-function setClassIncremental(el: any, value: any): void {
+export function setClassName(
+  el: TargetElement,
+  flags: number,
+  cls: string | string[],
+  prefix: string = '',
+  suffix: string = '',
+): void {
+  // The compiler passes static fragments/prefix/suffix, so flags uniquely
+  // identify the rendered class string for this element. Generic setClass()
+  // calls clear this cache before writing class through the slower path.
+  if (flags === el.$clsFlags) return
+
+  let value = prefix
+  if (isString(cls)) {
+    if (flags & 1) value += cls
+  } else {
+    // The compiler caps this at 31 entries because JS bitwise shifts are signed.
+    for (let i = 0, bit = 1; i < cls.length; i++, bit <<= 1) {
+      if (flags & bit) value += cls[i]
+    }
+  }
+  if (!prefix && value.charCodeAt(0) === 32) {
+    value = value.slice(1)
+  }
+  if (suffix) {
+    value = value ? `${value} ${suffix}` : suffix
+  }
+
+  if (
+    el.$root ||
+    ((__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && isHydrating)
+  ) {
+    // Root fallthrough and hydration still need the existing setClass;
+    // pass the rebuilt string as normalized to avoid doing that work twice.
+    setClass(el, value, false, true)
+  } else {
+    el.className = el.$cls = value
+  }
+  el.$clsFlags = flags
+}
+
+function setClassIncremental(
+  el: any,
+  value: any,
+  isNormalized: boolean = false,
+): void {
   const cacheKey = `$clsi${isApplyingFallthroughProps ? '$' : ''}`
-  const normalizedValue = normalizeClass(value)
+  const normalizedValue = isNormalized ? value : normalizeClass(value)
 
   if (
     (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
@@ -567,6 +615,7 @@ export function optimizePropertyLookup(): void {
   proto.$key = undefined
   proto.$fc = proto.$evtclick = undefined
   proto.$root = false
+  proto.$clsFlags = undefined
   proto.$html = proto.$cls = proto.$sty = ''
   // Initialize $txt to undefined instead of empty string to ensure setText()
   // properly updates the text node even when the value is empty string.

+ 1 - 0
packages/runtime-vapor/src/index.ts

@@ -39,6 +39,7 @@ export {
   setHtml,
   setBlockHtml,
   setClass,
+  setClassName,
   setStyle,
   setAttr,
   setValue,

+ 23 - 0
vite.config.ts

@@ -88,6 +88,29 @@ export default defineConfig({
           exclude: [...configDefaults.exclude, '**/e2e/**'],
         },
       },
+      {
+        extends: true,
+        test: {
+          name: 'bench-browser',
+          include: [],
+          browser: {
+            enabled: true,
+            provider: playwright({
+              launchOptions: {
+                args: process.env.CI
+                  ? ['--no-sandbox', '--disable-setuid-sandbox']
+                  : [],
+              },
+            }),
+            headless: true,
+            screenshotFailures: false,
+            instances: [{ browser: 'chromium' }],
+          },
+          benchmark: {
+            include: ['packages/runtime-vapor/__tests__/bench/*.bench.ts'],
+          },
+        },
+      },
       {
         extends: true,
         test: {