Skip to content

feat: support parameters in event data and filter expressions - #25661

Open
totally-not-ai[bot] wants to merge 4 commits into
mainfrom
25658-event-data-expression-parameters
Open

feat: support parameters in event data and filter expressions#25661
totally-not-ai[bot] wants to merge 4 commits into
mainfrom
25658-event-data-expression-parameters

Conversation

@totally-not-ai

@totally-not-ai totally-not-ai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Event data and filter expressions can now take values as parameters instead of building the values into the JavaScript text. You write the expression once with $0, $1, … placeholders and pass the values separately, so the browser reuses one compiled function for all listeners, and you no longer have to escape values yourself.

What changed

Behavior change (client/server wire format): the event settings that the server sends and the event data that the browser sends back changed shape. Server-side APIs stay source compatible, but the server and the client must be upgraded together, and any code or test that reads the raw settings JSON or builds event data keyed by the JavaScript expression must be updated.

  • Event data is now reported under a short key derived from the expression and its captures, and the server maps it back to the registered name before listeners see it (ElementListenerMap.translateEventData). No JavaScript text travels from the browser back to the server any more (ModScurity blocks requests with enter #13834). Listener code is unaffected: addEventData("event.key") is still read as getEventData().get("event.key").
  • Event settings JSON changed from {expression: debounceSettings} to {key: {"e": expression, "d": debounceSettings, "c": captureCount}}. The keys are declared once in JsonConstants and shared by the Java (GWT) and TypeScript clients.
  • Capture values are sent per element next to the constant pool reference (ParameterizedConstantPoolKey, encoded as [constantId, captures]), so two elements that use the same expression with different values still share one constant pool entry.
  • Breaking for implementers: DomListenerRegistration gains two new abstract methods. Anyone implementing this interface outside Flow must implement them. The only implementation in Flow is internal.
  • Fix: event settings are no longer cached for event types that have no listeners. The event type comes from the client, so caching it let a client grow the cache without limit.
  • New warning: if several listeners for the same element and event type use the same event data name with different expressions or captures, only one value is available. This is now logged and documented.

Use case

You have a table of rows and want a click on a row to tell the server which row was clicked, without adding a state node per row. The row id sits in a data- attribute, and you only care about primary-button clicks.

Element table = tableComponent.getElement();

table.addEventListener("click", event -> {
    String rowId = event.getEventData().get("rowId").asString();
    selectRow(rowId);
})
    // "$0" is the capture; no escaping or string concatenation needed
    .addEventData("rowId", "event.target.closest($0).dataset.rowId", "tr[data-row-id]")
    .setFilter("event.button === $0", 0);

Every table instance uses the same compiled JavaScript function in the browser; only the selector and the button number are sent per element.

API Changes

com.vaadin.flow.dom.DomListenerRegistration

// Added
DomListenerRegistration addEventData(String name, String expression, Object... captures) // new abstract interface method
DomListenerRegistration setFilter(String filter, Object... captures) // new abstract interface method

com.vaadin.flow.internal.ParameterizedConstantPoolKey

// Added
public class ParameterizedConstantPoolKey implements Serializable // internal use only
public ParameterizedConstantPoolKey(ConstantPoolKey sharedValue, JsonNode parameters)
public ArrayNode encode(ConstantPool constantPool) // encodes as [constantId, parameters]

com.vaadin.flow.internal.nodefeature.ElementListenerMap

// Added
public JsonNode translateEventData(String eventType, JsonNode eventData) // maps client keys to server names
public static String getFilterKey(DomListenerRegistration registration) // provided for unit testing
public static String getEventDataKey(DomListenerRegistration registration, String name) // provided for unit testing

com.vaadin.flow.shared.JsonConstants

// Added
public static final String EVENT_SETTINGS_EXPRESSION = "e"
public static final String EVENT_SETTINGS_DEBOUNCE = "d"
public static final String EVENT_SETTINGS_CAPTURE_COUNT = "c"

Test summary

# Status What the test verifies Why it matters
1 A parameterized event data expression reaches listeners under the registered name, and the wire key contains neither the expression nor the capture Core feature, plus the guarantee that no JavaScript is echoed back to the server
2 addEventData(String) still delivers the value under the expression, even though the wire key differs Existing applications must keep working
3 Two listeners sharing one expression with different captures get separate keys, and only the listener whose filter matches fires Captures must not leak between listeners
4 The shared settings are identical for both elements (one constant pool entry) while the captures are sent per element The whole point of parameters: shared code, per-element data
5 preventDefault() combined with a capturing filter keeps the filter captures and the capture count Otherwise the combined expression would be evaluated without its values
6 getFilter() returns the expression with placeholders; setFilter(null) clears it and its key Contract of the new overload
7 null name or expression throws IllegalArgumentException Fail fast instead of sending broken settings
8 Two listeners using the same event data name end up sharing one value Pins the documented uniqueness limitation
9 An event type not known on the server is passed through and adds nothing to the settings cache The client picks the event type, so caching it would let a client grow memory without limit
10 ParameterizedConstantPoolKey serializes into a map put change as [constantId, parameters] Wire format contract with the client
11 Both clients evaluate a parameterized expression with its captures, report one value per capture set, debounce per capture set, and decode an element capture into a DOM node Client half of the protocol, in both the GWT and the TypeScript implementation
12 Adding many event data expressions hashes each expression once instead of re-hashing all of them per addition Guards the #7826 performance regression
13 End to end in a real browser: captured event data arrives, and a captured filter only triggers its own listener Catches breakage that unit tests on either side would miss
14 gap A capture value of an unsupported type fails when addEventData/setFilter is called The javadoc promises this fails immediately; nothing pins it
15 gap The warning for conflicting event data names is actually logged Row 8 checks the resulting value, not the log message developers rely on
  • ElementListenersTest.addEventDataWithCaptures_valueReportedUnderName → 1
  • ElementListenersTest.addEventDataWithoutCaptures_reportedUnderExpression → 2
  • ElementListenersTest.sameExpressionDifferentCaptures_separateValuesAndFilters → 3
  • ElementListenersTest.capturesAreSentOutsideTheConstantPool → 4
  • ElementListenersTest.preventDefaultWithCapturedFilter_keepsFilterCaptures → 5
  • ElementListenersTest.setFilterWithCaptures_getFilterReturnsExpression → 6
  • ElementListenersTest.nullNameOrExpression_throws → 7
  • ElementListenersTest.duplicateEventDataName_valueIsSharedByBothListeners → 8
  • ElementListenersTest.translateEventDataForUnknownEventType_nothingIsCached → 9
  • MapPutChangeTest.testParameterizedConstantPoolValueType → 10
  • GwtBasicElementBinderTest.testEventFiredWithCaptures → 11
  • SimpleElementBindingStrategyEventDataTests ("evaluates a parameterized expression with its captures", "reports one value per set of captures for the same expression", "debounces a parameterized filter per set of captures", "decodes an element capture into a DOM node") → 11
  • ComponentEventBusTest.addListener_eventDataExpressionsPresent_constantPoolKeyNotCreatedAfterEachExpression → 12
  • EventDataCapturesIT.capturedEventDataIsReportedUnderItsName, EventDataCapturesIT.capturedFilterOnlyMatchesItsOwnListener → 13
  • DomEventTest.assertSettings (helper, updated) — re-points the existing @DomEvent filter and debounce tests at the new settings shape

Left untested on purpose: the key derivation itself (only observed through the keys the tests use), and the updated javadoc and the @JsFunction-to-NativeFunction refactoring in the GWT client, which have no behaviour of their own.

Event data and filter expressions can take capture values instead of
having the values concatenated into the expression text. The client then
compiles one function per expression regardless of the values, and the
values are sent per element instead of through the constant pool.

The client identifies each evaluated expression by a key derived from
the expression and its captures, so two entries that share an expression
but use different captures stay apart and no JavaScript is sent back to
the server when an event occurs.

Part of #25658
The event type of an incoming event is defined by the client, so caching
settings for a type that has no listeners let the client grow the cache
without limit. Only the key format of the wire protocol is affected by
the other changes.

- Warn when several listeners for the same element and event type use
  the same event data name, since only one of the values is then
  available, and document the uniqueness requirement.
- Declare the keys of the event settings once in JsonConstants instead
  of separately in each of the two client implementations.
- Drop the IllegalArgumentException from the capture javadoc, since an
  unsupported capture type is reported by the encoder instead.

Part of #25658
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Test Results

 1 441 files  + 1   1 525 suites  +1   1h 30m 33s ⏱️ -59s
12 069 tests +17  12 001 ✅ +17  68 💤 ±0  0 ❌ ±0 
12 387 runs  +17  12 319 ✅ +17  68 💤 ±0  0 ❌ ±0 

Results for commit 89ea425. ± Comparison against base commit cc07280.

♻️ This comment has been updated with latest results.

Settings are cached whenever they are updated, so the branch that
recomputed them for an event type with listeners was unreachable. Assert
the invariant instead of keeping a fallback that cannot run.

Also cover the case of asking for an event data key from a registration
without event data.

Part of #25658
@totally-not-ai

Copy link
Copy Markdown
Contributor Author

About the SonarCloud quality gate on this PR: the remaining uncovered new
code is the GWT client engine
(flow-client/src/main/java/com/vaadin/client/flow/binding/SimpleElementBindingStrategy.java),
which JaCoCo does not measure at all — that file sits at 0.4 % coverage
(737 of 741 lines uncovered) on main
, because its tests are
GWTTestCase tests that run in HtmlUnit rather than in the JVM. Any change
to that file therefore counts as fully uncovered new code.

Those lines are tested, just not in a way Sonar can see:

  • GwtBasicElementBinderTest.testEventFiredWithCaptures covers the new
    captured-expression path in the GWT engine.
  • The same logic in the TypeScript engine is covered by four new cases in
    SimpleElementBindingStrategyEventDataTests.ts.

The server side is covered normally — after the latest commit
ElementListenerMap has no uncovered new lines, and EventRpcHandler,
JacksonCodec and ParameterizedConstantPoolKey are fully covered.

So the gate cannot be met by adding more tests; it needs either a waiver or
a decision that GWT-only code is exempt.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
71.7% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@Artur-
Artur- requested a review from Legioth September 11, 2026 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants