Skip to content

feat(plotly): selected()/brushed() are state accessors, seeded on bridge bind - #419

Open
cpsievert wants to merge 10 commits into
feat/eager-state-accessorsfrom
spike/plotly-selection-state
Open

cpsievert wants to merge 10 commits into
feat/eager-state-accessorsfrom
spike/plotly-selection-state

Conversation

@cpsievert

Copy link
Copy Markdown
Collaborator

Stacked on #412.

Summary

A plotly selection is state on the graph div (trace.selectedpoints, plus the region on gd._fullLayout.selections), not just an event. The event bridge (js/src/plotly.ts) now rebuilds and sends the current selection when it binds plotly_selected — as a synthetic plotly_selected message (a no-data one when empty, the same message plotly.js sends for an empty selection; deliberately not plotly_deselect, which would wrongly clear clicked()).

That lets selected()/brushed() (and so selected_rows()) become kind="state" accessors: they attach at render via #412's mechanism, and a box/lasso selection made before the accessor's first read — for example, before a chat handler or LLM tool first reads it — is reported instead of silently lost. This was the original motivating failure behind #412, which #412 alone could not fix (these accessors were event-kind, and events can't seed).

clicked() stays kind="event"; so do selecting()/brushing() (drag streams) and relayout() (autoranged axes have no explicit range, so the empty state isn't seedable).

Trade-off: state accessors don't reset on re-render, so selected()/brushed() keep their value until the new plot reports its own (initially empty) selection, at which point they become None. Documented in the render_plotly docstring and CHANGELOG.

Evidence

  • plotly.js retains selectedpoints post-lasso and clears on deselect, verified empirically (Playwright-driven standalone pages) for scatter, scattergl, bar, and histogram; the state-rebuilt payload matches the real plotly_selected payload point-for-point (including customdata and histogram pointNumbers flattening; lasso → parsed lassoPoints; box → ascending range).
  • Driving the compiled bridge with a lasso before bind produces exactly one correct seed message; the seed is sent once per graph div (re-binds from other accessors don't re-seed).

Verification

  • New committed Playwright tests (tests/playwright/plotly_selection_seed/): box-select before a button-gated first read → selected()/selected_rows(df)/brushed() reflect it; double-click deselect clears to None. 2 passed × 4 runs, no flakes; existing plotly Playwright suites green (plotly_accessors, plotly_accessors_rerender, plotly_config, plotly_rerender_cleanup).
  • make check: ruff ✓ pyright ✓ unit tests 842 passed (the one test_geolibre failure is pre-existing on this base and already fixed on main by feat(geolibre): use geolibre's async command methods when available #360).

Note: plotly.js emits plotly_selected a few hundred ms after mouseup (held back for double-click disambiguation), so the fixture waits on a sentinel output for browser→server sync before triggering the gated read.

Related hygiene issues found during this investigation: #417, #418.

… state accessors

A plotly selection is state on the graph div (trace selectedpoints, plus
the region on the last layout selection), so when the event bridge binds
plotly_selected on a graph div it now also reports the current selection
as a synthetic plotly_selected message (a no-data one when nothing is
selected, the same message plotly.js sends for an empty selection).

That lets selected()/brushed() (and so selected_rows()) become state
accessors: they attach at render, and a box/lasso selection made before
the accessor's first read -- or before the bind request reaches the
browser -- is reported instead of silently lost. clicked() stays an
event. State accessors don't reset on re-render, so selected()/brushed()
now keep their value until the new plot reports its own (initially
empty) selection; documented in the render_plotly docstring.
…te accessors

Covers the selection-seed scenario end to end: a box selection made before
anything reads selected()/brushed() (gated behind an action button), then a
gated first read -- which must report the pre-read selection -- and a
deselect (double-click on the plot background) after the read, which must
clear the accessors to None.

plotly.js emits plotly_selected a few hundred ms after mouseup, so a button
click right after the drag can overtake the selection message; the fixture
follows the sentinel pattern (an ungated text output reading the same
accessor state, like ipyleaflet_eager_attach's synced_zoom) so the test
waits for the browser-to-server sync before triggering the gated read.

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.

🟡 Changes recommended

Selection seeding drops valid empty regions, misparses coordinates with negative exponents, and omits implicit coordinates, while the tests do not verify selection before bridge binding.

4 open issues
1 file not reviewed
  • shinywidgets/static/output.js: Generated file
What changed in this PR

Makes Plotly selection accessors state-based, building on #412’s eager attachment so selections made before the first accessor read can be recovered.

Changes:

  • Seeds the current selection and brush region when the browser bridge first binds.
  • Changes selected() and brushed() to state accessors and documents their re-render behavior.
  • Adds unit and browser tests for selection retention, gated reads, and deselection.
File Description
tests/​unit/​test_plotly_accessors.py Tests eager attachment, early selection messages, and re-render behavior.
tests/​playwright/​plotly_selection_seed/​test_plotly_selection_seed.py Adds gated-read and deselection browser tests.
tests/​playwright/​plotly_selection_seed/​app.py Adds the browser-test fixture and synchronization output.
shinywidgets/​static/​output.js Updates the bundled browser bridge.
shinywidgets/​_render_widget.py Documents selection state across re-renders.
shinywidgets/​_plotly.py Declares selection and brush accessors as state-based.
js/​src/​plotly.ts Reconstructs and sends selection state on initial binding.
CHANGELOG.md Records the accessor and selection-seeding changes.

Review effort: Balanced

Comment thread js/src/plotly.ts Resolved
Comment thread js/src/plotly.ts Resolved
Comment thread js/src/plotly.ts Resolved
…ure read

- Coordinates the trace doesn't carry explicitly (implicit x, bar x0/dx)
  fall back to calcdata, matching what live event point objects supply.
- An empty drag selection seeds points: [] plus the region, the same
  message plotly.js sends for one; only no-points-and-no-region remains
  the no-data case.
- The lasso vertex regex accepts signed exponents (1e-7), which it
  previously failed to match or misparsed.
- The fixture's ungated synced_selection read attached the accessor at
  startup, so the gated reads weren't genuinely late and the test could
  pass pre-change via live forwarding. Removed; tests now wait on
  graph-div selection state plus plotly.js's event-emission delay, and
  would fail pre-change.

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.

🟡 Changes recommended

Selection seeding can assign unrelated bin counts as histogram y values and drops valid typed-array selections, producing incorrect or missing selected-row data.

3 open issues
3 resolved since last review
1 file not reviewed
  • shinywidgets/static/output.js: Generated file

Review effort: Balanced

Comment thread js/src/plotly.ts Resolved
Comment thread js/src/plotly.ts Resolved
- A selection preconfigured from plotly.py arrives as a typed array,
  which Array.isArray rejected, dropping the seed; normalize with
  jsonable() first.
- Histogram selectedpoints are sample indices but histogram calcdata is
  bins, so the calcdata fallback seeded bin counts as y; the live event
  reads only the original arrays for aggregated traces, so skip the
  fallback for histograms to match.

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.

🟡 Changes recommended

Selection seeding misreports Scattergl coordinates, corrupts date-axis lasso vertices, and ignores subplot axis references, producing results inconsistent with live events.

4 open issues
2 resolved since last review
1 file not reviewed
  • shinywidgets/static/output.js: Generated file

Review effort: Balanced

Comment thread js/src/plotly.ts Resolved
Comment thread js/src/plotly.ts Resolved
Comment thread js/src/plotly.ts Resolved
- The calcdata coordinate fallback is restricted to verified layouts and
  moved into calcCoord(): scatter and bar align per data index; scattergl
  keeps resolved coords in typed arrays on the single calc entry's .t
  (its top-level x/y are false sentinels); other types are skipped, so
  unverified layouts omit keys rather than seed wrong values.
- Lasso vertices are parsed generically (M/L segments split on the
  comma), which also restores date-axis values: plotly stores them as
  date strings with '_' for the date/time separator, live lassoPoints
  use a space.
- The seeded region is keyed by the saved selection's xref/yref (x2/y2
  on later subplots), matching live range/lassoPoints keys.

Each verified against the compiled bridge: scattergl y-only seeds
x=0,1; a date-axis lasso seeds vertices identical to the live event;
a second-subplot selection seeds range keyed x2/y2 with the subplot
trace's points.

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.

🟡 Changes recommended

The selection-path regex includes the closing Z/z command in the final coordinate, corrupting regions returned by brushed(); the capture must be corrected and the bundled output regenerated.

2 open issues
3 resolved since last review
1 file not reviewed
  • shinywidgets/static/output.js: Generated file

Review effort: Balanced

Comment thread js/src/plotly.ts Resolved
A preconfigured closed path (M0,0L1,0L1,1Z) captured '1Z' as the final
coordinate, which Number() rejects and the date fallback returned as
the string '1Z'.

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.

🟡 Changes recommended

Existing selections report incorrect multicategory coordinates, and seeded date-axis brush coordinates retain SVG whitespace, requiring source fixes and bundle regeneration.

3 open issues
1 resolved since last review
1 file not reviewed
  • shinywidgets/static/output.js: Generated file

Review effort: Balanced

Comment thread js/src/plotly.ts Resolved
Comment thread js/src/plotly.ts Resolved
- Multicategory axes store one array per category level, so trace[key][i]
  is a level array, not the coordinate; reconstruct it level-wise
  (verified: seed x becomes ['A', 'two'], identical to the live event).
- Lasso vertex tokens are trimmed before conversion, so SVG formatting
  whitespace doesn't survive into date-valued region coordinates.

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.

🟡 Changes recommended

The multicategory selection logic skips points beyond the number of category levels, producing an incorrect coordinate; the source fix and regenerated output.js are still needed.

1 open issue
3 resolved since last review
1 file not reviewed
  • shinywidgets/static/output.js: Generated file

Review effort: Balanced

Comment thread js/src/plotly.ts Resolved
A multicategory trace array's length is the category level count, so for
a selected index beyond it trace[key][i] is undefined and the per-point
check fell through to the wrong path; detect array-of-arrays on the
trace attribute itself (verified: index 2 seeds ['B', 'one']).

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.

🟡 Changes recommended

The selection seed incorrectly treats selectedpoints=[] as absent, causing selected() to return None instead of an empty DataFrame; preserve the empty selection and regenerate the bundled output.

1 open issue
1 resolved since last review
1 file not reviewed
  • shinywidgets/static/output.js: Generated file

Review effort: Balanced

Comment thread js/src/plotly.ts Resolved
plotly treats selectedpoints: [] as an active empty selection (every
point gets unselected styling), distinct from an absent selectedpoints;
the seed now emits points: [] for it (empty DataFrame, matching the
empty-drag case) while a plot with no selection arrays anywhere still
seeds the no-data message (None).

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.

🟢 Approval recommended

No active review issues were identified for the changes preserving Plotly selections made before the first accessor read.

No open issues 🎉

1 resolved since last review
1 file not reviewed
  • shinywidgets/static/output.js: Generated file

Review effort: Balanced

@cpsievert
cpsievert marked this pull request as ready for review November 4, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants