From 26fee414ff012dd61d0669c4c05e0be47bab7636 Mon Sep 17 00:00:00 2001 From: Rizumu Ayaka Date: Mon, 8 Jan 2024 14:07:49 +0800 Subject: [PATCH] feat: compound expression for `v-on` (#60) --- .../transforms/__snapshots__/vOn.spec.ts.snap | 193 ++++++++++++ .../__tests__/transforms/vOn.spec.ts | 298 ++++++++++++++++-- packages/compiler-vapor/src/generate.ts | 57 +++- packages/reactivity/src/index.ts | 1 + packages/runtime-vapor/src/index.ts | 1 + packages/runtime-vapor/src/on.ts | 5 + playground/src/event-modifier.vue | 12 + 7 files changed, 532 insertions(+), 35 deletions(-) diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOn.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOn.spec.ts.snap index efea7f51d..7aa9e632a 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOn.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vOn.spec.ts.snap @@ -12,6 +12,18 @@ export function render(_ctx) { }" `; +exports[`v-on > complex member expression w/ prefixIdentifiers: true 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", (...args) => (_ctx.a['b' + _ctx.c] && _ctx.a['b' + _ctx.c](...args))) + return n0 +}" +`; + exports[`v-on > dynamic arg 1`] = ` "import { template as _template, children as _children, renderEffect as _renderEffect, on as _on } from 'vue/vapor'; @@ -26,6 +38,34 @@ export function render(_ctx) { }" `; +exports[`v-on > dynamic arg with complex exp prefixing 1`] = ` +"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _renderEffect(() => { + _on(n1, _ctx.event(_ctx.foo), (...args) => (_ctx.handler && _ctx.handler(...args))) + }) + return n0 +}" +`; + +exports[`v-on > dynamic arg with prefixing 1`] = ` +"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _renderEffect(() => { + _on(n1, _ctx.event, (...args) => (_ctx.handler && _ctx.handler(...args))) + }) + return n0 +}" +`; + exports[`v-on > event modifier 1`] = ` "import { template as _template, children as _children, on as _on, withModifiers as _withModifiers, withKeys as _withKeys } from 'vue/vapor'; @@ -59,6 +99,133 @@ export function render(_ctx) { }" `; +exports[`v-on > function expression w/ prefixIdentifiers: true 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", e => _ctx.foo(e)) + return n0 +}" +`; + +exports[`v-on > inline statement w/ prefixIdentifiers: true 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", $event => (_ctx.foo($event))) + return n0 +}" +`; + +exports[`v-on > multiple inline statements w/ prefixIdentifiers: true 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", $event => {_ctx.foo($event);_ctx.bar()}) + return n0 +}" +`; + +exports[`v-on > should NOT add a prefix to $event if the expression is a function expression 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", $event => {_ctx.i++;_ctx.foo($event)}) + return n0 +}" +`; + +exports[`v-on > should NOT wrap as function if expression is already function expression (with Typescript) 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", (e: any): any => _ctx.foo(e)) + return n0 +}" +`; + +exports[`v-on > should NOT wrap as function if expression is already function expression (with newlines) 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", + $event => { + _ctx.foo($event) + } + ) + return n0 +}" +`; + +exports[`v-on > should NOT wrap as function if expression is already function expression 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", $event => _ctx.foo($event)) + return n0 +}" +`; + +exports[`v-on > should NOT wrap as function if expression is complex member expression 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", (...args) => (_ctx.a['b' + _ctx.c] && _ctx.a['b' + _ctx.c](...args))) + return n0 +}" +`; + +exports[`v-on > should handle multi-line statement 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", $event => { +_ctx.foo(); +_ctx.bar() +}) + return n0 +}" +`; + +exports[`v-on > should handle multiple inline statement 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", $event => {_ctx.foo();_ctx.bar()}) + return n0 +}" +`; + exports[`v-on > should not wrap keys guard if no key modifier is present 1`] = ` "import { template as _template, children as _children, on as _on, withModifiers as _withModifiers } from 'vue/vapor'; @@ -148,6 +315,32 @@ export function render(_ctx) { }" `; +exports[`v-on > should wrap as function if expression is inline statement 1`] = ` +"import { template as _template, children as _children, on as _on } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _on(n1, "click", $event => (_ctx.i++)) + return n0 +}" +`; + +exports[`v-on > should wrap both for dynamic key event w/ left/right modifiers 1`] = ` +"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on, withKeys as _withKeys, withModifiers as _withModifiers } from 'vue/vapor'; + +export function render(_ctx) { + const t0 = _template("
") + const n0 = t0() + const { 0: [n1],} = _children(n0) + _renderEffect(() => { + _on(n1, _ctx.e, _withKeys(_withModifiers((...args) => (_ctx.test && _ctx.test(...args)), ["left"]), ["left"])) + }) + return n0 +}" +`; + exports[`v-on > should wrap keys guard for keyboard events or dynamic events 1`] = ` "import { template as _template, children as _children, on as _on, withKeys as _withKeys, withModifiers as _withModifiers } from 'vue/vapor'; diff --git a/packages/compiler-vapor/__tests__/transforms/vOn.spec.ts b/packages/compiler-vapor/__tests__/transforms/vOn.spec.ts index 08a73b430..0b6427069 100644 --- a/packages/compiler-vapor/__tests__/transforms/vOn.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/vOn.spec.ts @@ -124,30 +124,254 @@ describe('v-on', () => { expect(code).matchSnapshot() }) - test.todo('dynamic arg with prefixing') - test.todo('dynamic arg with complex exp prefixing') - test.todo('should wrap as function if expression is inline statement') - test.todo('should handle multiple inline statement') - test.todo('should handle multi-line statement') - test.todo('inline statement w/ prefixIdentifiers: true') - test.todo('multiple inline statements w/ prefixIdentifiers: true') - test.todo( - 'should NOT wrap as function if expression is already function expression', - ) - test.todo( + test('dynamic arg with prefixing', () => { + const { code } = compileWithVOn(`
`, { + prefixIdentifiers: true, + }) + + expect(code).matchSnapshot() + }) + + test('dynamic arg with complex exp prefixing', () => { + const { ir, code } = compileWithVOn(`
`, { + prefixIdentifiers: true, + }) + + expect(ir.vaporHelpers).contains('on') + expect(ir.vaporHelpers).contains('renderEffect') + expect(ir.helpers.size).toBe(0) + expect(ir.operation).toEqual([]) + + expect(ir.effect[0].operations[0]).toMatchObject({ + type: IRNodeTypes.SET_EVENT, + element: 1, + key: { + type: NodeTypes.SIMPLE_EXPRESSION, + content: 'event(foo)', + isStatic: false, + }, + value: { + type: NodeTypes.SIMPLE_EXPRESSION, + content: 'handler', + isStatic: false, + }, + }) + + expect(code).matchSnapshot() + }) + + test('should wrap as function if expression is inline statement', () => { + const { code, ir } = compileWithVOn(`
`) + + expect(ir.vaporHelpers).contains('on') + expect(ir.helpers.size).toBe(0) + expect(ir.effect).toEqual([]) + + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + element: 1, + value: { + type: NodeTypes.SIMPLE_EXPRESSION, + content: 'i++', + isStatic: false, + }, + }, + ]) + + expect(code).matchSnapshot() + expect(code).contains('_on(n1, "click", $event => (_ctx.i++))') + }) + + test('should handle multiple inline statement', () => { + const { ir, code } = compileWithVOn(`
`) + + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { content: 'foo();bar()' }, + }, + ]) + + expect(code).matchSnapshot() + // should wrap with `{` for multiple statements + // in this case the return value is discarded and the behavior is + // consistent with 2.x + expect(code).contains('_on(n1, "click", $event => {_ctx.foo();_ctx.bar()})') + }) + + test('should handle multi-line statement', () => { + const { code, ir } = compileWithVOn(`
`) + + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { content: '\nfoo();\nbar()\n' }, + }, + ]) + + expect(code).matchSnapshot() + // should wrap with `{` for multiple statements + // in this case the return value is discarded and the behavior is + // consistent with 2.x + expect(code).contains( + '_on(n1, "click", $event => {\n_ctx.foo();\n_ctx.bar()\n})', + ) + }) + + test('inline statement w/ prefixIdentifiers: true', () => { + const { code, ir } = compileWithVOn(`
`, { + prefixIdentifiers: true, + }) + + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { content: 'foo($event)' }, + }, + ]) + + expect(code).matchSnapshot() + // should NOT prefix $event + expect(code).contains('_on(n1, "click", $event => (_ctx.foo($event)))') + }) + + test('multiple inline statements w/ prefixIdentifiers: true', () => { + const { ir, code } = compileWithVOn(`
`, { + prefixIdentifiers: true, + }) + + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { content: 'foo($event);bar()' }, + }, + ]) + + expect(code).matchSnapshot() + // should NOT prefix $event + expect(code).contains( + '_on(n1, "click", $event => {_ctx.foo($event);_ctx.bar()})', + ) + }) + + test('should NOT wrap as function if expression is already function expression', () => { + const { code, ir } = compileWithVOn(`
`) + + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { content: '$event => foo($event)' }, + }, + ]) + + expect(code).matchSnapshot() + expect(code).contains('_on(n1, "click", $event => _ctx.foo($event))') + }) + + test.fails( 'should NOT wrap as function if expression is already function expression (with Typescript)', + () => { + const { ir, code } = compileWithVOn( + `
`, + { expressionPlugins: ['typescript'] }, + ) + + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { content: '(e: any): any => foo(e)' }, + }, + ]) + + expect(code).matchSnapshot() + expect(code).contains('_on(n1, "click", e => _ctx.foo(e))') + }, ) - test.todo( - 'should NOT wrap as function if expression is already function expression (with newlines)', - ) - test.todo( - 'should NOT wrap as function if expression is already function expression (with newlines + function keyword)', - ) - test.todo( - 'should NOT wrap as function if expression is complex member expression', - ) - test.todo('complex member expression w/ prefixIdentifiers: true') - test.todo('function expression w/ prefixIdentifiers: true') + + test('should NOT wrap as function if expression is already function expression (with newlines)', () => { + const { ir, code } = compileWithVOn( + `
`, + ) + + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { + content: ` + $event => { + foo($event) + } + `, + }, + }, + ]) + + expect(code).matchSnapshot() + }) + + test('should NOT add a prefix to $event if the expression is a function expression', () => { + const { ir, code } = compileWithVOn( + `
`, + { + prefixIdentifiers: true, + }, + ) + + expect(ir.operation[0]).toMatchObject({ + type: IRNodeTypes.SET_EVENT, + value: { content: '$event => {i++;foo($event)}' }, + }) + + expect(code).matchSnapshot() + }) + + test('should NOT wrap as function if expression is complex member expression', () => { + const { ir, code } = compileWithVOn(`
`) + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { content: `a['b' + c]` }, + }, + ]) + + expect(code).matchSnapshot() + }) + + test('complex member expression w/ prefixIdentifiers: true', () => { + const { ir, code } = compileWithVOn(`
`) + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { content: `a['b' + c]` }, + }, + ]) + + expect(code).matchSnapshot() + expect(code).contains( + `_on(n1, "click", (...args) => (_ctx.a['b' + _ctx.c] && _ctx.a['b' + _ctx.c](...args)))`, + ) + }) + + test('function expression w/ prefixIdentifiers: true', () => { + const { code, ir } = compileWithVOn(`
`, { + prefixIdentifiers: true, + }) + + expect(ir.operation).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + value: { content: `e => foo(e)` }, + }, + ]) + + expect(code).matchSnapshot() + expect(code).contains('_on(n1, "click", e => _ctx.foo(e))') + }) test('should error if no expression AND no modifier', () => { const onError = vi.fn() @@ -366,14 +590,40 @@ describe('v-on', () => { expect(ir.operation).toMatchObject([ { type: IRNodeTypes.SET_EVENT, - modifiers: { keys: ['left'] }, + modifiers: { + keys: ['left'], + nonKeys: [], + options: [], + }, }, ]) expect(code).matchSnapshot() }) - test.todo('should wrap both for dynamic key event w/ left/right modifiers') + test('should wrap both for dynamic key event w/ left/right modifiers', () => { + const { code, ir } = compileWithVOn(`
`, { + prefixIdentifiers: true, + }) + + expect(ir.effect[0].operations).toMatchObject([ + { + type: IRNodeTypes.SET_EVENT, + key: { + type: NodeTypes.SIMPLE_EXPRESSION, + content: 'e', + isStatic: false, + }, + modifiers: { + keys: ['left'], + nonKeys: ['left'], + options: [], + }, + }, + ]) + + expect(code).matchSnapshot() + }) test('should transform click.right', () => { const { code, ir } = compileWithVOn(`
`) diff --git a/packages/compiler-vapor/src/generate.ts b/packages/compiler-vapor/src/generate.ts index 6caf9a0d0..262ba0898 100644 --- a/packages/compiler-vapor/src/generate.ts +++ b/packages/compiler-vapor/src/generate.ts @@ -8,6 +8,7 @@ import { advancePositionWithClone, advancePositionWithMutation, createSimpleExpression, + isMemberExpression, isSimpleIdentifier, locStub, walkIdentifiers, @@ -33,6 +34,10 @@ import { SourceMapGenerator } from 'source-map-js' import { camelize, isGloballyAllowed, isString, makeMap } from '@vue/shared' import type { Identifier } from '@babel/types' +// TODO: share this with compiler-core +const fnExpRE = + /^\s*([\w$_]+|(async\s*)?\([^)]*?\))\s*(:[^=]+)?=>|^\s*(async\s+)?function(?:\s+[\w$]+)?\s*\(/ + // remove when stable // @ts-expect-error function checkNever(x: never): never {} @@ -508,15 +513,7 @@ function genSetEvent(oper: SetEventIRNode, context: CodegenContext) { ;(keys.length ? pushWithKeys : pushNoop)(() => (nonKeys.length ? pushWithModifiers : pushNoop)(() => { - if (oper.value && oper.value.content.trim()) { - push('(...args) => (') - genExpression(oper.value, context) - push(' && ') - genExpression(oper.value, context) - push('(...args))') - } else { - push('() => {}') - } + genEventHandler() }), ) }, @@ -524,6 +521,37 @@ function genSetEvent(oper: SetEventIRNode, context: CodegenContext) { !!options.length && (() => push(`{ ${options.map((v) => `${v}: true`).join(', ')} }`)), ) + + function genEventHandler() { + const exp = oper.value + if (exp && exp.content.trim()) { + const isMemberExp = isMemberExpression(exp.content, { + // TODO: expression plugins + expressionPlugins: [], + }) + const isInlineStatement = !(isMemberExp || fnExpRE.test(exp.content)) + const hasMultipleStatements = exp.content.includes(`;`) + + if (isInlineStatement) { + push('$event => ') + push(hasMultipleStatements ? '{' : '(') + const knownIds = Object.create(null) + knownIds['$event'] = 1 + genExpression(exp, context, knownIds) + push(hasMultipleStatements ? '}' : ')') + } else if (isMemberExp) { + push('(...args) => (') + genExpression(exp, context) + push(' && ') + genExpression(exp, context) + push('(...args))') + } else { + genExpression(exp, context) + } + } else { + push('() => {}') + } + } } function genWithDirective(oper: WithDirectiveIRNode, context: CodegenContext) { @@ -588,7 +616,11 @@ function genArrayExpression(elements: string[]) { const isLiteralWhitelisted = /*#__PURE__*/ makeMap('true,false,null,this') -function genExpression(node: IRExpression, context: CodegenContext): void { +function genExpression( + node: IRExpression, + context: CodegenContext, + knownIds: Record = Object.create(null), +): void { const { push } = context if (isString(node)) return push(node) @@ -616,10 +648,13 @@ function genExpression(node: IRExpression, context: CodegenContext): void { const ids: Identifier[] = [] walkIdentifiers( ast!, - (id) => { + (id, parent, parentStack, isReference, isLocal) => { + if (isLocal) return ids.push(id) }, true, + [], + knownIds, ) if (ids.length) { ids.sort((a, b) => a.start! - b.start!) diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index a8db2454a..8fe9c18c6 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -71,6 +71,7 @@ export { export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants' export { baseWatch, + getCurrentEffect, onEffectCleanup, traverse, BaseWatchErrorCodes, diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index c740912ef..805fbae0e 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -29,6 +29,7 @@ export { // effect stop, ReactiveEffect, + getCurrentEffect, onEffectCleanup, // effect scope effectScope, diff --git a/packages/runtime-vapor/src/on.ts b/packages/runtime-vapor/src/on.ts index eff58c941..a25f47cc8 100644 --- a/packages/runtime-vapor/src/on.ts +++ b/packages/runtime-vapor/src/on.ts @@ -1,3 +1,5 @@ +import { getCurrentEffect, onEffectCleanup } from '@vue/reactivity' + export function on( el: HTMLElement, event: string, @@ -5,4 +7,7 @@ export function on( options?: AddEventListenerOptions, ) { el.addEventListener(event, handler, options) + if (getCurrentEffect()) { + onEffectCleanup(() => el.removeEventListener(event, handler, options)) + } } diff --git a/playground/src/event-modifier.vue b/playground/src/event-modifier.vue index 01f14883f..0eb856023 100644 --- a/playground/src/event-modifier.vue +++ b/playground/src/event-modifier.vue @@ -1,7 +1,11 @@