Skip to content

Commit 4e03fa7

Browse files
divmaingaurav-rk9wjhsf
authored
feat: add lwc:on directive (#5344)
* test(integration-karma): add integration tests for lwc:on directive * feat(engine-core): add optional dynamicOn property to VNodeData interface * feat(engine-core): add patching utilities for dynamic event listeners * feat(engine-core): update hydration and rendering to patch dynamic event listeners * feat(shared): export Detached Object.prototype.propertyIsEnumerable from language.ts * fix(engine-core): use propertyIsEnumerable from @lwc/shared * feat(template-compiler): add type and helper functions for OnDirective AST node * feat: throw error when lwc:on is used on a non-root <template> element * feat: add parsing of lwc:on directive * feat: throw error when declarative event listeners are used alongside lwc:on * feat(template-compiler): add code generation for lwc:on directive * feat(engine-core): add basic implementation of freeze render API * docs(engine-core): add comment in patchDynamicEventListeners regarding freezing assumption * Apply suggestions from code review Add comments, improve readability of statements, correct copyright year Co-authored-by: Dale Bustad <[email protected]> Co-authored-by: Will Harney <[email protected]> * test(integration-karma): fix ignored properties and event type case tests * test(integration-karma): add tests to verify re-rendering behaviour * test(integration-karma): add a seperate test for object passed as public property add a seperate test for object passed as public property and refactors other tests to be independent * fix: fix @api issues by removing freeze api and adding copy api * style: apply suggestion from code review - consistent error names * test(integration-karma): add missing test component x/publicProp * style(engine-core): rename variables related to patching and add comments for better readability * fix(engine-core): add missing negation operator in patchDynamicEventListener * docs(template-compiler): add comment explaining the check used to know lwc:on is used with onfoo * docs(template-compiler): add comment explaining onDirective codegen in databag * fix(engine-core): change prevInp to prevOut in comparision * test(integration-karma): expand re-render tests to include the case when object is mutated * test(template-compiler): add snapshot tests for template-compiler * test(integration-karma): move catchUnhandledRejectionsAndError inside the specific describe Having in top-level may cause false success * test(integration-karma): add re-render tests for lwc:on used inside for:each loop * test(integration-karma): correct 'this' in rerenderLoop.js * fix: move copy api's work to codeGen and patching * refactor: remove dead code * fix: Apply suggestions from code review - <> in error message Co-authored-by: Will Harney <[email protected]> * feat: add template-compiler flag enableLwcOn * test(integration-karma): modify test to include <> in error message * test(integration-karma): remove console log spy * perf(engine-core): change delete obj[x] to obj[x] = undefined * test(integration-karma): simplify tests by re-using spies * chore: increase allowed bundlesize limit * test(template-compiler): fix error codes in snapshots * fix: change throw new Error to logError * docs(engine-core): add comments explaining dynamicOn has null proto and own enumerbale props * test(integration-karma): add tests for objects with computed key * fix(engine-core): modify patch logic to differentiate prop value = undefined and prop not existing * test(integration-karma): add tests where arg has property with value non-function/ whose eval throws * test(integration-karma): simplify value evaluation throws tests * chore: bump CI * fix: integration tests in Firefox --------- Co-authored-by: Gaurav Kochar <[email protected]> Co-authored-by: Will Harney <[email protected]>
1 parent 5102ebc commit 4e03fa7

File tree

87 files changed

+2183
-32
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

87 files changed

+2183
-32
lines changed

packages/@lwc/compiler/src/options.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ export interface TransformOptions {
125125
customRendererConfig?: CustomRendererConfig;
126126
/** @deprecated Ignored by compiler. `lwc:spread` is always enabled. */
127127
enableLwcSpread?: boolean;
128+
/** Flag to enable usage of dynamic event listeners (lwc:on) directive in HTML template */
129+
enableLwcOn?: boolean;
128130
/** Set to true if synthetic shadow DOM support is not needed, which can result in smaller/faster output. */
129131
disableSyntheticShadowSupport?: boolean;
130132
/**
@@ -148,6 +150,7 @@ type OptionalTransformKeys =
148150
| 'scopedStyles'
149151
| 'customRendererConfig'
150152
| 'enableLwcSpread'
153+
| 'enableLwcOn'
151154
| 'enableLightningWebSecurityTransforms'
152155
| 'enableDynamicComponents'
153156
| 'experimentalDynamicDirective'

packages/@lwc/compiler/src/transformers/template.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export default function templateTransform(
4040
customRendererConfig,
4141
enableDynamicComponents,
4242
experimentalDynamicDirective: deprecatedDynamicDirective,
43+
enableLwcOn,
4344
instrumentation,
4445
namespace,
4546
name,
@@ -61,6 +62,7 @@ export default function templateTransform(
6162
enableStaticContentOptimization,
6263
customRendererConfig,
6364
enableDynamicComponents,
65+
enableLwcOn,
6466
instrumentation,
6567
apiVersion,
6668
disableSyntheticShadowSupport,

packages/@lwc/engine-core/src/framework/hydration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { VNodeType, isVStaticPartElement } from './vnodes';
5050

5151
import { patchProps } from './modules/props';
5252
import { applyEventListeners } from './modules/events';
53+
import { patchDynamicEventListeners } from './modules/dynamic-events';
5354
import { hydrateStaticParts, traverseAndSetElements } from './modules/static-parts';
5455
import { getScopeTokenClass } from './stylesheet';
5556
import { renderComponent } from './component';
@@ -522,6 +523,7 @@ function handleMismatch(node: Node, vnode: VNode, renderer: RendererAPI): Node |
522523

523524
function patchElementPropsAndAttrsAndRefs(vnode: VBaseElement, renderer: RendererAPI) {
524525
applyEventListeners(vnode, renderer);
526+
patchDynamicEventListeners(null, vnode, renderer, vnode.owner);
525527
patchProps(null, vnode, renderer);
526528
// The `refs` object is blown away in every re-render, so we always need to re-apply them
527529
applyRefs(vnode, vnode.owner);
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: MIT
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
6+
*/
7+
import { isUndefined } from '@lwc/shared';
8+
import { EmptyObject } from '../utils';
9+
import { invokeEventListener } from '../invoker';
10+
import { logError } from '../../shared/logger';
11+
import type { VM } from '../vm';
12+
import type { VBaseElement } from '../vnodes';
13+
import type { RendererAPI } from '../renderer';
14+
15+
export function patchDynamicEventListeners(
16+
oldVnode: VBaseElement | null,
17+
vnode: VBaseElement,
18+
renderer: RendererAPI,
19+
owner: VM
20+
) {
21+
const {
22+
elm,
23+
data: { dynamicOn, dynamicOnRaw },
24+
sel,
25+
} = vnode;
26+
27+
// dynamicOn : A cloned version of the object passed to lwc:on, with null prototype and only its own enumerable properties.
28+
const oldDynamicOn = oldVnode?.data?.dynamicOn ?? EmptyObject;
29+
const newDynamicOn = dynamicOn ?? EmptyObject;
30+
31+
// dynamicOnRaw : object passed to lwc:on
32+
// Compare dynamicOnRaw to check if same object is passed to lwc:on
33+
const isObjectSame = oldVnode?.data?.dynamicOnRaw === dynamicOnRaw;
34+
35+
const { addEventListener, removeEventListener } = renderer;
36+
const attachedEventListeners = getAttachedEventListeners(owner, elm!);
37+
38+
// Properties that are present in 'oldDynamicOn' but not in 'newDynamicOn'
39+
for (const eventType in oldDynamicOn) {
40+
if (!(eventType in newDynamicOn)) {
41+
// log error if same object is passed
42+
if (isObjectSame && process.env.NODE_ENV !== 'production') {
43+
logError(
44+
`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.`,
45+
owner
46+
);
47+
}
48+
49+
// Remove listeners that were attached previously but don't have a corresponding property in `newDynamicOn`
50+
const attachedEventListener = attachedEventListeners[eventType];
51+
removeEventListener(elm, eventType, attachedEventListener!);
52+
attachedEventListeners[eventType] = undefined;
53+
}
54+
}
55+
56+
// Ensure that the event listeners that are attached match what is present in `newDynamicOn`
57+
for (const eventType in newDynamicOn) {
58+
const typeExistsInOld = eventType in oldDynamicOn;
59+
const newCallback = newDynamicOn[eventType];
60+
61+
// Skip if callback hasn't changed
62+
if (typeExistsInOld && oldDynamicOn[eventType] === newCallback) {
63+
continue;
64+
}
65+
66+
// log error if same object is passed
67+
if (isObjectSame && process.env.NODE_ENV !== 'production') {
68+
logError(
69+
`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.`,
70+
owner
71+
);
72+
}
73+
74+
// Remove listener that was attached previously
75+
if (typeExistsInOld) {
76+
const attachedEventListener = attachedEventListeners[eventType];
77+
removeEventListener(elm, eventType, attachedEventListener!);
78+
}
79+
80+
// Bind new callback to owner component and add it as listener to element
81+
const newBoundEventListener = bindEventListener(owner, newCallback);
82+
addEventListener(elm, eventType, newBoundEventListener);
83+
84+
// Store the newly added eventListener
85+
attachedEventListeners[eventType] = newBoundEventListener;
86+
}
87+
}
88+
89+
function getAttachedEventListeners(
90+
vm: VM,
91+
elm: Element
92+
): Record<string, EventListener | undefined> {
93+
let attachedEventListeners = vm.attachedEventListeners.get(elm);
94+
if (isUndefined(attachedEventListeners)) {
95+
attachedEventListeners = {};
96+
vm.attachedEventListeners.set(elm, attachedEventListeners);
97+
}
98+
return attachedEventListeners;
99+
}
100+
101+
function bindEventListener(vm: VM, fn: EventListener): EventListener {
102+
return function (event: Event) {
103+
invokeEventListener(vm, fn, vm.component, event);
104+
};
105+
}

packages/@lwc/engine-core/src/framework/rendering.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { patchProps } from './modules/props';
5353
import { patchClassAttribute } from './modules/computed-class-attr';
5454
import { patchStyleAttribute } from './modules/computed-style-attr';
5555
import { applyEventListeners } from './modules/events';
56+
import { patchDynamicEventListeners } from './modules/dynamic-events';
5657
import { applyStaticClassAttribute } from './modules/static-class-attr';
5758
import { applyStaticStyleAttribute } from './modules/static-style-attr';
5859
import { applyRefs } from './modules/refs';
@@ -586,6 +587,7 @@ function patchElementPropsAndAttrsAndRefs(
586587
}
587588

588589
const { owner } = vnode;
590+
patchDynamicEventListeners(oldVnode, vnode, renderer, owner);
589591
// Attrs need to be applied to element before props IE11 will wipe out value on radio inputs if
590592
// value is set before type=radio.
591593
patchClassAttribute(oldVnode, vnode, renderer);

packages/@lwc/engine-core/src/framework/vm.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ export interface VM<N = HostNode, E = HostElement> {
135135
readonly owner: VM<N, E> | null;
136136
/** References to elements rendered using lwc:ref (template refs) */
137137
refVNodes: RefVNodes | null;
138+
/** event listeners added to elements corresponding to functions provided by lwc:on */
139+
attachedEventListeners: WeakMap<Element, Record<string, EventListener | undefined>>;
138140
/** Whether or not the VM was hydrated */
139141
readonly hydrated: boolean;
140142
/** Rendering operations associated with the VM */
@@ -344,6 +346,7 @@ export function createVM<HostNode, HostElement>(
344346
mode,
345347
owner,
346348
refVNodes: null,
349+
attachedEventListeners: new WeakMap(),
347350
children: EmptyArray,
348351
aChildren: EmptyArray,
349352
velements: EmptyArray,

packages/@lwc/engine-core/src/framework/vnodes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ export interface VNodeData {
153153
readonly styleDecls?: ReadonlyArray<[string, string, boolean]>;
154154
readonly context?: Readonly<Record<string, Readonly<Record<string, any>>>>;
155155
readonly on?: Readonly<Record<string, (event: Event) => any>>;
156+
readonly dynamicOn?: Readonly<Record<string, (event: Event) => any>>; // clone of object passed to lwc:on, used to patch event listeners
157+
readonly dynamicOnRaw?: Readonly<Record<string, (event: Event) => any>>; // object passed to lwc:on, used to verify whether object reference has changed
156158
readonly svg?: boolean;
157159
readonly renderer?: RendererAPI;
158160
}

packages/@lwc/errors/src/compiler/error-info/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
66
*/
77
/**
8-
* Next error code: 1203
8+
* Next error code: 1207
99
*/
1010

1111
export * from './compiler';

packages/@lwc/errors/src/compiler/error-info/template-transform.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,4 +947,36 @@ export const ParserDiagnostics = {
947947
level: DiagnosticLevel.Warning,
948948
url: '',
949949
},
950+
951+
INVALID_LWC_ON_ELEMENT: {
952+
code: 1203,
953+
message:
954+
'Invalid lwc:on usage on element "{0}". The directive can\'t be used on a template element.',
955+
level: DiagnosticLevel.Error,
956+
url: '',
957+
},
958+
959+
INVALID_LWC_ON_LITERAL_PROP: {
960+
code: 1204,
961+
message:
962+
'Invalid lwc:on usage on element "{0}". The directive binding must be an expression.',
963+
level: DiagnosticLevel.Error,
964+
url: '',
965+
},
966+
967+
INVALID_LWC_ON_WITH_DECLARATIVE_LISTENERS: {
968+
code: 1205,
969+
message:
970+
'Invalid lwc:on usage on element "{0}". It is not permitted to use declarative event listeners alongside lwc:on',
971+
level: DiagnosticLevel.Error,
972+
url: '',
973+
},
974+
975+
INVALID_LWC_ON_OPTS: {
976+
code: 1206,
977+
message:
978+
'Invalid lwc:on usage. The `lwc:on` directive must be enabled in order to use this feature.',
979+
level: DiagnosticLevel.Error,
980+
url: '',
981+
},
950982
};

packages/@lwc/integration-karma/scripts/karma-plugins/lwc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ function createPreprocessor(config, emitter, logger) {
5858
strict: true,
5959
},
6060
enableDynamicComponents: true,
61+
enableLwcOn: true,
6162
experimentalComplexExpressions,
6263
enableStaticContentOptimization: !DISABLE_STATIC_CONTENT_OPTIMIZATION,
6364
disableSyntheticShadowSupport: DISABLE_SYNTHETIC_SHADOW_SUPPORT_IN_COMPILER,

0 commit comments

Comments
 (0)