Summary
When a Vega signal uses on event handlers (e.g., click-driven selection) and a server-side dataset depends on that signal via a filter expression, the comm plan does not include the signal in client_to_server. The identical pattern using bind instead of on IS correctly included.
This prevents interactive patterns where clicking a mark should filter a server-side dataset — the filtered dataset is permanently frozen at its initial value.
Reproduction
bind case — works correctly
{
"signals": [
{"name": "sel", "value": "A",
"bind": {"input": "select", "options": ["A", "B", "C"]}}
],
"data": [
{"name": "source", "values": [
{"category": "A"}, {"category": "B"}, {"category": "C"}
]},
{"name": "filtered", "source": "source",
"transform": [{"type": "filter", "expr": "datum.category === sel"}]}
]
}
Result: comm_plan.client_to_server contains sel. BrokenInteractivity warning fires in pre-transform mode (expected).
on case — broken
{
"signals": [
{"name": "sel", "value": null,
"on": [{"events": "click", "update": "'A'"}]}
],
"data": [
{"name": "source", "values": [
{"category": "A"}, {"category": "B"}, {"category": "C"}
]},
{"name": "filtered", "source": "source",
"transform": [{"type": "filter", "expr": "sel != null && datum.category === sel"}]}
]
}
Result: comm_plan.client_to_server is empty. No BrokenInteractivity warning. The filtered dataset is frozen at its initial empty state.
Root Cause
The client_to_server set is computed in stitch_specs (stitch.rs:52-62):
client_to_server = (server_inputs ∩ client_updates) - server_updates
The bug is in extract_server_data (extract.rs:179-180):
let mut server_signal = signal.clone();
server_signal.bind = None; // bind stripped from server copy
// on is NOT stripped — server copy retains on handlers
When extracting a signal to the server spec, bind is stripped but on is not.
UpdateVarsChartVisitor (visitors.rs:352-358) classifies a signal as an "update variable" if it has any of init, update, bind, or on. Since the server copy retains the on handlers, the signal appears in server_updates. The - server_updates subtraction then removes it from client_to_server.
For bind signals, the server copy has bind = None, so it does NOT appear in server_updates, and the signal correctly remains in client_to_server.
Walk-through
bind signal:
supported(): value present → Supported
- Server copy:
bind stripped → only value remains
server_updates: no init/update/bind/on → not in server_updates
client_to_server = {sel} ∩ {sel} - {} = {sel} ✅
on signal:
supported() (signal.rs:40): "value": null → Null variant → is_missing() returns false → Supported
- Server copy:
on NOT stripped → value + on handlers remain
server_updates: non-empty on → in server_updates
client_to_server = {sel} ∩ {sel} - {sel} = {} ❌
Proposed Fix
Strip on handlers from the server copy in extract.rs, the same way bind is already stripped:
let mut server_signal = signal.clone();
server_signal.bind = None;
server_signal.on = Vec::new(); // <-- ADD THIS
The on handlers reference DOM events (click, mouseover, etc.) which cannot fire on the server. The server signal's role is to receive updated values via the comm channel, not to compute them from events. This is exactly how bind signals already work after stripping.
Additional Context
There is a separate, secondary issue for on signals that have no value field at all (only update + on). These fail the supported() check (signal.rs:43) because of the self.on.is_empty() guard and are never extracted to the server. This is a different failure mode that would require changes to supported() itself. The fix above addresses the common case where the signal has value (including "value": null).
This issue was authored by Claude Code, reviewed and confirmed by @jonmmease prior to submission.
Summary
When a Vega signal uses
onevent handlers (e.g., click-driven selection) and a server-side dataset depends on that signal via a filter expression, the comm plan does not include the signal inclient_to_server. The identical pattern usingbindinstead ofonIS correctly included.This prevents interactive patterns where clicking a mark should filter a server-side dataset — the
filtereddataset is permanently frozen at its initial value.Reproduction
bindcase — works correctly{ "signals": [ {"name": "sel", "value": "A", "bind": {"input": "select", "options": ["A", "B", "C"]}} ], "data": [ {"name": "source", "values": [ {"category": "A"}, {"category": "B"}, {"category": "C"} ]}, {"name": "filtered", "source": "source", "transform": [{"type": "filter", "expr": "datum.category === sel"}]} ] }Result:
comm_plan.client_to_servercontainssel.BrokenInteractivitywarning fires in pre-transform mode (expected).oncase — broken{ "signals": [ {"name": "sel", "value": null, "on": [{"events": "click", "update": "'A'"}]} ], "data": [ {"name": "source", "values": [ {"category": "A"}, {"category": "B"}, {"category": "C"} ]}, {"name": "filtered", "source": "source", "transform": [{"type": "filter", "expr": "sel != null && datum.category === sel"}]} ] }Result:
comm_plan.client_to_serveris empty. NoBrokenInteractivitywarning. Thefiltereddataset is frozen at its initial empty state.Root Cause
The
client_to_serverset is computed institch_specs(stitch.rs:52-62):The bug is in
extract_server_data(extract.rs:179-180):When extracting a signal to the server spec,
bindis stripped butonis not.UpdateVarsChartVisitor(visitors.rs:352-358) classifies a signal as an "update variable" if it has any ofinit,update,bind, oron. Since the server copy retains theonhandlers, the signal appears inserver_updates. The- server_updatessubtraction then removes it fromclient_to_server.For
bindsignals, the server copy hasbind = None, so it does NOT appear inserver_updates, and the signal correctly remains inclient_to_server.Walk-through
bindsignal:supported():valuepresent → Supportedbindstripped → onlyvalueremainsserver_updates: noinit/update/bind/on→ not inserver_updatesclient_to_server = {sel} ∩ {sel} - {} = {sel}✅onsignal:supported()(signal.rs:40):"value": null→Nullvariant →is_missing()returnsfalse→ SupportedonNOT stripped →value+onhandlers remainserver_updates: non-emptyon→ inserver_updatesclient_to_server = {sel} ∩ {sel} - {sel} = {}❌Proposed Fix
Strip
onhandlers from the server copy inextract.rs, the same waybindis already stripped:The
onhandlers reference DOM events (click, mouseover, etc.) which cannot fire on the server. The server signal's role is to receive updated values via the comm channel, not to compute them from events. This is exactly howbindsignals already work after stripping.Additional Context
There is a separate, secondary issue for
onsignals that have novaluefield at all (onlyupdate+on). These fail thesupported()check (signal.rs:43) because of theself.on.is_empty()guard and are never extracted to the server. This is a different failure mode that would require changes tosupported()itself. The fix above addresses the common case where the signal hasvalue(including"value": null).This issue was authored by Claude Code, reviewed and confirmed by @jonmmease prior to submission.