[LiveComponent] Add conditional expressions to #[LiveListener] event names - #3793
[LiveComponent] Add conditional expressions to #[LiveListener] event names#3793casskir wants to merge 7 commits into
Conversation
📊 Packages dist files size differenceThanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
|
||||||||||||
The client-side LiveListener condition support (e01af26) added jexl and @types/jexl to LiveComponent's package.json but did not update pnpm-lock.yaml, breaking CI's frozen-lockfile install. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks for the PR @casskir! I think I understand the problem you're trying to solve: we can currently target a component name, a parent, ourselves, etc., but not a particular instance, or subset of instances, based on its current state/props. I can definitely see the limitation here. If a page contains many instances of the same component listening to the same event, we currently don't have a way to prevent unrelated instances from reacting before the request is made. I'm less convinced about solving this by introducing conditional expressions directly into I'm also not sure we want LiveComponent events to become a way to broadcast application/domain events and then let each component decide, based on its current props, whether it should react. To me, LiveComponent events are primarily about UI/component communication. If a specific product changed on the server, I would rather have the response explicitly update or refresh the relevant UI, for example through a Turbo Stream, than broadcast a productUpdated event to every mounted component. This is also why I'm a bit uncomfortable with So I can see the performance issue described by the PR, but I'm not yet convinced that conditional listeners are the right abstraction to solve it. |
There was a problem hiding this comment.
Pull request overview
Adds optional client-side conditional expressions to #[LiveListener] event names in the Symfony UX LiveComponent package, allowing components to ignore irrelevant events before any Ajax request is made (reducing unnecessary re-renders and server work).
Changes:
- Extend
LiveListenerto parseeventName(condition)and exposegetCondition(): ?string; propagate the condition throughAsLiveComponent::liveListeners(). - Implement client-side condition evaluation (Jexl) before queueing listener actions; add
ValueStore::getCurrentProps()to evaluate against locally modified props. - Update debug tooling + docs + tests + dist build artifacts (including bundling Jexl into
dist/live_controller.js).
Reviewed changes
Copilot reviewed 14 out of 18 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/LiveComponent/tests/Unit/Attribute/LiveListenerTest.php | Adds unit coverage for parsing event name + optional condition syntax. |
| src/LiveComponent/tests/Unit/Attribute/AsLiveComponentTest.php | Updates listener metadata assertions and adds a condition-specific test case. |
| src/LiveComponent/tests/Integration/LivePropInheritanceTest.php | Updates integration assertion to include the new condition key. |
| src/LiveComponent/src/Command/LiveComponentDebugCommand.php | Displays listener conditions in debug:live-component output. |
| src/LiveComponent/src/Attribute/LiveListener.php | Parses eventName(condition) and exposes condition to consumers. |
| src/LiveComponent/src/Attribute/AsLiveComponent.php | Includes condition in the normalized listeners metadata array. |
| src/LiveComponent/doc/index.rst | Documents “Conditional Listeners” and the event/props variables. |
| src/LiveComponent/assets/test/unit/ValueStore.test.ts | Adds tests for new getCurrentProps() behavior. |
| src/LiveComponent/assets/test/unit/controller/listener-condition.test.ts | Adds unit tests verifying condition pass/fail and malformed-condition behavior. |
| src/LiveComponent/assets/src/live_controller.ts | Updates Stimulus values typing to include condition. |
| src/LiveComponent/assets/src/listener_condition.ts | Introduces Jexl-based condition evaluation helper. |
| src/LiveComponent/assets/src/Component/ValueStore.ts | Adds getCurrentProps() to merge original + pending + dirty props for evaluation. |
| src/LiveComponent/assets/src/Component/index.ts | Evaluates conditions before queuing listener actions. |
| src/LiveComponent/assets/package.json | Adds jexl and @types/jexl dependencies. |
| src/LiveComponent/assets/dist/live_controller.js | Rebuilt bundle with inlined Jexl + condition evaluation + new ValueStore method. |
| src/LiveComponent/assets/dist/live_controller.d.ts | Updates public typings for listeners + ValueStore. |
| pnpm-lock.yaml | Updates lockfile for new dependencies and transitive resolution changes. |
| bin/build_package.ts | Ensures jexl and @babel/runtime are inlined into the single-file dist bundle. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const listeners = this.listeners.get(name) || []; | ||
| listeners.forEach(({ action, condition }) => { | ||
| if (condition && !evaluateListenerCondition(condition, data, this.valueStore.getCurrentProps())) { | ||
| return; | ||
| } | ||
|
|
||
| // debounce slightly to allow for multiple actions to queue | ||
| this.action(action, data, 1); | ||
| }); |
| import { Jexl } from 'jexl'; | ||
|
|
||
| const jexl = new Jexl(); | ||
|
|
||
| /** | ||
| * Evaluates the condition of a #[LiveListener] on the client. | ||
| * | ||
| * The expression has access to two variables: | ||
| * - "event": the data that was emitted along with the event | ||
| * - "props": the current props of the component that declares the listener | ||
| * | ||
| * For example, a LiveListener declared as: | ||
| * | ||
| * #[LiveListener('product_updated(event.id == props.product)')] | ||
| * | ||
| * will only trigger its action if, on the client, the emitted event's "id" | ||
| * matches this component's "product" prop. | ||
| * | ||
| * A malformed expression is treated as "false" (the listener is skipped) | ||
| * so a typo in a condition can never accidentally trigger every listener, | ||
| * and the error is logged to help debugging. | ||
| */ | ||
| export function evaluateListenerCondition(condition: string, eventData: any, props: any): boolean { | ||
| try { | ||
| return !!jexl.evalSync(condition, { event: eventData, props }); | ||
| } catch (error) { | ||
| console.error(`LiveComponent: could not evaluate LiveListener condition "${condition}".`, error); | ||
|
|
||
| return false; | ||
| } | ||
| } |
| * The event name can be followed by a condition, in parentheses, using the | ||
| * ExpressionLanguage-like syntax evaluated entirely on the client (it never | ||
| * triggers an Ajax call by itself): |
…ndition evaluation.
Thanks for the detailed feedback! I don't think this solution moves away from the idea of UI/component communication — it just lets each component decide for itself how to react to an event, regardless of whether that event is tied to a specific instance or to particular data. That decision (react or ignore) is arguably still a UI-level concern, even if the condition happens to be based on props coming from the server. A concrete example: imagine a table listing requests/tickets with their statuses. In an outer component we change the status of a single request, but as a result we're forced to re-render every row individually (or the whole table at once). This can easily produce 10+ requests to the server whose response we don't actually care about, since only one row's status changed. And even if the row component itself needs to react somehow (e.g. highlight the updated record), the server-side code we'd write to filter out irrelevant instances is exactly the same logic — we're just moving that same condition to the client instead of duplicating it in PHP. I agree a Turbo Stream-based solution is possible, but it requires extra code for every such pattern: either re-rendering and swapping the whole component in the DOM, or replacing specific inner elements, or some other bespoke wiring. In practice this means writing custom glue code for what is a fairly simple problem, which increases the codebase, the chance of bugs, and the maintenance cost. With conditional listeners, this works "out of the box," since the component's standard re-render mechanism already solves it. So while I understand the concern about blurring server/client boundaries, I'd frame it differently: the condition isn't really evaluating server state on the client — it's the component deciding, based on its own current props, whether an already-broadcast UI event is relevant to it. That's a pattern that's already implicit in x-target/name-based listeners, just applied at a finer granularity. |
But as soon you're involving Symfony or Twig it's not an "broadcast UI event" anymore, but a server-side transmitted one.
Asking again because I need an example to figure things out: Is this like some sass-type UI we're talking about, and you want to do something on the list part because you just changed it on the right part (details/form) ? Or is there a scenario i'm still not getting ? If this is the case, you have a live component per row that right ? Just asking because any new type of behaviour must be very explicit on the why / when / how, as we will need to document, support it, maintain it, etc etc.. and I want to be sure it's aligned with the overall scope of Live Components. |
Today, a #[LiveListener] matches purely by event name: as soon as any component on the page emits an event, every component listening for that name fires an Ajax call and re-renders — even when the event has nothing to do with that particular component instance.
A common example: a page renders a list of "product card" components, each listening to productUpdated. When one product changes, the event is emitted once, but every card on the page reacts, each making its own Ajax round-trip and re-render, even though only one of them is actually concerned.
Filtering this out today means writing the check by hand inside the listener method and returning early — but by then the Ajax call has already been made and the server has already done the (wasted) work of building a response.
Solution
The event name passed to #[LiveListener] can now be followed by a condition in parentheses, written as a small expression:
The condition is evaluated entirely on the client, before any Ajax call is made:
If the condition doesn't pass, the listener is skipped and no request is sent to the server at all — the server-side render cycle is completely untouched by this feature. If the condition itself is invalid (e.g. a typo), it's treated as false (not "always true") and an error is logged to the browser console, so a bad expression can't accidentally make a listener fire on everything.
Conditions are written using Jexl syntax (comparisons, &&/||, nested property access, function calls...). The library is bundled directly into dist/live_controller.js, exactly like idiomorph already is - no extra install step or importmap entry is required from users of the package.
Plain #[LiveListener('eventName')] usages are completely unaffected — the condition is optional and fully backward compatible.
Changes:
Tests: