diff --git a/packages/@lwc/compiler/src/options.ts b/packages/@lwc/compiler/src/options.ts index 576a3eb62e..c386477428 100755 --- a/packages/@lwc/compiler/src/options.ts +++ b/packages/@lwc/compiler/src/options.ts @@ -125,6 +125,8 @@ export interface TransformOptions { customRendererConfig?: CustomRendererConfig; /** @deprecated Ignored by compiler. `lwc:spread` is always enabled. */ enableLwcSpread?: boolean; + /** Flag to enable usage of dynamic event listeners (lwc:on) directive in HTML template */ + enableLwcOn?: boolean; /** Set to true if synthetic shadow DOM support is not needed, which can result in smaller/faster output. */ disableSyntheticShadowSupport?: boolean; /** @@ -148,6 +150,7 @@ type OptionalTransformKeys = | 'scopedStyles' | 'customRendererConfig' | 'enableLwcSpread' + | 'enableLwcOn' | 'enableLightningWebSecurityTransforms' | 'enableDynamicComponents' | 'experimentalDynamicDirective' diff --git a/packages/@lwc/compiler/src/transformers/template.ts b/packages/@lwc/compiler/src/transformers/template.ts index 9d837f55aa..6374aebd50 100755 --- a/packages/@lwc/compiler/src/transformers/template.ts +++ b/packages/@lwc/compiler/src/transformers/template.ts @@ -40,6 +40,7 @@ export default function templateTransform( customRendererConfig, enableDynamicComponents, experimentalDynamicDirective: deprecatedDynamicDirective, + enableLwcOn, instrumentation, namespace, name, @@ -61,6 +62,7 @@ export default function templateTransform( enableStaticContentOptimization, customRendererConfig, enableDynamicComponents, + enableLwcOn, instrumentation, apiVersion, disableSyntheticShadowSupport, diff --git a/packages/@lwc/engine-core/src/framework/hydration.ts b/packages/@lwc/engine-core/src/framework/hydration.ts index 2eb6002c87..bf35b134db 100644 --- a/packages/@lwc/engine-core/src/framework/hydration.ts +++ b/packages/@lwc/engine-core/src/framework/hydration.ts @@ -50,6 +50,7 @@ import { VNodeType, isVStaticPartElement } from './vnodes'; import { patchProps } from './modules/props'; import { applyEventListeners } from './modules/events'; +import { patchDynamicEventListeners } from './modules/dynamic-events'; import { hydrateStaticParts, traverseAndSetElements } from './modules/static-parts'; import { getScopeTokenClass } from './stylesheet'; import { renderComponent } from './component'; @@ -517,6 +518,7 @@ function handleMismatch(node: Node, vnode: VNode, renderer: RendererAPI): Node | function patchElementPropsAndAttrsAndRefs(vnode: VBaseElement, renderer: RendererAPI) { applyEventListeners(vnode, renderer); + patchDynamicEventListeners(null, vnode, renderer, vnode.owner); patchProps(null, vnode, renderer); // The `refs` object is blown away in every re-render, so we always need to re-apply them applyRefs(vnode, vnode.owner); diff --git a/packages/@lwc/engine-core/src/framework/modules/dynamic-events.ts b/packages/@lwc/engine-core/src/framework/modules/dynamic-events.ts new file mode 100644 index 0000000000..91602dd73d --- /dev/null +++ b/packages/@lwc/engine-core/src/framework/modules/dynamic-events.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { isUndefined } from '@lwc/shared'; +import { EmptyObject } from '../utils'; +import { invokeEventListener } from '../invoker'; +import { logError } from '../../shared/logger'; +import type { VM } from '../vm'; +import type { VBaseElement } from '../vnodes'; +import type { RendererAPI } from '../renderer'; + +export function patchDynamicEventListeners( + oldVnode: VBaseElement | null, + vnode: VBaseElement, + renderer: RendererAPI, + owner: VM +) { + const { + elm, + data: { dynamicOn, dynamicOnRaw }, + sel, + } = vnode; + + // dynamicOn : A cloned version of the object passed to lwc:on, with null prototype and only its own enumerable properties. + const oldDynamicOn = oldVnode?.data?.dynamicOn ?? EmptyObject; + const newDynamicOn = dynamicOn ?? EmptyObject; + + // dynamicOnRaw : object passed to lwc:on + // Compare dynamicOnRaw to check if same object is passed to lwc:on + const isObjectSame = oldVnode?.data?.dynamicOnRaw === dynamicOnRaw; + + const { addEventListener, removeEventListener } = renderer; + const attachedEventListeners = getAttachedEventListeners(owner, elm!); + + // Properties that are present in 'oldDynamicOn' but not in 'newDynamicOn' + for (const eventType in oldDynamicOn) { + if (!(eventType in newDynamicOn)) { + // log error if same object is passed + if (isObjectSame && process.env.NODE_ENV !== 'production') { + logError( + `Detected mutation of property '${eventType}' in the object passed to lwc:on for <${sel}>. Reusing the same object with modified properties is prohibited. Please pass a new object instead.`, + owner + ); + } + + // Remove listeners that were attached previously but don't have a corresponding property in `newDynamicOn` + const attachedEventListener = attachedEventListeners[eventType]; + removeEventListener(elm, eventType, attachedEventListener!); + attachedEventListeners[eventType] = undefined; + } + } + + // Ensure that the event listeners that are attached match what is present in `newDynamicOn` + for (const eventType in newDynamicOn) { + const typeExistsInOld = eventType in oldDynamicOn; + const newCallback = newDynamicOn[eventType]; + + // Skip if callback hasn't changed + if (typeExistsInOld && oldDynamicOn[eventType] === newCallback) { + continue; + } + + // log error if same object is passed + if (isObjectSame && process.env.NODE_ENV !== 'production') { + logError( + `Detected mutation of property '${eventType}' in the object passed to lwc:on for <${sel}>. Reusing the same object with modified properties is prohibited. Please pass a new object instead.`, + owner + ); + } + + // Remove listener that was attached previously + if (typeExistsInOld) { + const attachedEventListener = attachedEventListeners[eventType]; + removeEventListener(elm, eventType, attachedEventListener!); + } + + // Bind new callback to owner component and add it as listener to element + const newBoundEventListener = bindEventListener(owner, newCallback); + addEventListener(elm, eventType, newBoundEventListener); + + // Store the newly added eventListener + attachedEventListeners[eventType] = newBoundEventListener; + } +} + +function getAttachedEventListeners( + vm: VM, + elm: Element +): Record { + let attachedEventListeners = vm.attachedEventListeners.get(elm); + if (isUndefined(attachedEventListeners)) { + attachedEventListeners = {}; + vm.attachedEventListeners.set(elm, attachedEventListeners); + } + return attachedEventListeners; +} + +function bindEventListener(vm: VM, fn: EventListener): EventListener { + return function (event: Event) { + invokeEventListener(vm, fn, vm.component, event); + }; +} diff --git a/packages/@lwc/engine-core/src/framework/rendering.ts b/packages/@lwc/engine-core/src/framework/rendering.ts index 2a43d1bf8a..0555103327 100644 --- a/packages/@lwc/engine-core/src/framework/rendering.ts +++ b/packages/@lwc/engine-core/src/framework/rendering.ts @@ -53,6 +53,7 @@ import { patchProps } from './modules/props'; import { patchClassAttribute } from './modules/computed-class-attr'; import { patchStyleAttribute } from './modules/computed-style-attr'; import { applyEventListeners } from './modules/events'; +import { patchDynamicEventListeners } from './modules/dynamic-events'; import { applyStaticClassAttribute } from './modules/static-class-attr'; import { applyStaticStyleAttribute } from './modules/static-style-attr'; import { applyRefs } from './modules/refs'; @@ -586,6 +587,7 @@ function patchElementPropsAndAttrsAndRefs( } const { owner } = vnode; + patchDynamicEventListeners(oldVnode, vnode, renderer, owner); // Attrs need to be applied to element before props IE11 will wipe out value on radio inputs if // value is set before type=radio. patchClassAttribute(oldVnode, vnode, renderer); diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index 4424effcd6..5112f39eb2 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -135,6 +135,8 @@ export interface VM { readonly owner: VM | null; /** References to elements rendered using lwc:ref (template refs) */ refVNodes: RefVNodes | null; + /** event listeners added to elements corresponding to functions provided by lwc:on */ + attachedEventListeners: WeakMap>; /** Whether or not the VM was hydrated */ readonly hydrated: boolean; /** Rendering operations associated with the VM */ @@ -344,6 +346,7 @@ export function createVM( mode, owner, refVNodes: null, + attachedEventListeners: new WeakMap(), children: EmptyArray, aChildren: EmptyArray, velements: EmptyArray, diff --git a/packages/@lwc/engine-core/src/framework/vnodes.ts b/packages/@lwc/engine-core/src/framework/vnodes.ts index da410aa555..81184c1bc9 100644 --- a/packages/@lwc/engine-core/src/framework/vnodes.ts +++ b/packages/@lwc/engine-core/src/framework/vnodes.ts @@ -153,6 +153,8 @@ export interface VNodeData { readonly styleDecls?: ReadonlyArray<[string, string, boolean]>; readonly context?: Readonly>>>; readonly on?: Readonly any>>; + readonly dynamicOn?: Readonly any>>; // clone of object passed to lwc:on, used to patch event listeners + readonly dynamicOnRaw?: Readonly any>>; // object passed to lwc:on, used to verify whether object reference has changed readonly svg?: boolean; readonly renderer?: RendererAPI; } diff --git a/packages/@lwc/errors/src/compiler/error-info/index.ts b/packages/@lwc/errors/src/compiler/error-info/index.ts index 99107e2034..55f70e8f28 100644 --- a/packages/@lwc/errors/src/compiler/error-info/index.ts +++ b/packages/@lwc/errors/src/compiler/error-info/index.ts @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ /** - * Next error code: 1203 + * Next error code: 1207 */ export * from './compiler'; diff --git a/packages/@lwc/errors/src/compiler/error-info/template-transform.ts b/packages/@lwc/errors/src/compiler/error-info/template-transform.ts index 28fa781e60..3a86c7f486 100644 --- a/packages/@lwc/errors/src/compiler/error-info/template-transform.ts +++ b/packages/@lwc/errors/src/compiler/error-info/template-transform.ts @@ -947,4 +947,36 @@ export const ParserDiagnostics = { level: DiagnosticLevel.Warning, url: '', }, + + INVALID_LWC_ON_ELEMENT: { + code: 1203, + message: + 'Invalid lwc:on usage on element "{0}". The directive can\'t be used on a template element.', + level: DiagnosticLevel.Error, + url: '', + }, + + INVALID_LWC_ON_LITERAL_PROP: { + code: 1204, + message: + 'Invalid lwc:on usage on element "{0}". The directive binding must be an expression.', + level: DiagnosticLevel.Error, + url: '', + }, + + INVALID_LWC_ON_WITH_DECLARATIVE_LISTENERS: { + code: 1205, + message: + 'Invalid lwc:on usage on element "{0}". It is not permitted to use declarative event listeners alongside lwc:on', + level: DiagnosticLevel.Error, + url: '', + }, + + INVALID_LWC_ON_OPTS: { + code: 1206, + message: + 'Invalid lwc:on usage. The `lwc:on` directive must be enabled in order to use this feature.', + level: DiagnosticLevel.Error, + url: '', + }, }; diff --git a/packages/@lwc/integration-karma/scripts/karma-plugins/lwc.js b/packages/@lwc/integration-karma/scripts/karma-plugins/lwc.js index 59c13b5f71..a23a9116c9 100644 --- a/packages/@lwc/integration-karma/scripts/karma-plugins/lwc.js +++ b/packages/@lwc/integration-karma/scripts/karma-plugins/lwc.js @@ -58,6 +58,7 @@ function createPreprocessor(config, emitter, logger) { strict: true, }, enableDynamicComponents: true, + enableLwcOn: true, experimentalComplexExpressions, enableStaticContentOptimization: !DISABLE_STATIC_CONTENT_OPTIMIZATION, disableSyntheticShadowSupport: DISABLE_SYNTHETIC_SHADOW_SUPPORT_IN_COMPILER, diff --git a/packages/@lwc/integration-karma/test/lwc-on/index.spec.js b/packages/@lwc/integration-karma/test/lwc-on/index.spec.js new file mode 100644 index 0000000000..9858ee3258 --- /dev/null +++ b/packages/@lwc/integration-karma/test/lwc-on/index.spec.js @@ -0,0 +1,462 @@ +import { createElement } from 'lwc'; +import Basic from 'x/basic'; +import ExecutionContext from 'x/executionContext'; +import Ignored from 'x/ignored'; +import CaseVariants from 'x/caseVariants'; +import Spread from 'x/spread'; +import Lifecycle from 'x/lifecycle'; +import ValueNotFunction from 'x/valueNotFunction'; +import Rerender from 'x/rerender'; +import RerenderLoop from 'x/rerenderLoop'; +import PublicProp from 'x/publicProp'; +import ComputedKey from 'x/computedKey'; +import ValueEvaluationThrows from 'x/ValueEvaluationThrows'; + +describe('lwc:on', () => { + it('adds multiple event listeners', () => { + const element = createElement('x-basic', { is: Basic }); + const testFn = jasmine.createSpy('test function'); + element.testFn = testFn; + document.body.appendChild(element); + const button = element.shadowRoot.querySelector('button'); + button.click(); + button.dispatchEvent(new MouseEvent('mouseover')); + + expect(testFn).toHaveBeenCalledWith('click handler called'); + expect(testFn).toHaveBeenCalledWith('mouseover handler called'); + }); + + it('event listeners added by lwc:on are bound to the owner component', () => { + const element = createElement('x-execution-context', { is: ExecutionContext }); + const testFn = jasmine.createSpy('test function'); + element.testFn = testFn; + document.body.appendChild(element); + const button = element.shadowRoot.querySelector('button'); + + button.click(); + + expect(testFn).toHaveBeenCalledWith("'this' is the component"); + }); + + describe('ignored properties', () => { + let element; + let button; + let testFn; + + function setup(propType) { + element = createElement('x-ignored', { is: Ignored }); + testFn = jasmine.createSpy('test function'); + element.testFn = testFn; + element.propType = propType; + document.body.appendChild(element); + button = element.shadowRoot.querySelector('button'); + } + + // In these tests, we are implicitly asserting that no error is thrown if the test passes + + it('silently ignores non-enumerable properties', () => { + setup('non-enumerable'); + button.click(); + expect(testFn).not.toHaveBeenCalled(); + }); + + it('silently ignores inherited properties', () => { + setup('inherited'); + button.click(); + expect(testFn).not.toHaveBeenCalled(); + }); + + it('silently ignores symbol-keyed properties', () => { + setup('symbol-keyed'); + }); + }); + + describe('event type case', () => { + let element; + let button; + let testFn; + + function setup(propCase) { + element = createElement('x-case-variants', { is: CaseVariants }); + testFn = jasmine.createSpy('test function'); + element.testFn = testFn; + element.propCase = propCase; + document.body.appendChild(element); + button = element.shadowRoot.querySelector('button'); + } + + it('adds event listeners corresponding to lowercase keyed property', () => { + setup('lower'); + button.dispatchEvent(new CustomEvent('lowercase')); + expect(testFn).toHaveBeenCalledWith('lowercase handler called'); + }); + + it('adds event listeners corresponding to kebab-case keyed property', () => { + setup('kebab'); + button.dispatchEvent(new CustomEvent('kebab-case')); + expect(testFn).toHaveBeenCalledWith('kebab-case handler called'); + }); + + it('adds event listeners corresponding to camelCase keyed property', () => { + setup('camel'); + button.dispatchEvent(new CustomEvent('camelCase')); + expect(testFn).toHaveBeenCalledWith('camelCase handler called'); + }); + + it('adds event listeners corresponding to CAPScase keyed property', () => { + setup('caps'); + button.dispatchEvent(new CustomEvent('CAPSCASE')); + expect(testFn).toHaveBeenCalledWith('CAPSCASE handler called'); + }); + + it('adds event listeners corresponding to PascalCase keyed property', () => { + setup('pascal'); + button.dispatchEvent(new CustomEvent('PascalCase')); + expect(testFn).toHaveBeenCalledWith('PascalCase handler called'); + }); + + it('adds event listeners corresponding to empty-string keyed property', () => { + setup('empty'); + button.dispatchEvent(new CustomEvent('')); + expect(testFn).toHaveBeenCalledWith('empty string handler called'); + }); + }); + + it('event listeners are added independently from lwc:on and lwc:spread', () => { + const element = createElement('x-spread', { is: Spread }); + const testFn = jasmine.createSpy('test function'); + element.testFn = testFn; + document.body.appendChild(element); + const button = element.shadowRoot.querySelector('button'); + + button.click(); + + expect(testFn).toHaveBeenCalledWith('lwc:spread handler called'); + expect(testFn).toHaveBeenCalledWith('lwc:on handler called'); + }); + + it("event listeners are added before child's connectedCallback", () => { + const element = createElement('x-lifecycle', { is: Lifecycle }); + const testFn = jasmine.createSpy('foo handler'); + element.testFn = testFn; + document.body.appendChild(element); + + expect(testFn).toHaveBeenCalledWith( + 'handled events dispatched from child connectedCallback' + ); + }); + + describe('object passed to lwc:on has property whose values is not a function', () => { + let element; + let button; + + let caughtError; + + TestUtils.catchUnhandledRejectionsAndErrors((error) => { + caughtError = error; + }); + + afterEach(() => { + caughtError = undefined; + }); + + function setup(handlerType) { + element = createElement('x-value-not-function', { is: ValueNotFunction }); + element.handlerType = handlerType; + document.body.appendChild(element); + button = element.shadowRoot.querySelector('button'); + } + + function assertError() { + if (process.env.NODE_ENV !== 'production') { + expect(caughtError.message).toContain( + "Uncaught Error: Assert Violation: Invalid event handler for event 'click' on" + ); + } else { + expect(caughtError.error instanceof TypeError).toBe(true); + } + } + + it('null passed as handler', () => { + setup('null'); + button.click(); + + assertError(); + }); + + describe('undefined passed as handler', () => { + it('directly sets undefined', () => { + setup('undefined'); + button.click(); + + assertError(); + }); + + it('value of an accessor property without getter', () => { + setup('setter without getter'); + button.click(); + + assertError(); + }); + }); + + it('string passed as handler', () => { + setup('string'); + button.click(); + + assertError(); + }); + }); + + describe('re-render behavior', () => { + let element; + let button; + let testFn; + + describe('without for:each loop', () => { + beforeEach(() => { + element = createElement('x-rerender', { is: Rerender }); + testFn = jasmine.createSpy('test function'); + element.testFn = testFn; + document.body.appendChild(element); + button = element.shadowRoot.querySelector('button'); + }); + + describe('with new object', () => { + it('Event listeners are added when lwc:on is provided a new object with additional properties', async () => { + element.listenersName = 'click and mouseover'; + await element.triggerReRender(); + + button.click(); + button.dispatchEvent(new MouseEvent('mouseover')); + expect(testFn).toHaveBeenCalledWith('click handler called'); + expect(testFn).toHaveBeenCalledWith('mouseover handler called'); + }); + + it('Event listeners are removed when lwc:on is provided a new object with reduced properties', async () => { + element.listenersName = 'empty'; + await element.triggerReRender(); + + button.click(); + expect(testFn).not.toHaveBeenCalledWith('click handler called'); + }); + + it('Event listeners are modified when lwc:on is provided a new object with modified properties', async () => { + element.listenersName = 'modified click'; + await element.triggerReRender(); + + button.click(); + expect(testFn).not.toHaveBeenCalledWith('click handler called'); + expect(testFn).toHaveBeenCalledWith('modified click handler called'); + }); + }); + + describe('with same object modified', () => { + let consoleSpy; + beforeEach(() => { + consoleSpy = TestUtils.spyConsole(); + }); + afterEach(() => { + consoleSpy.reset(); + }); + + it('throws when a new property is added to object passed to lwc:on', async () => { + element.addMouseoverHandler(); + await element.triggerReRender(); + + if (process.env.NODE_ENV !== 'production') { + expect(consoleSpy.calls.error.length).toEqual(1); + expect(consoleSpy.calls.error[0][0].message).toContain( + "Detected mutation of property 'mouseover' in the object passed to lwc:on for