Skip to content

[LiveComponent] Add conditional expressions to #[LiveListener] event names - #3793

Open
casskir wants to merge 7 commits into
symfony:3.xfrom
casskir:3.x
Open

[LiveComponent] Add conditional expressions to #[LiveListener] event names#3793
casskir wants to merge 7 commits into
symfony:3.xfrom
casskir:3.x

Conversation

@casskir

@casskir casskir commented Aug 18, 2026

Copy link
Copy Markdown
Q A
Bug fix? no
New feature? yes
Deprecations? no
Documentation? yes
Issues none — no existing issue, explained below
License MIT

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:

use Symfony\UX\LiveComponent\Attribute\LiveListener;

#[LiveProp]
public int $product;

#[LiveListener('productUpdated(event.id == props.product)')]
public function refreshProduct() 
{
    // only called when the emitted event's "id" matches this component's "product" prop                                                                                                                    
}

The condition is evaluated entirely on the client, before any Ajax call is made:

  • event — the data emitted along with the event ($this->emit('productUpdated', ['id' => $product->getId()])).
  • props — the current props of this component (including local changes not yet confirmed by the server).

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:

  • Symfony\UX\LiveComponent\Attribute\LiveListener: parses eventName(condition), exposes getCondition(): ?string.
  • AsLiveComponent::liveListeners(): now also returns the condition for each listener.
  • debug:live-component: displays the condition (event if (condition) => action) when one is set.
  • Client (assets/src): Component/ValueStore evaluate the condition against { event, props } via jexl before queuing the action; ValueStore::getCurrentProps() was added to expose the effective (server + local dirty) props for that evaluation.
  • bin/build_package.ts (repo root, shared build tooling): added jexl and its transitive @babel/runtime helpers to deps.onlyBundle, next to idiomorph, so the library gets inlined into the single-file bundle instead of left as an unresolved import.

Tests:

  • tests/Unit/Attribute/LiveListenerTest.php — parsing of the event-name/condition syntax (plain name, condition, nested parentheses, whitespace, empty parentheses, invalid syntax).
  • tests/Unit/Attribute/AsLiveComponentTest.php — updated/extended for the new condition key.
  • assets/test/unit/controller/listener-condition.test.ts — condition passing/failing, evaluated against locally-changed props, malformed condition never firing.
  • assets/test/unit/ValueStore.test.ts — new getCurrentProps() coverage.
  • dist/ was rebuilt and smoke-tested (imported directly under jsdom, confirmed the compiled bundle correctly skips/fires the action based on the condition).

@carsonbot carsonbot added Documentation Improvements or additions to documentation Feature New Feature LiveComponent Status: Needs Review Needs to be reviewed labels Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

📊 Packages dist files size difference

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

FileBefore (Size / Gzip)After (Size / Gzip)
LiveComponent
live_controller.d.ts 7.72 kB / 2 kB 7.86 kB+2% 📈 / 2.03 kB+1% 📈
live_controller.js 84.57 kB / 18.9 kB 148.67 kB+76% 📈 / 30.67 kB+62% 📈

casskir and others added 5 commits August 18, 2026 18:10
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>
Comment thread bin/build_package.ts Outdated
@carsonbot carsonbot added Status: Needs Work Additional work is needed and removed Status: Needs Review Needs to be reviewed labels Aug 21, 2026
@Kocal
Kocal requested review from kbond and smnandre August 21, 2026 11:38
@carsonbot carsonbot added Status: Needs Review Needs to be reviewed and removed Status: Needs Work Additional work is needed labels Aug 21, 2026
@smnandre

Copy link
Copy Markdown
Member

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.

https://symfony.com/bundles/ux-live-component/current/index.html#emitting-only-to-components-with-a-specific-name

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 #[LiveListener], though.

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 event.id == props.product: the condition is declared in PHP, but evaluated against client-side state. It starts blurring the boundary between server state and UI state in a way I'm not sure we want to encourage.

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.

@smnandre smnandre added the Status: Waiting feedback Needs feedback from the author label Aug 26, 2026
@smnandre
smnandre requested a lite review from Copilot August 26, 2026 01:31
@carsonbot carsonbot removed the Status: Waiting feedback Needs feedback from the author label Aug 26, 2026

@smnandre smnandre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cf ma previous message

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 LiveListener to parse eventName(condition) and expose getCondition(): ?string; propagate the condition through AsLiveComponent::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.

Comment on lines +246 to 254
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);
});
Comment on lines +10 to +40
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;
}
}
Comment on lines +20 to +22
* 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):
@casskir

casskir commented Aug 27, 2026

Copy link
Copy Markdown
Author

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.

https://symfony.com/bundles/ux-live-component/current/index.html#emitting-only-to-components-with-a-specific-name

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 #[LiveListener], though.

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 event.id == props.product: the condition is declared in PHP, but evaluated against client-side state. It starts blurring the boundary between server state and UI state in a way I'm not sure we want to encourage.

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.

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.

@smnandre

Copy link
Copy Markdown
Member

an already-broadcast UI event

But as soon you're involving Symfony or Twig it's not an "broadcast UI event" anymore, but a server-side transmitted one.


a table listing requests/tickets with their statuses. In an outer component we change the status of a single reques

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 ?
And they do change (outside this side form) live ?
They have a state in the page ? Actions ? Data ?

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation Feature New Feature LiveComponent Status: Needs Review Needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants