Browse Source

perf(compiler-vapor): expand object literal v-bind and v-on (#14884)

edison 3 weeks ago
parent
commit
5ed3716fb4

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

@@ -115,12 +115,12 @@ export function render(_ctx) {
 `;
 
 exports[`compiler: expression > cache expressions > not cache variable in function expression 1`] = `
-"import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
+"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>", 1)
 
 export function render(_ctx) {
   const n0 = t0()
-  _renderEffect(() => _setDynamicProps(n0, [{ foo: bar => _ctx.foo = bar }]))
+  _renderEffect(() => _setProp(n0, "foo", bar => _ctx.foo = bar))
   return n0
 }"
 `;

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

@@ -156,6 +156,107 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: element transform > component > object literal v-bind joins existing static component props 1`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    id: "x",
+    foo: () => (_ctx.bar)
+  }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > object literal v-bind preserves dynamic source merge order 1`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => ({ [_ctx.name]: _ctx.value }),
+    { foo: () => (_ctx.bar) }
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > object literal v-bind preserves dynamic source merge order 2`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    foo: () => (_ctx.bar),
+    $: [
+      () => ({ [_ctx.name]: _ctx.value })
+    ]
+  }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > object literal v-bind props are expanded 1`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    foo: () => (_ctx.bar),
+    baz: 1,
+    id: "x",
+    formatter: () => (v => v + 1)
+  }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > object literal v-on conflicts with existing component handlers stay dynamic 1`] = `
+"import { toHandlers as _toHandlers, createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => (_toHandlers({ click: _ctx.a })),
+    { onClick: () => _ctx.b }
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > object literal v-on conflicts with existing component handlers stay dynamic 2`] = `
+"import { toHandlers as _toHandlers, createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => (_toHandlers({ click: _ctx.a })),
+    { onClick: () => (_ctx.b) }
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > object literal v-on handlers are expanded 1`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    onClick: () => (_ctx.onClick),
+    onInput: () => (_ctx.onInput),
+    "onFoo-bar": () => (_ctx.onFooBar)
+  }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > object literal v-on joins existing static component props 1`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    id: "x",
+    onClick: () => (_ctx.onClick)
+  }, null, true)
+  return n0
+}"
+`;
+
 exports[`compiler: element transform > component > props merging: class 1`] = `
 "import { createAssetComponent as _createAssetComponent } from 'vue';
 
@@ -310,6 +411,164 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 1`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => ({ [_ctx.foo]: 1, ..._ctx.obj })
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 2`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => ({ __proto__: _ctx.foo })
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 3`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => ({ key: _ctx.foo })
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 4`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => ({ foo: _ctx.a, foo: _ctx.b })
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 5`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => ({ foo() {} })
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 6`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    foo: "x",
+    $: [
+      () => ({ foo: _ctx.bar })
+    ]
+  }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 7`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    foo: () => (_ctx.a),
+    $: [
+      () => ({ foo: _ctx.b })
+    ]
+  }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 8`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    "foo-bar": "x",
+    $: [
+      () => ({ fooBar: _ctx.bar })
+    ]
+  }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 9`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    onVnodeMounted: () => _ctx.a,
+    $: [
+      () => ({ onVnodeMounted: _ctx.b })
+    ]
+  }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-bind shapes stay as dynamic sources 10`] = `
+"import { withModifiers as _withModifiers, createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", {
+    onContextmenu: () => _withModifiers(_ctx.a, ["right"]),
+    $: [
+      () => ({ onContextmenu: _ctx.b })
+    ]
+  }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-on shapes stay as dynamic sources 1`] = `
+"import { toHandlers as _toHandlers, createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => (_toHandlers({ [_ctx.event]: _ctx.onClick, ..._ctx.listeners }))
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-on shapes stay as dynamic sources 2`] = `
+"import { toHandlers as _toHandlers, createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => (_toHandlers({ __proto__: _ctx.onClick }))
+  ] }, null, true)
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > component > unsupported object literal v-on shapes stay as dynamic sources 3`] = `
+"import { toHandlers as _toHandlers, createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Foo", { $: [
+    () => (_toHandlers({ click: _ctx.onClick, Click: _ctx.onClick2 }))
+  ] }, null, true)
+  return n0
+}"
+`;
+
 exports[`compiler: element transform > component > v-bind="obj" 1`] = `
 "import { createAssetComponent as _createAssetComponent } from 'vue';
 
@@ -499,6 +758,16 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: element transform > constant object literal v-bind props are lowered to template attrs 1`] = `
+"import { template as _template } from 'vue';
+const t0 = _template("<div id=foo disabled>", 3)
+
+export function render(_ctx) {
+  const n0 = t0()
+  return n0
+}"
+`;
+
 exports[`compiler: element transform > custom element 1`] = `
 "import { createPlainElement as _createPlainElement } from 'vue';
 
@@ -639,6 +908,39 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: element transform > object literal v-bind before dynamic key tracks the original source 1`] = `
+"import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", 1)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setDynamicProps(n0, [{ id: _ctx.foo }, { [_ctx.name]: _ctx.bar }]))
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > object literal v-bind prop is lowered to setProp 1`] = `
+"import { setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", 1)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setProp(n0, "id", _ctx.foo))
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > order-sensitive object literal v-bind props stay dynamic 1`] = `
+"import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", 1)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setDynamicProps(n0, [{ id: "foo" }, { id: _ctx.bar }]))
+  return n0
+}"
+`;
+
 exports[`compiler: element transform > plain template element 1`] = `
 "import { createPlainElement as _createPlainElement, txt as _txt, insert as _insert, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div> ")
@@ -874,6 +1176,39 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: element transform > unsafe native object literal v-bind props stay dynamic 1`] = `
+"import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div>", 1)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setDynamicProps(n0, [{ onClick: _ctx.click }]))
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > unsafe native object literal v-bind props stay dynamic 2`] = `
+"import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<input>", 1)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _renderEffect(() => _setDynamicProps(n0, [{ '.value': _ctx.value }]))
+  return n0
+}"
+`;
+
+exports[`compiler: element transform > unsafe native object literal v-bind props stay dynamic 3`] = `
+"import { setDynamicProps as _setDynamicProps, template as _template } from 'vue';
+const t0 = _template("<div>", 1)
+
+export function render(_ctx) {
+  const n0 = t0()
+  _setDynamicProps(n0, [{ 'foo bar': 'x' }])
+  return n0
+}"
+`;
+
 exports[`compiler: element transform > v-bind="obj" 1`] = `
 "import { setDynamicProps as _setDynamicProps, renderEffect as _renderEffect, template as _template } from 'vue';
 const t0 = _template("<div>", 1)

+ 30 - 0
packages/compiler-vapor/__tests__/transforms/__snapshots__/vModel.spec.ts.snap

@@ -13,6 +13,21 @@ exports[`compiler: vModel transform > component > component v-model should merge
 "
 `;
 
+exports[`compiler: vModel transform > component > object literal v-bind after v-model stays dynamic to preserve merge order 1`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Comp", {
+    foo: () => (_ctx.foo),
+    "onUpdate:foo": () => _value => (_ctx.foo = _value),
+    $: [
+      () => ({ foo: _ctx.bar })
+    ]
+  }, null, true)
+  return n0
+}"
+`;
+
 exports[`compiler: vModel transform > component > v-model after dynamic bind keeps model getters 1`] = `
 "import { createAssetComponent as _createAssetComponent } from 'vue';
 
@@ -29,6 +44,21 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: vModel transform > component > v-model after object literal v-bind keeps model generated props 1`] = `
+"import { createAssetComponent as _createAssetComponent } from 'vue';
+
+export function render(_ctx) {
+  const n0 = _createAssetComponent("Comp", { $: [
+    () => ({ foo: _ctx.bar }),
+    {
+      foo: () => (_ctx.foo),
+      "onUpdate:foo": () => _value => (_ctx.foo = _value)
+    }
+  ] }, null, true)
+  return n0
+}"
+`;
+
 exports[`compiler: vModel transform > component > v-model for component should generate modelModifiers 1`] = `
 "import { createAssetComponent as _createAssetComponent } from 'vue';
 

+ 282 - 0
packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts

@@ -394,6 +394,121 @@ describe('compiler: element transform', () => {
       expect(code).contains(`arrLabel: () =>`)
     })
 
+    test('object literal v-bind props are expanded', () => {
+      const { code } = compileWithElementTransform(
+        `<Foo v-bind="{ foo: bar, baz: 1, id: 'x', formatter: v => v + 1 }" />`,
+      )
+
+      expect(code).toMatchSnapshot()
+      expect(code).contains(`foo: () => (_ctx.bar)`)
+      expect(code).contains(`baz: 1`)
+      expect(code).contains(`id: "x"`)
+      expect(code).contains(`formatter: () => (v => v + 1)`)
+      expect(code).not.contains(`$: [`)
+    })
+
+    test('object literal v-bind preserves dynamic source merge order', () => {
+      const { code } = compileWithElementTransform(
+        `<Foo :[name]="value" v-bind="{ foo: bar }" />`,
+      )
+      const { code: beforeCode } = compileWithElementTransform(
+        `<Foo v-bind="{ foo: bar }" :[name]="value" />`,
+      )
+
+      expect(code).toMatchSnapshot()
+      expect(code).contains(`$: [
+    () => ({ [_ctx.name]: _ctx.value }),
+    { foo: () => (_ctx.bar) }
+  ]`)
+      expect(beforeCode).toMatchSnapshot()
+      expect(beforeCode).contains(`foo: () => (_ctx.bar),
+    $: [
+      () => ({ [_ctx.name]: _ctx.value })
+    ]`)
+    })
+
+    test('object literal v-bind joins existing static component props', () => {
+      const { code } = compileWithElementTransform(
+        `<Foo id="x" v-bind="{ foo: bar }" />`,
+      )
+
+      expect(code).toMatchSnapshot()
+      expect(code).contains(`id: "x"`)
+      expect(code).contains(`foo: () => (_ctx.bar)`)
+      expect(code).not.contains(`$: [`)
+    })
+
+    test('unsupported object literal v-bind shapes stay as dynamic sources', () => {
+      const { code } = compileWithElementTransform(
+        `<Foo v-bind="{ [foo]: 1, ...obj }" />`,
+      )
+      const { code: protoCode } = compileWithElementTransform(
+        `<Foo v-bind="{ __proto__: foo }" />`,
+      )
+      const { code: reservedCode } = compileWithElementTransform(
+        `<Foo v-bind="{ key: foo }" />`,
+      )
+      const { code: duplicateCode } = compileWithElementTransform(
+        `<Foo v-bind="{ foo: a, foo: b }" />`,
+      )
+      const { code: methodCode } = compileWithElementTransform(
+        `<Foo v-bind="{ foo() {} }" />`,
+      )
+      const { code: conflictCode } = compileWithElementTransform(
+        `<Foo foo="x" v-bind="{ foo: bar }" />`,
+      )
+      const { code: staticBindConflictCode } = compileWithElementTransform(
+        `<Foo :foo="a" v-bind="{ foo: b }" />`,
+      )
+      const { code: camelizedConflictCode } = compileWithElementTransform(
+        `<Foo foo-bar="x" v-bind="{ fooBar: bar }" />`,
+      )
+      const { code: vnodeHookConflictCode } = compileWithElementTransform(
+        `<Foo @vue:mounted="a" v-bind="{ onVnodeMounted: b }" />`,
+      )
+      const { code: clickRightConflictCode } = compileWithElementTransform(
+        `<Foo @click.right="a" v-bind="{ onContextmenu: b }" />`,
+      )
+
+      expect(code).toMatchSnapshot()
+      expect(code).contains(`$: [`)
+      expect(code).contains(`() => ({ [_ctx.foo]: 1, ..._ctx.obj })`)
+      expect(protoCode).toMatchSnapshot()
+      expect(protoCode).contains(`$: [`)
+      expect(protoCode).contains(`() => ({ __proto__: _ctx.foo })`)
+      expect(reservedCode).toMatchSnapshot()
+      expect(reservedCode).contains(`$: [`)
+      expect(reservedCode).contains(`() => ({ key: _ctx.foo })`)
+      expect(duplicateCode).toMatchSnapshot()
+      expect(duplicateCode).contains(`$: [`)
+      expect(duplicateCode).contains(`() => ({ foo: _ctx.a, foo: _ctx.b })`)
+      expect(methodCode).toMatchSnapshot()
+      expect(methodCode).contains(`$: [`)
+      expect(methodCode).contains(`() => ({ foo() {} })`)
+      expect(conflictCode).toMatchSnapshot()
+      expect(conflictCode).contains(`foo: "x"`)
+      expect(conflictCode).contains(`$: [
+      () => ({ foo: _ctx.bar })
+    ]`)
+      expect(staticBindConflictCode).toMatchSnapshot()
+      expect(staticBindConflictCode).contains(`$: [
+      () => ({ foo: _ctx.b })
+    ]`)
+      expect(camelizedConflictCode).toMatchSnapshot()
+      expect(camelizedConflictCode).contains(`"foo-bar": "x"`)
+      expect(camelizedConflictCode).contains(`$: [
+      () => ({ fooBar: _ctx.bar })
+    ]`)
+      expect(vnodeHookConflictCode).toMatchSnapshot()
+      expect(vnodeHookConflictCode).contains(`$: [
+      () => ({ onVnodeMounted: _ctx.b })
+    ]`)
+      expect(clickRightConflictCode).toMatchSnapshot()
+      expect(clickRightConflictCode).contains(`$: [
+      () => ({ onContextmenu: _ctx.b })
+    ]`)
+    })
+
     test('v-bind="obj"', () => {
       const { code, ir } = compileWithElementTransform(`<Foo v-bind="obj" />`)
       expect(code).toMatchSnapshot()
@@ -541,6 +656,80 @@ describe('compiler: element transform', () => {
       })
     })
 
+    test('object literal v-on handlers are expanded', () => {
+      const { code } = compileWithElementTransform(
+        `<Foo v-on="{ click: onClick, input: onInput, 'foo-bar': onFooBar }" />`,
+      )
+
+      expect(code).toMatchSnapshot()
+      expect(code).contains(`onClick: () => (_ctx.onClick)`)
+      expect(code).contains(`onInput: () => (_ctx.onInput)`)
+      expect(code).contains(`"onFoo-bar": () => (_ctx.onFooBar)`)
+      expect(code).not.contains(`_toHandlers`)
+      expect(code).not.contains(`$: [`)
+    })
+
+    test('object literal v-on joins existing static component props', () => {
+      const { code } = compileWithElementTransform(
+        `<Foo id="x" v-on="{ click: onClick }" />`,
+      )
+
+      expect(code).toMatchSnapshot()
+      expect(code).contains(`id: "x"`)
+      expect(code).contains(`onClick: () => (_ctx.onClick)`)
+      expect(code).not.contains(`_toHandlers`)
+      expect(code).not.contains(`$: [`)
+    })
+
+    test('object literal v-on conflicts with existing component handlers stay dynamic', () => {
+      const { code } = compileWithElementTransform(
+        `<Foo v-on="{ click: a }" @click="b" />`,
+      )
+      const { code: bindCode } = compileWithElementTransform(
+        `<Foo v-on="{ click: a }" v-bind="{ onClick: b }" />`,
+      )
+
+      expect(code).toMatchSnapshot()
+      expect(code).contains(`_toHandlers`)
+      expect(code).contains(`() => (_toHandlers({ click: _ctx.a }))`)
+      expect(code).contains(`{ onClick: () => _ctx.b }`)
+      expect(bindCode).toMatchSnapshot()
+      expect(bindCode).contains(`_toHandlers`)
+      expect(bindCode).contains(`() => (_toHandlers({ click: _ctx.a }))`)
+      expect(bindCode).contains(`{ onClick: () => (_ctx.b) }`)
+    })
+
+    test('unsupported object literal v-on shapes stay as dynamic sources', () => {
+      const { code } = compileWithElementTransform(
+        `<Foo v-on="{ [event]: onClick, ...listeners }" />`,
+      )
+      const { code: protoCode } = compileWithElementTransform(
+        `<Foo v-on="{ __proto__: onClick }" />`,
+      )
+      const { code: duplicateCode } = compileWithElementTransform(
+        `<Foo v-on="{ click: onClick, Click: onClick2 }" />`,
+      )
+
+      expect(code).toMatchSnapshot()
+      expect(code).contains(`_toHandlers`)
+      expect(code).contains(`$: [`)
+      expect(code).contains(
+        `() => (_toHandlers({ [_ctx.event]: _ctx.onClick, ..._ctx.listeners }))`,
+      )
+      expect(protoCode).toMatchSnapshot()
+      expect(protoCode).contains(`_toHandlers`)
+      expect(protoCode).contains(`$: [`)
+      expect(protoCode).contains(
+        `() => (_toHandlers({ __proto__: _ctx.onClick }))`,
+      )
+      expect(duplicateCode).toMatchSnapshot()
+      expect(duplicateCode).contains(`_toHandlers`)
+      expect(duplicateCode).contains(`$: [`)
+      expect(duplicateCode).contains(
+        `() => (_toHandlers({ click: _ctx.onClick, Click: _ctx.onClick2 }))`,
+      )
+    })
+
     test('v-on="obj" before static event keeps handler getters', () => {
       const { code } = compileWithElementTransform(
         `<Foo v-on="obj" @foo="bar" />`,
@@ -920,6 +1109,99 @@ describe('compiler: element transform', () => {
     expect(code).contains('_setDynamicProps(n0, [_ctx.obj])')
   })
 
+  test('object literal v-bind prop is lowered to setProp', () => {
+    const { code, ir } = compileWithElementTransform(
+      `<div v-bind="{ id: foo }" />`,
+    )
+
+    expect(code).toMatchSnapshot()
+    expect(code).contains(`_setProp(n0, "id", _ctx.foo)`)
+    expect(code).not.contains(`_setDynamicProps`)
+    expect(ir.block.effect).toMatchObject([
+      {
+        expressions: [{ content: 'foo' }],
+        operations: [
+          {
+            type: IRNodeTypes.SET_PROP,
+            element: 0,
+            prop: {
+              key: { content: 'id' },
+              values: [{ content: 'foo' }],
+            },
+          },
+        ],
+      },
+    ])
+  })
+
+  test('object literal v-bind before dynamic key tracks the original source', () => {
+    const { code, ir } = compileWithElementTransform(
+      `<div v-bind="{ id: foo }" :[name]="bar" />`,
+    )
+
+    expect(code).toMatchSnapshot()
+    expect(code).contains(
+      `_setDynamicProps(n0, [{ id: _ctx.foo }, { [_ctx.name]: _ctx.bar }])`,
+    )
+    expect(ir.block.effect).toMatchObject([
+      {
+        expressions: [
+          { content: '{ id: foo }' },
+          { content: 'name' },
+          { content: 'bar' },
+        ],
+      },
+    ])
+  })
+
+  test('constant object literal v-bind props are lowered to template attrs', () => {
+    const { code, ir } = compileWithElementTransform(
+      `<div v-bind="{ id: 'foo', disabled: true }" />`,
+    )
+
+    expect(code).toMatchSnapshot()
+    expect(code).contains(`_template("<div id=foo disabled>", 3)`)
+    expect(code).not.contains(`_renderEffect`)
+    expect(code).not.contains(`_setDynamicProps`)
+    expect(ir.block.effect).lengthOf(0)
+  })
+
+  test('order-sensitive object literal v-bind props stay dynamic', () => {
+    const { code } = compileWithElementTransform(
+      `<div id="foo" v-bind="{ id: bar }" />`,
+    )
+
+    expect(code).toMatchSnapshot()
+    expect(code).contains(
+      `_setDynamicProps(n0, [{ id: "foo" }, { id: _ctx.bar }])`,
+    )
+  })
+
+  test('unsafe native object literal v-bind props stay dynamic', () => {
+    const { code: eventCode } = compileWithElementTransform(
+      `<div v-bind="{ onClick: click }" />`,
+    )
+    const { code: prefixCode } = compileWithElementTransform(
+      `<input v-bind="{ '.value': value }" />`,
+    )
+    const { code: unsafeNameCode } = compileWithElementTransform(
+      `<div v-bind="{ 'foo bar': 'x' }" />`,
+    )
+
+    expect(eventCode).toMatchSnapshot()
+    expect(eventCode).contains(
+      `_setDynamicProps(n0, [{ onClick: _ctx.click }])`,
+    )
+    expect(prefixCode).toMatchSnapshot()
+    expect(prefixCode).contains(
+      `_setDynamicProps(n0, [{ '.value': _ctx.value }])`,
+    )
+    expect(unsafeNameCode).toMatchSnapshot()
+    expect(unsafeNameCode).contains(
+      `_setDynamicProps(n0, [{ 'foo bar': 'x' }])`,
+    )
+  })
+
   test('v-bind="obj" after static prop', () => {
     const { code, ir } = compileWithElementTransform(
       `<div id="foo" v-bind="obj" />`,

+ 26 - 0
packages/compiler-vapor/__tests__/transforms/vModel.spec.ts

@@ -308,6 +308,32 @@ describe('compiler: vModel transform', () => {
       expect(code).not.contains(`modelModifiers: () => ({ trim: true })`)
     })
 
+    test('v-model after object literal v-bind keeps model generated props', () => {
+      const { code } = compileWithVModel(
+        '<Comp v-bind="{ foo: bar }" v-model:foo="foo" />',
+      )
+
+      expect(code).contains(`() => ({ foo: _ctx.bar })`)
+      expect(code).contains(`foo: () => (_ctx.foo)`)
+      expect(code).contains(
+        `"onUpdate:foo": () => _value => (_ctx.foo = _value)`,
+      )
+      expect(code).toMatchSnapshot()
+    })
+
+    test('object literal v-bind after v-model stays dynamic to preserve merge order', () => {
+      const { code } = compileWithVModel(
+        '<Comp v-model:foo="foo" v-bind="{ foo: bar }" />',
+      )
+
+      expect(code).contains(`foo: () => (_ctx.foo)`)
+      expect(code).contains(
+        `"onUpdate:foo": () => _value => (_ctx.foo = _value)`,
+      )
+      expect(code).contains(`() => ({ foo: _ctx.bar })`)
+      expect(code).toMatchSnapshot()
+    })
+
     test('v-model with arguments for component should generate modelModifiers', () => {
       const { code, ir } = compileWithVModel(
         '<Comp v-model:foo.trim="foo" v-model:bar.number="bar" />',

+ 370 - 14
packages/compiler-vapor/src/transforms/transformElement.ts

@@ -9,17 +9,21 @@ import {
   type RootNode,
   type SimpleExpressionNode,
   type TemplateChildNode,
+  advancePositionWithClone,
   createCompilerError,
   createSimpleExpression,
   hasSingleChild,
+  isSimpleIdentifier,
   isSingleIfBlock,
   isStaticArgOf,
   isValidHTMLNesting,
+  resolveModifiers,
 } from '@vue/compiler-dom'
 import {
   camelize,
   capitalize,
   extend,
+  getModifierPropName,
   includeBooleanAttr,
   isAlwaysCloseTag,
   isBlockTag,
@@ -27,11 +31,13 @@ import {
   isBuiltInDirective,
   isFormattingTag,
   isInlineTag,
+  isOn,
   isVoidTag,
   makeMap,
   normalizeClass,
   normalizeStyle,
   stringifyStyle,
+  toHandlerKey,
 } from '@vue/shared'
 import type {
   DirectiveTransformResult,
@@ -57,9 +63,14 @@ import {
   isStaticExpression,
   resolveExpression,
 } from '../utils'
-import { IMPORT_EXP_END, IMPORT_EXP_START } from '../generators/utils'
+import {
+  IMPORT_EXP_END,
+  IMPORT_EXP_START,
+  getParserOptions,
+} from '../generators/utils'
 import { normalizeBindShorthand } from './vBind'
 import type { Expression, ObjectExpression, ObjectProperty } from '@babel/types'
+import { parseExpression } from '@babel/parser'
 
 export const isReservedProp: (key: string) => boolean = /*#__PURE__*/ makeMap(
   // the leading comma is intentional so empty string "" is also included
@@ -402,6 +413,7 @@ const dynamicKeys = ['indeterminate']
 // or any of " ' ` = < or >.
 // https://html.spec.whatwg.org/multipage/introduction.html#intro-early-example
 const NEEDS_QUOTES_RE = /[\s"'`=<>]/
+const UNSAFE_ATTR_NAME_RE = /[\u0000-\u0020"'<=/>]/
 
 function transformNativeElement(
   node: PlainElementNode,
@@ -818,17 +830,47 @@ export function buildProps(
     }
   }
 
+  function pushStaticObjectLiteralProps(props: IRPropsStatic) {
+    if (dynamicArgs.length) {
+      pushMergeArg()
+      dynamicArgs.push(props)
+    } else {
+      results.push(...props.map(toDirectiveResult))
+    }
+  }
+
   for (const prop of props) {
     if (prop.type === NodeTypes.DIRECTIVE && !prop.arg) {
       if (prop.name === 'bind') {
         // v-bind="obj"
         if (prop.exp) {
-          dynamicExpr.push(prop.exp)
-          pushMergeArg()
-          dynamicArgs.push({
-            kind: IRDynamicPropsKind.EXPRESSION,
-            value: prop.exp,
-          })
+          const objectLiteralProps = isComponent
+            ? resolveComponentObjectLiteralBindProps(
+                prop.exp,
+                context,
+                props,
+                prop,
+              )
+            : resolveNativeObjectLiteralBindProps(
+                prop.exp,
+                context,
+                props,
+                prop,
+              )
+          if (objectLiteralProps) {
+            if (isComponent) {
+              pushStaticObjectLiteralProps(objectLiteralProps)
+            } else {
+              results.push(...objectLiteralProps.map(toDirectiveResult))
+            }
+          } else {
+            dynamicExpr.push(prop.exp)
+            pushMergeArg()
+            dynamicArgs.push({
+              kind: IRDynamicPropsKind.EXPRESSION,
+              value: prop.exp,
+            })
+          }
         } else {
           context.options.onError(
             createCompilerError(ErrorCodes.X_V_BIND_NO_EXPRESSION, prop.loc),
@@ -839,13 +881,23 @@ export function buildProps(
         // v-on="obj"
         if (prop.exp) {
           if (isComponent) {
-            dynamicExpr.push(prop.exp)
-            pushMergeArg()
-            dynamicArgs.push({
-              kind: IRDynamicPropsKind.EXPRESSION,
-              value: prop.exp,
-              handler: true,
-            })
+            const objectLiteralProps = resolveComponentObjectLiteralOnProps(
+              prop.exp,
+              context,
+              props,
+              prop,
+            )
+            if (objectLiteralProps) {
+              pushStaticObjectLiteralProps(objectLiteralProps)
+            } else {
+              dynamicExpr.push(prop.exp)
+              pushMergeArg()
+              dynamicArgs.push({
+                kind: IRDynamicPropsKind.EXPRESSION,
+                value: prop.exp,
+                handler: true,
+              })
+            }
           } else {
             context.registerEffect(
               [prop.exp],
@@ -906,6 +958,303 @@ export function buildProps(
   return [false, irProps]
 }
 
+function resolveObjectLiteralProps(
+  exp: SimpleExpressionNode,
+  context: TransformContext<ElementNode>,
+  keyTransform?: (key: string) => string,
+  isValidKey?: (key: string) => boolean,
+): IRPropsStatic | undefined {
+  const ast = exp.ast
+  if (!ast || ast.type !== 'ObjectExpression') return
+
+  const props: IRPropsStatic = []
+  const knownKeys = new Set<string>()
+  for (const property of ast.properties) {
+    if (property.type !== 'ObjectProperty' || property.computed) {
+      return
+    }
+
+    let key = getObjectPropertyName(property)
+    if (key == null || key === '__proto__') return
+    if (isValidKey && !isValidKey(key)) return
+    if (keyTransform) key = keyTransform(key)
+    if (knownKeys.has(key)) return
+    knownKeys.add(key)
+
+    props.push({
+      key: createSimpleExpression(key, true),
+      values: [
+        resolveExpression(
+          createObjectBindSubExpression(
+            exp,
+            property.value as Expression,
+            context,
+          ),
+          true,
+        ),
+      ],
+    })
+  }
+  return props
+}
+
+function resolveComponentObjectLiteralBindProps(
+  exp: SimpleExpressionNode,
+  context: TransformContext<ElementNode>,
+  nodeProps: (VaporDirectiveNode | AttributeNode)[],
+  currentProp: VaporDirectiveNode,
+): IRPropsStatic | undefined {
+  const props = resolveObjectLiteralProps(
+    exp,
+    context,
+    undefined,
+    isSafeObjectLiteralBindKey,
+  )
+  if (
+    !props ||
+    hasComponentObjectLiteralBindConflict(nodeProps, currentProp, props)
+  ) {
+    return
+  }
+  return props
+}
+
+function resolveNativeObjectLiteralBindProps(
+  exp: SimpleExpressionNode,
+  context: TransformContext<ElementNode>,
+  nodeProps: (VaporDirectiveNode | AttributeNode)[],
+  currentProp: VaporDirectiveNode,
+): IRPropsStatic | undefined {
+  const props = resolveObjectLiteralProps(
+    exp,
+    context,
+    undefined,
+    isSafeNativeObjectLiteralBindKey,
+  )
+  if (
+    !props ||
+    hasNativeObjectLiteralBindConflict(nodeProps, currentProp, props)
+  ) {
+    return
+  }
+  return props
+}
+
+function resolveComponentObjectLiteralOnProps(
+  exp: SimpleExpressionNode,
+  context: TransformContext<ElementNode>,
+  nodeProps: (VaporDirectiveNode | AttributeNode)[],
+  currentProp: VaporDirectiveNode,
+): IRPropsStatic | undefined {
+  const props = resolveObjectLiteralProps(exp, context, toHandlerKey)
+  if (
+    !props ||
+    hasComponentObjectLiteralBindConflict(nodeProps, currentProp, props)
+  ) {
+    return
+  }
+  return props
+}
+
+function isSafeNativeObjectLiteralBindKey(key: string): boolean {
+  return (
+    key !== '' &&
+    !UNSAFE_ATTR_NAME_RE.test(key) &&
+    isSafeObjectLiteralBindKey(key) &&
+    !isOn(key) &&
+    key.charCodeAt(0) !== 46 /* . */ &&
+    key.charCodeAt(0) !== 94 /* ^ */
+  )
+}
+
+function isSafeObjectLiteralBindKey(key: string): boolean {
+  return !isReservedProp(key)
+}
+
+function hasComponentObjectLiteralBindConflict(
+  props: (VaporDirectiveNode | AttributeNode)[],
+  currentProp: VaporDirectiveNode,
+  objectLiteralProps: IRPropsStatic,
+): boolean {
+  const keys = createComponentConflictKeySet(
+    objectLiteralProps.map(prop => prop.key.content),
+  )
+  for (const prop of props) {
+    if (prop === currentProp) continue
+
+    let key: string | undefined
+    if (prop.type === NodeTypes.ATTRIBUTE) {
+      key = prop.name
+    } else if (prop.name === 'bind') {
+      if (!prop.arg) {
+        const bindKeys = getObjectLiteralKeys(prop.exp)
+        if (bindKeys && hasComponentKeyOverlap(keys, bindKeys)) return true
+        continue
+      }
+      key = getStaticBindKey(prop)
+    } else if (prop.name === 'on') {
+      key = getStaticHandlerKey(prop)
+    } else if (prop.name === 'model') {
+      if (hasComponentModelKey(keys, prop)) {
+        return true
+      }
+    }
+
+    if (key && hasComponentKey(keys, key)) {
+      return true
+    }
+  }
+  return false
+}
+
+function hasComponentModelKey(
+  keys: Set<string>,
+  prop: VaporDirectiveNode,
+): boolean {
+  const { arg } = prop
+  if (arg && (arg.type !== NodeTypes.SIMPLE_EXPRESSION || !arg.isStatic)) {
+    return true
+  }
+
+  const key = arg ? arg.content : 'modelValue'
+  return (
+    hasComponentKey(keys, key) ||
+    hasComponentKey(keys, `onUpdate:${camelize(key)}`) ||
+    (prop.modifiers.length > 0 &&
+      hasComponentKey(keys, getModifierPropName(key)))
+  )
+}
+
+function hasNativeObjectLiteralBindConflict(
+  props: (VaporDirectiveNode | AttributeNode)[],
+  currentProp: VaporDirectiveNode,
+  objectLiteralProps: IRPropsStatic,
+): boolean {
+  const keys = new Set(objectLiteralProps.map(prop => prop.key.content))
+  for (const prop of props) {
+    if (prop === currentProp) continue
+
+    let key: string | undefined
+    if (prop.type === NodeTypes.ATTRIBUTE) {
+      key = prop.name
+    } else if (prop.name === 'bind') {
+      if (!prop.arg) return true
+      key = getStaticBindKey(prop)
+      if (!key) return true
+    }
+
+    if (key && keys.has(key)) {
+      return true
+    }
+  }
+  return false
+}
+
+function getStaticBindKey(prop: VaporDirectiveNode): string | undefined {
+  const { arg } = prop
+  if (!arg || arg.type !== NodeTypes.SIMPLE_EXPRESSION || !arg.isStatic) return
+
+  let key = arg.content
+  if (isReservedProp(key)) return
+  if (prop.modifiers.some(modifier => modifier.content === 'camel')) {
+    key = camelize(key)
+  }
+  return key
+}
+
+function getStaticHandlerKey(prop: VaporDirectiveNode): string | undefined {
+  const { arg } = prop
+  if (!arg || arg.type !== NodeTypes.SIMPLE_EXPRESSION || !arg.isStatic) return
+
+  let key = arg.content
+  if (key.startsWith('vue:')) {
+    key = `vnode-${key.slice(4)}`
+  }
+
+  const { nonKeyModifiers, eventOptionModifiers } = resolveModifiers(
+    `on${key}`,
+    prop.modifiers,
+    null,
+    prop.loc,
+  )
+  if (key.toLowerCase() === 'click') {
+    if (nonKeyModifiers.includes('middle')) {
+      key = 'mouseup'
+    }
+    if (nonKeyModifiers.includes('right')) {
+      key = 'contextmenu'
+    }
+  }
+
+  key = toHandlerKey(camelize(key))
+  const optionPostfix = eventOptionModifiers.map(capitalize).join('')
+  if (optionPostfix) key += optionPostfix
+  return key
+}
+
+function getObjectLiteralKeys(
+  exp: SimpleExpressionNode | undefined,
+): Set<string> | undefined {
+  const ast = exp && exp.ast
+  if (!ast || ast.type !== 'ObjectExpression') return
+
+  const keys = new Set<string>()
+  for (const property of ast.properties) {
+    if (property.type !== 'ObjectProperty' || property.computed) {
+      return
+    }
+    const key = getObjectPropertyName(property)
+    if (key == null) return
+    keys.add(key)
+  }
+  return keys
+}
+
+function createComponentConflictKeySet(keys: string[]): Set<string> {
+  const normalized = new Set<string>()
+  for (const key of keys) {
+    normalized.add(key)
+    normalized.add(camelize(key))
+  }
+  return normalized
+}
+
+function hasComponentKey(keys: Set<string>, key: string): boolean {
+  return keys.has(key) || keys.has(camelize(key))
+}
+
+function hasComponentKeyOverlap(
+  left: Set<string>,
+  right: Set<string>,
+): boolean {
+  for (const key of right) {
+    if (hasComponentKey(left, key)) return true
+  }
+  return false
+}
+
+function createObjectBindSubExpression(
+  source: SimpleExpressionNode,
+  node: Expression,
+  context: TransformContext<ElementNode>,
+): 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.options.expressionPlugins),
+      )
+  return expression
+}
+
 function transformProp(
   prop: VaporDirectiveNode | AttributeNode,
   node: ElementNode,
@@ -985,6 +1334,13 @@ function resolveDirectiveResult(prop: DirectiveTransformResult): IRProp {
   })
 }
 
+function toDirectiveResult(prop: IRProp): DirectiveTransformResult {
+  return extend({}, prop, {
+    values: undefined,
+    value: prop.values[0],
+  })
+}
+
 function mergePropValues(existing: IRProp, incoming: IRProp) {
   const newValues = incoming.values
   existing.values.push(...newValues)