Skip to content

on-driven signals excluded from client_to_server comm plan due to unsanitized server copy #586

Description

@jonmmease

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:

  1. supported(): value present → Supported
  2. Server copy: bind stripped → only value remains
  3. server_updates: no init/update/bind/onnot in server_updates
  4. client_to_server = {sel} ∩ {sel} - {} = {sel}

on signal:

  1. supported() (signal.rs:40): "value": nullNull variant → is_missing() returns false → Supported
  2. Server copy: on NOT stripped → value + on handlers remain
  3. server_updates: non-empty onin server_updates
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions