Skip to content

[feature] Add single-row-required selection mode to st.dataframe#14288

Merged
sfc-gh-lmasuch merged 20 commits intodevelopfrom
lukasmasuch/single-row-required
Mar 24, 2026
Merged

[feature] Add single-row-required selection mode to st.dataframe#14288
sfc-gh-lmasuch merged 20 commits intodevelopfrom
lukasmasuch/single-row-required

Conversation

@lukasmasuch
Copy link
Copy Markdown
Collaborator

@lukasmasuch lukasmasuch commented Mar 10, 2026

Describe your changes

Adds single-row-required selection mode to st.dataframe that ensures exactly one row is always selected (radio-like behavior):

  • Auto-selects the first row when no default is provided

  • Prevents clearing the selection (only allows changing to a different row)

  • Uses circle checkbox style for visual differentiation from square checkboxes

  • Hides the "Clear selection" toolbar button

  • Demo

  • Product spec

Open Questions:

  • Circular vs Checkbox (everything else requires changes in the underlying library):
image image

Github Issues

Testing Plan

  • Unit Tests (Python): lib/tests/streamlit/elements/arrow_dataframe_test.py
  • Unit Tests (TypeScript): useSelectionHandler.test.ts, useWidgetState.test.ts
  • E2E Tests: e2e_playwright/st_dataframe_selections_test.py

Contribution License Agreement

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

Agent metrics
Type Name Count
skill addressing-pr-review-comments 1
skill checking-changes 1
skill fixing-flaky-e2e-tests 1
skill updating-internal-docs 2
skill writing-product-specs 1
subagent Explore 1
subagent fixing-pr 5
subagent general-purpose 6
subagent qa-testing-feature 1
subagent reviewing-local-changes 2
subagent simplifying-local-changes 2

Adds a new selection mode that ensures exactly one row is always
selected (radio-like behavior). Auto-selects the first row when
no default is provided, prevents clearing, and uses circle checkbox
style for visual differentiation.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lukasmasuch lukasmasuch added change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users labels Mar 10, 2026
Copilot AI review requested due to automatic review settings March 10, 2026 09:09
@lukasmasuch lukasmasuch added the impact:users PR changes affect end users label Mar 10, 2026
@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Mar 10, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 10, 2026

✅ PR preview is ready!

Name Link
📦 Wheel file https://core-previews.s3-us-west-2.amazonaws.com/pr-14288/streamlit-1.55.0-py3-none-any.whl
📦 @streamlit/component-v2-lib Download from artifacts
🕹️ Preview app pr-14288.streamlit.app (☁️ Deploy here if not accessible)

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a new st.dataframe selection mode, "single-row-required", to enforce radio-like behavior where exactly one row is always selected (defaulting to the first row) and clearing the selection is disallowed.

Changes:

  • Extended the backend selection mode parsing/validation and selection-state serde to support "single-row-required" and auto-select row 0 when needed.
  • Updated the frontend DataFrame selection handling/state initialization to prevent row deselection and render circle-style row markers.
  • Added/updated unit and e2e coverage plus a product spec for the new mode.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
specs/2026-03-10-single-row-required-selection/product-spec.md Product/behavior spec for required single-row selection mode.
proto/streamlit/proto/Dataframe.proto Adds SINGLE_ROW_REQUIRED enum value to the Dataframe selection mode proto.
lib/streamlit/elements/arrow.py Backend support: new mode literals, validation, proto mapping, and auto-select row 0 in required mode.
lib/tests/streamlit/elements/arrow_dataframe_test.py Backend unit tests updated to include the new selection mode and required-mode validation behavior.
frontend/lib/src/components/widgets/DataFrame/hooks/useWidgetState.ts Frontend widget initialization: auto-select row 0 for required mode when no stored/default selection exists.
frontend/lib/src/components/widgets/DataFrame/hooks/useWidgetState.test.ts Updates existing tests for new function signature (but missing coverage for required-mode branch).
frontend/lib/src/components/widgets/DataFrame/hooks/useSelectionHandler.ts Frontend selection processing updated to prevent clearing row selection in required mode.
frontend/lib/src/components/widgets/DataFrame/hooks/useSelectionHandler.test.ts Adds tests for required-mode detection and “cannot clear” behavior.
frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx UI tweaks: hide clear-selection action (partially), and circle row markers for required mode.
e2e_playwright/st_dataframe_selections.py Adds an app scenario exercising "single-row-required" mode.
e2e_playwright/st_dataframe_selections_test.py E2E behavior + snapshot coverage for required single-row selection mode.
Comments suppressed due to low confidence (1)

frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx:738

  • The “Clear selection” toolbar action is still shown when isRequiredRowSelectionActivated is true and a column/cell selection exists. Clicking it calls clearSelection() which clears row selection too, violating the single-row-required invariant (and the spec statement that the clear action should be hidden). Either hide this action unconditionally when required mode is active, or adjust the clear behavior to preserve the required row selection.
        {((isRowSelectionActivated &&
          isRowSelected &&
          !isRequiredRowSelectionActivated) ||
          (isColumnSelectionActivated && isColumnSelected) ||
          (isCellSelectionActivated && isCellSelected)) && (
          // Add clear selection action if selections are active
          // and a valid selections currently exists. Cell selections
          // are not relevant since they are not synced to the backend
          // at the moment. Hide for single-row-required mode since
          // clearing is not allowed.
          <ToolbarAction
            label="Clear selection"
            icon={Close}
            onClick={() => {
              clearSelection()
              clearTooltip()
            }}
          />

@lukasmasuch lukasmasuch changed the title [feature] Add single-row-required selection mode to st.dataframe [feature] Add single-row-required selection mode to st.dataframe Mar 10, 2026
@lukasmasuch lukasmasuch added the update-snapshots Trigger snapshot autofix workflow label Mar 10, 2026
@github-actions github-actions bot removed the update-snapshots Trigger snapshot autofix workflow label Mar 10, 2026
lukasmasuch and others added 2 commits March 10, 2026 10:33
- Fix clearSelection to preserve row selection in single-row-required mode
  (prevents sorting columns from clearing the required row selection)
- Update RangeIndex hiding to include single-row-required mode
- Add unit test for clearSelection behavior in single-row-required mode

Co-Authored-By: Claude Opus 4.6 <[email protected]>
## Describe your changes

Automated snapshot updates for #14288 created via the snapshot autofix
CI workflow.

This workflow was triggered by adding the `update-snapshots` label to a
PR after Playwright E2E tests failed with snapshot mismatches.

**Updated snapshots:** 6 file(s)

⚠️ **Please review the snapshot changes carefully** - they could mask
visual bugs if accepted blindly.

This PR targets a feature branch and can be merged without review
approval.

Co-authored-by: Streamlit Bot <[email protected]>
@lukasmasuch lukasmasuch added the update-snapshots Trigger snapshot autofix workflow label Mar 10, 2026
@github-actions github-actions bot removed the update-snapshots Trigger snapshot autofix workflow label Mar 10, 2026
## Describe your changes

Automated snapshot updates for #14288 created via the snapshot autofix
CI workflow.

This workflow was triggered by adding the `update-snapshots` label to a
PR after Playwright E2E tests failed with snapshot mismatches.

**Updated snapshots:** 1 file(s)

⚠️ **Please review the snapshot changes carefully** - they could mask
visual bugs if accepted blindly.

This PR targets a feature branch and can be merged without review
approval.

Co-authored-by: Streamlit Bot <[email protected]>
@lukasmasuch lukasmasuch added the update-snapshots Trigger snapshot autofix workflow label Mar 10, 2026
@github-actions github-actions bot removed the update-snapshots Trigger snapshot autofix workflow label Mar 10, 2026
## Describe your changes

Automated snapshot updates for #14288 created via the snapshot autofix
CI workflow.

This workflow was triggered by adding the `update-snapshots` label to a
PR after Playwright E2E tests failed with snapshot mismatches.

**Updated snapshots:** 1 file(s)

⚠️ **Please review the snapshot changes carefully** - they could mask
visual bugs if accepted blindly.

This PR targets a feature branch and can be merged without review
approval.

Co-authored-by: Streamlit Bot <[email protected]>
@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Mar 10, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 10, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR adds a new "single-row-required" selection mode to st.dataframe that enforces exactly one row to always be selected (radio-like behavior). Changes span protobuf (new SINGLE_ROW_REQUIRED = 6 enum value), Python backend (validation, serde, auto-selection of first row), frontend TypeScript (prevention of clearing, circle checkbox style, toolbar button hiding, auto-selection), and comprehensive tests (Python unit, TypeScript unit, E2E with visual snapshots). A product spec is also included.

Code Quality

The implementation is well-structured and follows existing codebase patterns. The _ROW_SELECTION_MODES set is a good refactor to centralize row-mode checks. The layered approach (proto → backend → frontend) is clean.

Issues identified (by reviewer agreement level):

  1. Missing docstring for "single-row-required" (all 3 reviewers agree): The selection_mode parameter docstring at lib/streamlit/elements/arrow.py (lines 659-673) lists all modes except "single-row-required". Users will not discover this feature without it. This should be added to both the type annotation line and the bullet list description.

  2. Unnecessary backend sync on prevented clear (2 of 3 reviewers — opus-4.6-thinking, partially gpt-5.3-codex-high): In processSelectionChange (useSelectionHandler.ts, lines 164-185), the syncSelection flag is computed before the single-row-required prevention block restores old rows. When a user attempts to clear the selection, syncSelection is true, but the prevention logic restores the old rows, resulting in a no-op sync to the backend. While likely harmless (widget manager should deduplicate), this is a wasteful round-trip that could in edge cases trigger an unnecessary rerun.

  3. Sorting behavior with single-row-required (gpt-5.3-codex-high raised as blocking; verified as non-blocking): The sort path calls clearSelection() before sorting, but clearSelection now preserves rows in single-row-required mode. This means the selected display-index is preserved through sort transitions, which could point to different data after re-sort. After code verification: this is a known limitation inherent to the existing selection + sort design (the codebase comment at DataFrame.tsx line 933-937 acknowledges this gap for all row modes). For single-row mode, the workaround is to clear selection before sort. For single-row-required, clearing is not possible by design. The behavior is consistent (the selection index is preserved), and the backend correctly receives the current display-index. While not ideal UX, this is not a regression — it is an inherited limitation. A follow-up to remap selections through sort transitions would improve all row selection modes, not just single-row-required. Verdict: valid observation, but not blocking for this PR.

Test Coverage

Coverage is solid across all layers:

  • Python unit tests (arrow_dataframe_test.py): mode parsing, validation, invalid combinations, auto-selection, empty dataframe edge case, single-row limiting.
  • TypeScript unit tests (useSelectionHandler.test.ts): mode detection, preventing row clear, allowing row change, clearSelection preserving rows.
  • E2E tests (st_dataframe_selections_test.py): auto-selection, no clearing, selection change, visual snapshot (circle checkbox).

Gaps identified (by reviewer agreement):

  1. Missing useWidgetState auto-selection test (2 of 3 reviewers — opus-4.6-thinking, gpt-5.3-codex-high): The useWidgetState.test.ts changes only add isRequiredRowSelectionActivated: false to existing tests. There is no test where isRequiredRowSelectionActivated: true verifies that loadInitialSelectionState auto-selects row 0 when no stored selection or default exists. This is a meaningful frontend coverage gap.

  2. Missing typing test (1 of 3 reviewers — opus-4.6-thinking): lib/tests/streamlit/typing/dataframe_types.py does not include an assert_type call for selection_mode="single-row-required". Since this is a new literal value in the SelectionMode type alias, it should be verified for type-safety.

  3. No sorting + required-mode test (gpt-5.3-codex-high): While the sort behavior is not blocking (see Code Quality Fix line number in error message #3), a test documenting the expected behavior of single-row-required during sort would be valuable for future maintainability.

Backwards Compatibility

All three reviewers agree: this is a fully backwards-compatible, additive change. Existing selection modes are unaffected. The new mode is opt-in and mutually exclusive with other row modes (enforced by validation).

Security & Risk

All three reviewers agree: no security concerns. Changes are confined to dataframe selection logic with no impact on routing, auth, sessions, WebSocket transport, embedding, or external assets.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Confidence: High (all 3 reviewers agree)

Accessibility

All three reviewers agree: no accessibility regressions. The circle checkbox (checkboxStyle: "circle") provides appropriate visual differentiation. Hiding the non-functional "Clear selection" button is correct. Existing keyboard handling (Escape) is intercepted via the prevention logic. No new interactive elements require additional ARIA labels.

Recommendations

Must-fix before merge:

  1. Update the selection_mode docstring in lib/streamlit/elements/arrow.py (lines 659-673) to include "single-row-required" in both the parameter type line and the bullet list description. Without this, users cannot discover the feature.

  2. Add a useWidgetState unit test for auto-selection where isRequiredRowSelectionActivated: true, no stored widget value exists, and no selectionDefault is set — verifying that loadInitialSelectionState returns a selection with row 0 and syncs it to the widget manager.

  3. Add a typing test in lib/tests/streamlit/typing/dataframe_types.py with assert_type for selection_mode="single-row-required".

Nice-to-have:

  1. Fix unnecessary sync on prevented clear in processSelectionChange: after the single-row-required prevention block restores old rows, skip the sync if the final selection equals the current selection.

  2. Add a sorting + required-mode test to document the expected behavior when sorting columns with single-row-required active, for future maintainability.

Reviewer Agreement Summary

Topic gemini-3.1-pro gpt-5.3-codex-high opus-4.6-thinking
Overall quality Solid Solid Solid
Sorting concern Not raised Blocking Not raised
Missing docstring Not raised Raised Raised (blocking)
Missing useWidgetState test Not raised Partially (sorting focus) Raised
Missing typing test Not raised Not raised Raised
Unnecessary sync Not raised Partially noted Raised
Verdict APPROVED CHANGES_REQUESTED CHANGES_REQUESTED

The consolidation model (opus-4.6-thinking) independently verified the sorting concern raised by gpt-5.3-codex-high and determined it is a valid observation but not a blocking issue for this PR, as it reflects a pre-existing limitation in the sort + selection design rather than a regression introduced by this change.

Verdict

CHANGES REQUESTED: Two of three reviewers requested changes. While the implementation is solid and well-structured, three gaps should be addressed before merge: (1) the missing docstring for the new selection mode (users can't discover the feature), (2) missing useWidgetState unit test for the auto-selection path, and (3) missing typing test for the new literal value. The sorting behavior concern is valid but non-blocking — it is a pre-existing limitation, not a regression.


This is a consolidated AI review by opus-4.6-thinking, synthesizing reviews from gemini-3.1-pro, gpt-5.3-codex-high, and opus-4.6-thinking.


📋 Review by `gemini-3.1-pro`

Summary

This PR introduces a new single-row-required selection mode for st.dataframe. This mode ensures that exactly one row is always selected, mimicking the behavior of a radio button. It auto-selects the first row if no default is provided, prevents clearing the selection, hides the "Clear selection" toolbar button, and uses a circle checkbox style for visual differentiation.

Code Quality

The code changes are well-structured, clear, and follow the existing patterns in the codebase.

  • In lib/streamlit/elements/arrow.py, the validation logic correctly groups row selection modes and ensures only one is specified. The auto-selection logic is properly integrated into _validate_selection_state and DataframeSelectionSerde.deserialize.
  • In the frontend, useSelectionHandler.ts and useWidgetState.ts appropriately handle the new mode by preventing selection clearing and setting the initial default selection. The changes to DataFrame.tsx correctly update the UI (hiding the clear button and changing the checkbox style).

Test Coverage

The changes are comprehensively tested:

  • Python Unit Tests: arrow_dataframe_test.py covers the new selection mode parsing, validation, and auto-selection logic.
  • TypeScript Unit Tests: useSelectionHandler.test.ts and useWidgetState.test.ts cover the frontend state management, ensuring that the selection cannot be cleared and that the initial state is correctly loaded.
  • E2E Tests: st_dataframe_selections_test.py verifies the end-to-end behavior, including auto-selection, the absence of the clear button, the inability to clear via Escape or clicking the selected row, and visual snapshots for the circle checkbox style.

Backwards Compatibility

This is a non-breaking, additive change. Existing selection modes (single-row, multi-row, etc.) are unaffected. The new mode is mutually exclusive with other row selection modes, which is enforced by validation.

Security & Risk

No security concerns or regression risks were identified. The changes are confined to the dataframe selection logic and do not interact with security-sensitive areas such as routing, authentication, or external assets.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • lib/streamlit/elements/arrow.py: Only modifies dataframe selection state validation and serialization.
    • frontend/lib/src/components/widgets/DataFrame/*: Only modifies frontend selection state management and UI rendering for the dataframe component.
  • Suggested external_test focus areas: N/A
  • Confidence: High
  • Assumptions and gaps: The changes are purely related to the internal state management and UI of the dataframe component and do not touch any external integration points.

Accessibility

The frontend changes rely on glide-data-grid for rendering. The visual change to a circle checkbox (checkboxStyle: "circle") provides a clear visual cue for the radio-like behavior without negatively impacting the existing accessibility of the grid.

Recommendations

The implementation is solid and ready to be merged. No further changes are required.

Verdict

APPROVED: The implementation is complete, well-tested, and follows the project's best practices.


This is an automated AI review by gemini-3.1-pro. Please verify the feedback and use your judgment.

📋 Review by `gpt-5.3-codex-high`

Summary

This PR introduces a new st.dataframe selection mode, "single-row-required", with end-to-end support across protobuf, Python backend validation/serde, frontend selection handling, and e2e snapshots/tests. The intended behavior is radio-like row selection: a row is always selected (defaulting to the first row), selection cannot be cleared, and row markers are rendered with circle style.

Code Quality

I found one blocking behavior issue:

  • Sorting path violates required-selection correctness and can desync UI vs backend state in frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx (lines ~932-939 and ~1129-1133) together with frontend/lib/src/components/widgets/DataFrame/hooks/useSelectionHandler.ts (lines ~233-253).
    • Sorting currently calls clearSelection() / clearSelection(false, true) before sort, but clearSelection now forcibly preserves rows when single-row-required is active (effectiveKeepRows = keepRows || isRequiredRowSelectionActivated).
    • Result: the sort flow no longer truly clears row selection for required mode, while the surrounding sort comments explicitly rely on clearing due unsupported row remapping.
    • This creates a high risk of stale display-index selection through sort transitions and inconsistent selected-row semantics (potential mismatch between visible selected row and backend-reported original row index).

Test Coverage

Coverage is generally solid across backend + frontend hook tests + e2e scenarios for default selection, preventing clear, and visual behavior.
However, there is a key missing regression test for the blocking issue:

  • No test covers "single-row-required" behavior when sorting via header click and column menu sort.
  • Existing single-row sorting behavior is tested, but required-mode sort semantics are not.

Backwards Compatibility

The API change is additive ("single-row-required" and new enum value), so baseline compatibility is good.
That said, current sort behavior in required mode risks functional regressions for users who rely on stable selection semantics during sorting.

Security & Risk

No direct security-sensitive surfaces are modified (no auth/session/websocket handshake/routes/CORS/cookie/header changes).
Primary risk is functional correctness/regression in selection state synchronization when sorting in required mode.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx: widget selection UX/state logic only; no external-origin, routing, auth, websocket transport, or embedding boundary changes.
    • frontend/lib/src/components/widgets/DataFrame/hooks/useSelectionHandler.ts: local selection-state transitions only.
    • lib/streamlit/elements/arrow.py: selection-mode parsing/validation and selection-state serde only.
    • proto/streamlit/proto/Dataframe.proto: additive enum value only.
    • e2e_playwright/st_dataframe_selections_test.py: local widget behavior and snapshot coverage only.
  • Suggested external_test focus areas:
    • None required for this diff; prioritize local e2e + unit coverage for sorting/selection-state correctness.
    • If desired defensively, validate required-row selection behavior under embedded iframe host only after local sort semantics are fixed.
  • Confidence: High
  • Assumptions and gaps: Assessment assumes no hidden changes outside this PR that alter hosting, embedding, auth, routing, websocket transport, or storage behavior.

Accessibility

No accessibility regressions are obvious in this diff.
Hiding the clear button in required mode is consistent with behavior constraints, and row marker circle styling is a visual affordance change without introducing new non-semantic interactive elements.

Recommendations

  1. Fix required-mode sorting behavior so selection semantics remain correct after sort (e.g., remap selection by original row identity and sync, or intentionally reselect a deterministic row and sync). Avoid preserving stale display-index selections through sort.
  2. Add targeted tests for "single-row-required" sorting paths (both header-click sort and column-menu sort) asserting backend-reported selected row consistency after sort.
  3. Update st.dataframe selection_mode docs in lib/streamlit/elements/arrow.py (lines ~659-669) to include "single-row-required" in the public parameter documentation list.

Verdict

CHANGES REQUESTED: The feature is close, but sorting with single-row-required currently has a correctness gap that can produce inconsistent selection behavior and should be fixed before merge.


This is an automated AI review by gpt-5.3-codex-high. Please verify the feedback and use your judgment.

📋 Review by `opus-4.6-thinking`

Summary

This PR adds a new "single-row-required" selection mode to st.dataframe that enforces exactly one row to always be selected (radio-like behavior). Key changes:

  • Protobuf: Adds SINGLE_ROW_REQUIRED = 6 enum value to Dataframe.proto.
  • Backend (Python): Adds "single-row-required" to SelectionMode, introduces _ROW_SELECTION_MODES set for unified row-mode checks, auto-selects first row when no default/selection exists, prevents clearing to empty in serde and validation.
  • Frontend (TypeScript): Adds isRequiredRowSelectionActivated derived state in useSelectionHandler, prevents row clearing in processSelectionChange and clearSelection, hides "Clear selection" toolbar button, uses circle checkbox style for visual radio-like appearance, auto-selects first row in loadInitialSelectionState.
  • Tests: Python unit tests, TypeScript unit tests, E2E tests (with visual snapshots across browsers).
  • Spec: Product spec documenting design decisions and alternatives.

Code Quality

The implementation is well-structured and follows existing patterns throughout the codebase. The layered approach (proto -> backend -> frontend) is clean, and the _ROW_SELECTION_MODES set is a good refactor to centralize row-mode checks.

Specific notes:

  1. Minor inefficiency in processSelectionChange (useSelectionHandler.ts, lines 164-185): The syncSelection flag is computed before the single-row-required prevention logic fires. When a user attempts to clear the selection (e.g., clicking the selected row), rowSelectionChanged is true and syncSelection is true, but the prevention logic restores the old rows. This causes syncSelectionState to be called with unchanged data. While likely harmless (the widget manager should deduplicate), it's a wasteful no-op sync that could in edge cases trigger an unnecessary rerun. Consider recomputing syncSelection after the prevention block, or short-circuiting early.

  2. DataframeSelectionSerde fields (arrow.py, lines 198-199): The is_required_row_mode and num_rows fields on the dataclass are clean. However, num_rows is somewhat generic and could cause confusion since the serde is only concerned with it for the required-row fallback. A comment or more specific name (e.g., _num_rows_for_required_fallback) might help, though the current naming is acceptable.

  3. Spec file: The specs/2026-03-10-single-row-required-selection/product-spec.md file is a nice addition for documenting design decisions. It's well-written and thorough.

Test Coverage

Python unit tests (arrow_dataframe_test.py): Good coverage of the new mode, including:

  • Selection mode parsing (single and combined)
  • Invalid mode combinations (single-row-required + single-row, single-row-required + multi-row)
  • Auto-selection of first row when empty
  • Preservation of valid existing selection
  • Empty dataframe edge case
  • Limiting to single row

TypeScript unit tests (useSelectionHandler.test.ts): Good coverage including:

  • Detection of single-row-required mode
  • Preventing clearing row selection
  • Allowing changing row selection
  • clearSelection preserving rows

Gaps identified:

  1. Missing useWidgetState test for auto-selection: The useWidgetState.test.ts changes only add isRequiredRowSelectionActivated: false to existing test calls. There is no new test case for isRequiredRowSelectionActivated: true to verify that loadInitialSelectionState auto-selects the first row when no stored selection or default exists. This is a meaningful coverage gap for the frontend auto-selection path.

  2. Missing typing test: lib/tests/streamlit/typing/dataframe_types.py does not include an assert_type test for selection_mode="single-row-required". Since this is a new literal value in the SelectionMode type alias, it should be verified.

E2E tests (st_dataframe_selections_test.py): Two well-structured tests covering:

  • Behavior (auto-selection, no clearing, selection change, negative assertions)
  • Visual snapshot (circle checkbox style)

The E2E test uses wait_for_timeout in three places (200ms, 200ms, 250ms). The comments justify this as necessary because glide-data-grid renders to canvas and selection state isn't observable via DOM for negative assertions. This is acceptable per the E2E guidelines but could be a flakiness risk in slow CI. Consider increasing the timeouts slightly or using wait_until with a polling approach where possible.

Backwards Compatibility

This change is fully backwards compatible:

  • The protobuf change is additive (new enum value SINGLE_ROW_REQUIRED = 6). Existing values are unchanged.
  • The new "single-row-required" selection mode is opt-in; existing "single-row" and "multi-row" modes are unaffected.
  • The _ROW_SELECTION_MODES refactor doesn't change existing behavior — it centralizes the set of row modes that were previously checked inline.
  • The SelectionMode type alias now includes "single-row-required", which is additive and won't break existing code.

Security & Risk

No security concerns identified:

  • No changes to routing, auth, cookies, CSRF, or session handling.
  • No changes to WebSocket communication (only a new protobuf enum value).
  • No new dependencies added.
  • No file upload/download, asset serving, or path traversal changes.
  • No HTML/Markdown rendering or sanitization changes.
  • No eval, exec, subprocess, or similar injection vectors introduced.
  • The changes are purely within the dataframe selection handling logic.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • proto/streamlit/proto/Dataframe.proto: Additive enum value only, no wire protocol changes
    • lib/streamlit/elements/arrow.py: Pure selection logic within dataframe widget, no routing/auth/network changes
    • frontend/lib/src/components/widgets/DataFrame/: UI selection handling only, no embedding/iframe/postMessage changes
  • Suggested external_test focus areas: N/A
  • Confidence: High
  • Assumptions and gaps: None. This is a self-contained feature addition to the dataframe widget with no external system integration, no cross-origin behavior, no security header changes, and no embedding/iframe implications.

Accessibility

  • The change uses checkboxStyle: "circle" for row markers in single-row-required mode, which provides visual differentiation from square checkboxes (radio-like appearance). This is a valid visual cue for the different selection semantics.
  • The "Clear selection" toolbar button (which has label="Clear selection" for accessibility) is correctly hidden when it would be non-functional.
  • No new interactive elements are introduced that would need additional ARIA labels.
  • The existing keyboard handling (Escape key) is properly intercepted via the processSelectionChange prevention logic rather than by adding new keyboard event handlers, maintaining existing accessibility patterns.

Recommendations

  1. Add a useWidgetState unit test for auto-selection: Add a test case in useWidgetState.test.ts where isRequiredRowSelectionActivated: true, no stored widget value exists, and no selectionDefault is set, verifying that loadInitialSelectionState returns a selection with row 0 and syncs it to the widget manager.

  2. Update the selection_mode docstring: The parameter docstring at lib/streamlit/elements/arrow.py line 659 lists all modes except "single-row-required". Add it to both the parameter type annotation line and the bullet list description:

    selection_mode : "single-row", "single-row-required", "multi-row", ...
    

    And add a bullet:

    - "single-row-required": Exactly one row must always be selected (radio-like). Auto-selects the first row if no default is given.
    
  3. Add a typing test: Add an assert_type call in lib/tests/streamlit/typing/dataframe_types.py for selection_mode="single-row-required" to verify the new literal value is accepted by the type system.

  4. Consider fixing the unnecessary sync: In processSelectionChange (useSelectionHandler.ts), after the single-row-required prevention block restores old rows, the syncSelection flag is still true. Consider adding a check after prevention to skip the sync if the final selection equals the current selection, e.g.:

    const actualRowChanged = !isEqual(
      updatedSelection.rows.toArray(),
      gridSelection.rows.toArray()
    )

    This prevents unnecessary backend syncs when the user attempts to clear in single-row-required mode.

Verdict

CHANGES REQUESTED: Solid, well-structured feature implementation with good test coverage across all layers. The main gaps are a missing docstring update for the new selection mode (item 2 — users won't discover the feature without it), a missing useWidgetState unit test for the auto-selection path (item 1), and a missing typing test (item 3). Items 1-3 should be addressed before merging; item 4 is a nice-to-have improvement.


This is an automated AI review by opus-4.6-thinking.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Mar 10, 2026
- Add "single-row-required" to selection_mode docstring in arrow.py
- Add typing test for selection_mode="single-row-required"
- Add useWidgetState unit test for auto-selection in required mode
- Fix unnecessary backend sync when row clear is prevented

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Mar 11, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 11, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR adds a new single-row-required selection mode to st.dataframe, providing radio-like row selection behavior where exactly one row must always be selected. The implementation spans the full stack:

  • Protobuf: New SINGLE_ROW_REQUIRED = 6 enum value in Dataframe.proto.
  • Backend (Python): New "single-row-required" literal in SelectionMode, a _ROW_SELECTION_MODES set for centralized row-mode checks, auto-selection of row 0 when no default is provided, and validation that at most one row mode is active.
  • Frontend (TypeScript): useSelectionHandler prevents clearing the row selection and exposes isRequiredRowSelectionActivated; useWidgetState auto-selects row 0 on initial load; DataFrame.tsx hides the "Clear selection" toolbar button, uses circle checkbox style, and preserves row selection across column sorts via a ref-guarded useEffect.
  • Tests: Python unit tests, TypeScript unit tests, typing tests, and E2E (Playwright) tests covering auto-selection, no-clear invariant, sort preservation, combined mode, and visual snapshots.

Code Quality

All three reviewers agree that the code is well-structured and follows existing codebase patterns. Notable strengths:

  • The _ROW_SELECTION_MODES set centralizes row-mode checks and avoids repeating mode string comparisons.
  • The clearSelection override via effectiveKeepRows is clean and minimal.
  • The processSelectionChange guard for preventing row clearing correctly handles combined modes (row required + column selection), including the actualSyncNeeded logic to still sync column/cell changes.
  • Dynamic validation error messages are more helpful than the previous static ones.
  • The pendingRowSelectionRemapRef in DataFrame.tsx is a pragmatic approach to preserving the selected row through column sorts.

Minor observations (non-blocking):

  1. Stale docstring warning (lib/streamlit/elements/arrow.py, lines 112-115): The warning text still states "If a user sorts a dataframe, row selections will be reset," which is no longer universally true with single-row-required mode (sorting preserves the selected data row). This should be updated to note the exception. (Identified by gpt-5.3-codex-high; verified — the text does not reflect the new behavior.)

  2. useEffect dependency array breadth (DataFrame.tsx, lines 413-418): The row-remapping effect depends on gridSelection and processSelectionChange, causing it to fire on every selection change — not just sorts. The ref guard (pendingRowSelectionRemapRef.current === null) correctly prevents unnecessary work, so this is functionally correct but adds overhead. If getOriginalIndex reliably changes only when sorting occurs, it may suffice as the sole trigger. (Identified by opus-4.6-thinking; non-blocking since the ref guard ensures correctness.)

  3. E2E wait_for_timeout usage (st_dataframe_selections_test.py, lines 198, 210, 219): The test uses wait_for_timeout(200) and wait_for_timeout(250) which is discouraged by the E2E testing guidelines. The justification (canvas-based rendering makes selection state not DOM-observable) is valid and the comments explaining the rationale are appreciated, but this may introduce flakiness in slower CI environments. (Identified by opus-4.6-thinking; acknowledged as a pragmatic tradeoff.)

Test Coverage

All three reviewers agree that test coverage is thorough and well-organized across all layers. This was the strongest point of consensus.

  • Python unit tests (arrow_dataframe_test.py): Cover auto-selection of row 0, preservation of existing selection, empty dataframe handling, row limiting to 1, mode parsing, and invalid mode combinations.
  • TypeScript unit tests (useSelectionHandler.test.ts, useWidgetState.test.ts): Cover mode detection, preventing row clearing, allowing row changes, clearSelection preservation, combined mode with column selection, and auto-selection on initial load.
  • Type tests (dataframe_types.py): Verify the new mode string is accepted by the type checker.
  • E2E tests (st_dataframe_selections_test.py): Cover auto-selection on load, Escape key not clearing, re-clicking same row not deselecting, changing to a different row, sort preservation, combined row+column mode with programmatic changes, and visual snapshot.
  • Negative assertions: Tests properly assert that clearing does NOT happen (e.g., not_to_contain_text("'rows': []")).

One area for improvement: the E2E sort preservation test (test_single_row_required_select_and_sort) asserts that some row remains selected after sorting but doesn't verify it's the correct row (original index 0). Since the test data is deterministic, asserting 'rows': [0] after sort would strengthen confidence that the remap logic correctly tracks the original data row. (Identified by opus-4.6-thinking.)

Backwards Compatibility

All three reviewers agree: no backwards compatibility concerns.

  • The protobuf enum SINGLE_ROW_REQUIRED = 6 is purely additive; existing values (0-5) are unchanged.
  • The SelectionMode type alias adds a new literal without modifying existing ones.
  • All existing selection modes continue to work identically.
  • The row-mode conflict check was generalized from checking a specific pair to checking len(row_modes) > 1, correctly covering all combinations.

Security & Risk

All three reviewers agree: no security concerns identified. Changes are confined to UI selection logic — no new endpoints, auth changes, external requests, HTML/Markdown rendering changes, or new dependencies.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • Changes are strictly limited to st.dataframe element's selection state logic and visual rendering (lib/streamlit/elements/arrow.py, DataFrame.tsx, and associated hooks).
    • No routing, auth, embedding, cross-origin, iframe, or asset-serving changes.
  • Confidence: High
  • Assumptions and gaps: Assumes the dataframe widget's WebSocket message handling is not affected by the new enum value in external/embedded contexts (confirmed by reviewing protobuf and widget state serialization code).

Accessibility

All three reviewers agree the accessibility aspects are handled well:

  • The circle checkbox style (checkboxStyle: "circle") provides appropriate visual differentiation for single-choice (radio-like) behavior.
  • Since glide-data-grid renders to a <canvas> element, standard ARIA attributes don't apply to individual rows; the grid's built-in accessibility handling is unchanged.
  • The "Clear selection" toolbar button is correctly hidden (not just disabled) for this mode, preventing user confusion.

Reviewer Consensus

Area gemini-3.1-pro gpt-5.3-codex-high opus-4.6-thinking
Code Quality Clean, well-structured Cohesive, follows patterns Well-structured, follows patterns
Test Coverage Excellent Strong, multi-layered Thorough, well-organized
Backwards Compat Fully compatible No breaking changes No concerns
Security No concerns No concerns No concerns
External Test Not needed Not needed Not needed
Accessibility Thoughtful improvement No regressions Appropriate UX semantics
Verdict APPROVED APPROVED APPROVED

Unique findings by reviewer:

  • gpt-5.3-codex-high: Stale docstring warning about sort clearing behavior (lines 112-115 in arrow.py).
  • opus-4.6-thinking: useEffect dependency array breadth, E2E wait_for_timeout usage, sort test precision, minor formatting nit in test file.
  • gemini-3.1-pro: No additional issues — clean approval.

No conflicts between reviewers; all agree on the core assessment with different reviewers surfacing complementary minor observations.

Recommendations

  1. Update the stale warning text in lib/streamlit/elements/arrow.py (lines 112-115) to clarify that sort-clearing behavior does not apply to single-row-required mode. (Non-blocking; could be a follow-up.)

  2. Consider strengthening the E2E sort preservation test to assert the exact row index after sort (e.g., 'rows': [0]), not just that a row is selected. (Non-blocking.)

  3. Consider narrowing the useEffect dependency array for the row-remapping effect in DataFrame.tsx to reduce unnecessary effect firings. (Non-blocking; functionally correct as-is.)

Verdict

APPROVED: All three reviewers unanimously approve. The implementation is robust, well-tested across all layers (backend, frontend, E2E), follows existing codebase patterns, and introduces no backwards compatibility or security concerns. The minor suggestions above are non-blocking refinements that could be addressed in a follow-up.


This is a consolidated AI review by opus-4.6-thinking. Please verify the feedback and use your judgment.


📋 Review by `gemini-3.1-pro`

Summary

This PR introduces a new single-row-required selection mode for st.dataframe. This mode ensures that exactly one row is always selected, providing a radio-like behavior. It automatically selects the first row if no default is provided, prevents clearing the selection, and uses a circular checkbox style to visually differentiate it from standard multi-select checkboxes.

Code Quality

The code changes are clean, well-structured, and follow the existing patterns in the codebase.

  • The frontend logic in useSelectionHandler.ts correctly intercepts selection changes to prevent clearing the row selection.
  • The default state handling in useWidgetState.ts and the backend _validate_selection_state function are robust and handle edge cases (like empty dataframes) appropriately.
  • The use of pendingRowSelectionRemapRef in DataFrame.tsx is a pragmatic and effective way to preserve the selected row's data mapping when the user sorts a column.

Test Coverage

Test coverage is excellent.

  • Python Unit Tests: arrow_dataframe_test.py covers the new selection mode parsing, validation, and auto-selection logic, including edge cases like empty dataframes.
  • TypeScript Unit Tests: useSelectionHandler.test.ts and useWidgetState.test.ts thoroughly test the new mode's behavior, including preventing selection clearing and auto-selecting the first row.
  • E2E Tests: st_dataframe_selections_test.py includes comprehensive Playwright tests that verify the visual style (via snapshots), auto-selection, interaction behavior (preventing deselection), and the preservation of selection during column sorting.

Backwards Compatibility

The changes are fully backwards compatible. The new single-row-required mode is an opt-in feature. The validation logic for existing selection modes (single-row and multi-row) remains functionally identical, with only a minor improvement to the exception message when multiple row selection modes are provided.

Security & Risk

No security concerns or regression risks identified. The changes are confined to the dataframe element's frontend and backend state management and do not interact with security-sensitive areas such as routing, authentication, or cross-origin behavior.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • The changes are strictly limited to the st.dataframe element's selection state logic and visual rendering (lib/streamlit/elements/arrow.py, DataFrame.tsx, and associated hooks).
  • Confidence: High
  • Assumptions and gaps: None

Accessibility

The frontend changes include a thoughtful accessibility improvement: using checkboxStyle: "circle" for the single-row-required mode. This provides users with the standard visual paradigm for single-choice selection (radio button appearance), distinguishing it from the multi-choice square checkboxes.

Recommendations

None. The implementation is solid and ready for merge.

Verdict

APPROVED: The implementation of the single-row-required selection mode is robust, well-tested, and follows best practices.


This is an automated AI review by gemini-3.1-pro. Please verify the feedback and use your judgment.

📋 Review by `gpt-5.3-codex-high`

Summary

This PR adds a new st.dataframe selection mode, single-row-required, across protobuf, Python API validation/serde, frontend selection handling, and end-to-end/unit/type tests. The implementation enforces exactly one selected row (auto-select row 0 when needed), prevents clearing that row, preserves selection across sort in the required mode, and updates row-marker visuals to a radio-like circle style.

Code Quality

The implementation is cohesive and follows existing patterns across backend validation (_validate_selection_state), frontend hooks (useSelectionHandler, useWidgetState), and DataFrame UI orchestration (DataFrame.tsx). I found no blocking correctness issues in the changed logic paths.

One non-blocking docs consistency nit:

  • lib/streamlit/elements/arrow.py (around lines 112-115) still states that row selections reset after sorting; this is no longer universally true with single-row-required (sorting now preserves the selected data row in that mode).

Test Coverage

Coverage is strong and multi-layered for this change:

  • Python unit tests extend selection mode parsing/validation and required-row behavior in lib/tests/streamlit/elements/arrow_dataframe_test.py.
  • Frontend hook tests add required-row behavior checks in frontend/lib/src/components/widgets/DataFrame/hooks/useSelectionHandler.test.ts and initial-state auto-selection coverage in frontend/lib/src/components/widgets/DataFrame/hooks/useWidgetState.test.ts.
  • E2E coverage in e2e_playwright/st_dataframe_selections_test.py validates:
    • initial auto-selection,
    • inability to clear required row,
    • sort preservation behavior,
    • combined single-row-required + multi-column scenarios,
    • visual snapshot behavior (including new snapshots for all browsers).
  • Typing coverage is updated in lib/tests/streamlit/typing/dataframe_types.py.

Given the scope, this is sufficient for merge confidence.

Backwards Compatibility

No breaking API changes were identified:

  • Existing selection modes remain unchanged.
  • New protobuf enum value is appended (SINGLE_ROW_REQUIRED = 6), preserving previous enum numeric values.
  • Validation changes only tighten invalid row-mode combinations involving the new mode.

Behavioral change is additive and opt-in via selection_mode="single-row-required".

Security & Risk

No security-sensitive surfaces were modified (no auth/session routing, cookies/CSRF, asset handlers, external fetches, dynamic code execution, or sensitive data handling changes). Risk is primarily functional UI/state synchronization, and current tests cover those paths well.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx: selection/sorting UI state handling only; no routing/auth/embed/cross-origin logic.
    • frontend/lib/src/components/widgets/DataFrame/hooks/useSelectionHandler.ts: in-memory selection invariants only.
    • lib/streamlit/elements/arrow.py: selection-mode parsing and state validation only; no server endpoint/auth/header changes.
    • proto/streamlit/proto/Dataframe.proto: additive enum for widget selection mode only.
  • Suggested external_test focus areas:
    • None required for this PR specifically.
  • Confidence: High
  • Assumptions and gaps: Assessment is based on the PR diff only; no runtime validation against externally hosted/embedded deployments was executed in this review.

Accessibility

No clear accessibility regressions were found in this diff. The change mainly affects canvas-rendered row marker styling (square -> circle) and selection state logic, not semantic DOM controls. Toolbar behavior remains label-based (Clear selection action), and hiding that action in required-row mode does not remove an alternate required user path.

Recommendations

  1. Update the stale warning text in lib/streamlit/elements/arrow.py (around lines 112-115) to clarify that sort-clearing behavior does not apply to single-row-required.

Verdict

APPROVED: The feature implementation is coherent, well-tested across backend/frontend/e2e layers, and appears ready to merge with only a minor documentation consistency follow-up.


This is an automated AI review by gpt-5.3-codex-high. Please verify the feedback and use your judgment.

📋 Review by `opus-4.6-thinking`

Summary

This PR adds a new single-row-required selection mode to st.dataframe, providing radio-like row selection behavior where exactly one row must always be selected. Key changes:

  • Protobuf: New SINGLE_ROW_REQUIRED = 6 enum value in Dataframe.proto.
  • Backend (Python): New "single-row-required" literal in SelectionMode, a _ROW_SELECTION_MODES set for centralized row-mode checks, auto-selection of row 0 when no default is provided, and validation that at most one row mode is active.
  • Frontend (TypeScript): useSelectionHandler prevents clearing the row selection and exposes isRequiredRowSelectionActivated; useWidgetState auto-selects row 0 on initial load; DataFrame.tsx hides the "Clear selection" toolbar button, uses circle checkbox style, and preserves row selection across column sorts via a ref-guarded useEffect.
  • Tests: Python unit tests, TypeScript unit tests, typing tests, and E2E (Playwright) tests covering auto-selection, no-clear invariant, sort preservation, combined mode, and visual snapshots.

Code Quality

The code is well-structured and follows existing patterns in the codebase.

Strengths:

  • The _ROW_SELECTION_MODES set (lib/streamlit/elements/arrow.py:95-99) is a good refactoring that centralizes row-mode checks and avoids repeating mode string comparisons.
  • The clearSelection override via effectiveKeepRows (useSelectionHandler.ts:244) is clean and minimal.
  • The processSelectionChange guard for preventing row clearing (useSelectionHandler.ts:176-187) is correctly placed and handles combined modes (row required + column selection) properly, including the actualSyncNeeded logic to still sync column/cell changes.
  • Validation error messages are now dynamic (arrow.py:287-289), which is more helpful.

Minor observations:

  1. useEffect dependency array breadth (DataFrame.tsx:416-421): The row-remapping effect depends on gridSelection and processSelectionChange. Since processSelectionChange is rebuilt on every gridSelection change, this effect fires on every selection change—not just sorts. The ref guard (pendingRowSelectionRemapRef.current === null) correctly prevents unnecessary work, but per the frontend AGENTS.md guidance to "keep dependency arrays minimal," consider whether getOriginalIndex alone (plus the ref guard) would suffice, since the ref is only set in the sort handler and getOriginalIndex is the signal that the sort has been applied.

  2. E2E test wait_for_timeout usage (st_dataframe_selections_test.py:143,156,165): The test uses wait_for_timeout(200) and wait_for_timeout(250) which is discouraged by the E2E AGENTS.md. The justification (canvas-based rendering makes selection state not DOM-observable) is valid, but this may introduce flakiness in slower CI environments. The comments explaining the rationale are appreciated.

Test Coverage

Test coverage is thorough and well-organized across all layers:

  • Python unit tests (arrow_dataframe_test.py): Cover auto-selection of row 0, preservation of existing selection, empty dataframe handling, row limiting to 1, mode parsing, and invalid mode combinations (single-row-required + single-row or multi-row).
  • TypeScript unit tests (useSelectionHandler.test.ts, useWidgetState.test.ts): Cover mode detection, preventing row clearing, allowing row changes, clearSelection preservation, combined mode with column selection, and auto-selection on initial load.
  • Type tests (dataframe_types.py): Verify the new mode string is accepted by the type checker.
  • E2E tests (st_dataframe_selections_test.py): Cover auto-selection on load, Escape key not clearing, re-clicking same row not deselecting, changing to a different row, sort preservation, combined row+column mode with programmatic changes, visual snapshot.
  • Negative assertions: Tests properly assert that clearing does NOT happen (e.g., not_to_contain_text("'rows': []"), expect(syncSelectionStateMock).toBeCalledTimes(1) not incrementing).

The E2E sort test (test_single_row_required_select_and_sort) asserts that some row remains selected after sorting but doesn't verify it's the correct row (the one that was selected before sort). This is pragmatic given the data-dependent nature of sorting, though a more precise assertion would strengthen confidence.

Backwards Compatibility

No backwards compatibility concerns:

  • The protobuf enum SINGLE_ROW_REQUIRED = 6 is purely additive; existing values (0-5) are unchanged.
  • The SelectionMode type alias adds a new literal without modifying existing ones.
  • All existing selection modes continue to work identically.
  • The validation now uses a centralized _ROW_SELECTION_MODES set, but the behavior for existing modes is preserved.
  • The row-mode conflict check was generalized from checking {"single-row", "multi-row"} to checking len(row_modes) > 1, which correctly covers all combinations.

Security & Risk

No security concerns identified:

  • Changes are confined to UI selection logic—no new endpoints, auth changes, or external requests.
  • No HTML/Markdown rendering or sanitization changes.
  • No new dependencies added.
  • The auto-select behavior only affects the first row (index 0) and is constrained by existing validation.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • proto/streamlit/proto/Dataframe.proto: Additive enum value only, no routing/transport changes.
    • lib/streamlit/elements/arrow.py: Selection mode logic, no server/auth/embedding changes.
    • frontend/lib/src/components/widgets/DataFrame/: UI component changes within existing widget framework, no cross-origin, iframe, or asset-serving changes.
  • Suggested external_test focus areas: None required.
  • Confidence: High
  • Assumptions and gaps: Assumes the dataframe widget's WebSocket message handling is not affected by the new enum value in external/embedded contexts (confirmed by reviewing the protobuf and widget state serialization code).

Accessibility

  • The circle checkbox style (checkboxStyle: "circle" at DataFrame.tsx:1075) provides visual differentiation for the radio-like behavior, which is appropriate UX semantics.
  • Since glide-data-grid renders to a <canvas> element, standard ARIA attributes don't apply to individual rows. The grid's built-in accessibility handling is unchanged.
  • The "Clear selection" toolbar button is correctly hidden (not just disabled) for this mode, preventing user confusion.
  • No new interactive elements introduced outside the canvas, so no additional a11y requirements.

Recommendations

  1. Consider narrowing the useEffect dependency array for the row-remapping effect in DataFrame.tsx (lines 416-421). The current dependencies (getOriginalIndex, originalNumRows, gridSelection, processSelectionChange) cause the effect to fire on every selection change. Since the ref guard prevents all non-sort executions, this is functionally correct but adds unnecessary overhead. If getOriginalIndex reliably changes only when sorting occurs, it may be sufficient as the sole trigger alongside the ref check.

  2. Strengthen the E2E sort preservation test (test_single_row_required_select_and_sort): Consider asserting the exact row index after sort, not just that a row is selected. This would validate that the remap logic correctly tracks the original data row through the sort.

  3. Minor: The useWidgetState.test.ts test at line 809 ("auto-selects first row in single-row-required mode when no stored selection or default") is missing a blank line before it that other test cases have—just a formatting nit.

Verdict

APPROVED: Well-implemented feature with thorough test coverage across all layers, no backwards compatibility or security concerns, and clean integration with existing selection infrastructure. The minor suggestions above are non-blocking refinements.


This is an automated AI review by opus-4.6-thinking.

{
id: element.id,
formId: element.formId,
} as WidgetInfo,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

question: Is this cast necessary? If not, remove it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Improved it a bit, but still need a case for element.id

// no stored selection and no explicit default.
if (isRequiredRowSelectionActivated && originalNumRows > 0) {
const defaultRequiredSelection: GridSelection = {
rows: CompactSelection.empty().add(0),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

question: Is this 0 guaranteed to be the same as selection: rows: [0]? Are they both guaranteed to be both the row at data index 0 or the row at display index 0? I'm thinking about the use case where the table is sorted and thus data index 0 !== display index 0.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yep, it's guaranteed to be the same row since this only runs in loadInitialSelectionState before any sorting is applied

@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Mar 20, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 20, 2026
@streamlit streamlit deleted a comment from github-actions bot Mar 20, 2026
@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Mar 20, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 20, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR introduces a new single-row-required selection mode for st.dataframe that enforces exactly one row is always selected (radio-button-like behavior). The changes span all layers: protobuf (SINGLE_ROW_REQUIRED = 6), Python backend (validation, serde, API), frontend hooks (useSelectionHandler, useWidgetState), the main DataFrame.tsx component, and comprehensive tests (Python unit, TypeScript unit, E2E with snapshots, and type tests).

Key behaviors: auto-selects the first row when no default is provided, prevents clearing the row selection, uses a circle checkbox style for visual differentiation, hides the "Clear selection" toolbar button, preserves row selection through column sort operations by remapping display indices, and can be combined with column/cell selection modes.

Reviewer Consensus

Reviewer Verdict Notes
gemini-3.1-pro APPROVED No issues found
gpt-5.3-codex-high CHANGES REQUESTED One blocking issue: column-menu sort path missing remap logic
opus-4.6-thinking APPROVED Minor suggestions only

All three reviewers agreed on:

  • Overall code quality is high, following existing patterns well
  • Test coverage is comprehensive across all layers (backend, frontend, E2E)
  • The change is fully backwards compatible (additive protobuf enum, opt-in API)
  • No security concerns
  • No external test needed
  • Good accessibility approach given the canvas-based rendering constraints

Key disagreement: gpt-5.3-codex-high identified a sorting consistency issue between the header-click and column-menu sort paths that the other two reviewers did not flag as blocking. After independent verification, this issue is confirmed valid — the onSortColumn handler for the column menu does not set pendingRowSelectionRemapRef, so while the row selection is preserved (the clearSelection function's effectiveKeepRows guard works), the display index is not remapped after sort. This means the selected row silently changes to point at a different underlying data row when sorting via the column menu, inconsistent with the header-click behavior.

Cross-Cutting Concerns

Sort remap inconsistency (confirmed): The onHeaderClicked handler (line 965) correctly captures original row indices into pendingRowSelectionRemapRef before sorting, allowing the useEffect at line 385 to remap the selection to track the same data row. The onSortColumn handler for the column menu (line 1161) lacks this logic. While the invariant "exactly one row selected" is maintained, the selected data row changes silently — a behavioral inconsistency that should be fixed. This is covered in the inline comments with a concrete fix suggestion.

E2E test robustness: Both opus-4.6-thinking and the inline comments note that wait_for_timeout calls could be replaced with more robust wait_until patterns. The sort E2E test also only asserts that a row remains selected, not which specific row, which limits its regression coverage — especially relevant given the confirmed sort remap bug.

Verdict

CHANGES_REQUESTED: The column-menu sort path missing the row selection remap is a real behavioral inconsistency that should be fixed before merge. The fix is straightforward (apply the same pendingRowSelectionRemapRef pattern from the header-click handler). The rest of the implementation is solid and well-tested.


Consolidated review by opus-4.6-thinking from 3/3 expected models: gemini-3.1-pro, gpt-5.3-codex-high, opus-4.6-thinking.

This review also includes 4 inline comment(s) on specific code lines.


Inline comments (4) that could not be posted on specific lines

frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx (line 1169)
issue: The column-menu sort handler does not remap the row selection in single-row-required mode. The header-click handler (line 965) captures original row indices into pendingRowSelectionRemapRef before sorting, so the useEffect at line 385 can find the new display index for the same data row. This handler skips that step — clearSelection(false, true) preserves the display index (thanks to effectiveKeepRows), but after sorting, that display index now points to a different underlying data row. Please apply the same remap logic here, e.g.:

if (isRequiredRowSelectionActivated && isRowSelected) {
  const originalRowIndices = gridSelection.rows.toArray().map(getOriginalIndex)
  pendingRowSelectionRemapRef.current = originalRowIndices
  clearSelection(true, true)
} else if (isRowSelectionActivated && isRowSelected) {
  clearSelection(false, true)
} else {
  clearSelection(true, true)
}

e2e_playwright/st_dataframe_selections_test.py (line 209)
suggestion: This test asserts that some row remains selected after sort (not_to_contain_text("'rows': []")) but doesn't verify which specific row. Consider asserting the expected row index based on the known sort order to provide stronger regression coverage — e.g., original row 0 should map to a known position after ascending sort on col_0.


frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx (line 417)
thought: This useEffect dependency array includes gridSelection and processSelectionChange, which means it re-runs on every selection change — not just sorts. The pendingRowSelectionRemapRef null-check guard makes this safe, but it fires more often than strictly necessary. An alternative would be to track the sort operation itself as the trigger (e.g., a sort counter ref), but the current approach is pragmatic and correct.


e2e_playwright/st_dataframe_selections_test.py (line 144)
nitpick: wait_for_timeout(200) is discouraged by the E2E guidelines. The comment explains why it's needed (canvas rendering), but consider using wait_until with a callback that checks the expected state instead, which would be more robust against timing variations.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Mar 20, 2026
lukasmasuch and others added 3 commits March 23, 2026 21:48
- Add row selection remapping for column-menu sort in single-row-required mode
  (matches existing header-click sort behavior)
- Improve E2E test to verify specific row index after sort, not just any row
- Remove unnecessary `as WidgetInfo` type assertions in useWidgetState

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lukasmasuch lukasmasuch removed the do-not-merge PR is blocked from merging label Mar 23, 2026
Schedule the row selection remapping via useTimeout(0ms) instead of
useEffect to ensure getOriginalIndex reflects the sorted state.
Also captures column/cell selection at sort time to avoid stale refs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Mar 23, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 23, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Summary

This PR introduces a new single-row-required selection mode for st.dataframe that ensures exactly one row is always selected, providing radio-button-like behavior. The implementation spans the full stack: a new protobuf enum value (SINGLE_ROW_REQUIRED = 6), Python backend validation and auto-selection logic in arrow.py, frontend selection handling in useSelectionHandler.ts and useWidgetState.ts, visual differentiation via circular checkbox markers in DataFrame.tsx, and comprehensive tests at all layers (Python unit, frontend unit, and E2E).

Code Quality

The code is well-structured and follows existing patterns for selection handling throughout. The new mode integrates cleanly into the existing selection infrastructure with clear separation of concerns between the hook layer (useSelectionHandler, useWidgetState) and the component layer (DataFrame).

One correctness issue was identified in the sort/remap path in DataFrame.tsx. Both sort handlers (column-header click and column-menu sort) use clearSelection(true, false), which unnecessarily clears column selections and triggers a redundant backend sync with incorrect intermediate state before performRowSelectionRemap restores them. Additionally, gridSelection.current (cell selection) is captured and restored after sorting without remapping its row index, meaning a stale cell selection could point to different data post-sort. All three reviewers that completed their review agreed this is a blocking issue. See inline comments for specific remediation.

Test Coverage

Test coverage is excellent across all layers:

  • Python unit tests (arrow_dataframe_test.py): Cover the new selection mode parsing, validation, auto-selection of the first row, and edge cases (empty dataframes, invalid row indices).
  • Frontend unit tests (useSelectionHandler.test.ts, useWidgetState.test.ts): Thoroughly test the new mode including required-row detection, prevention of row clearing, auto-selection of the first row, and combined row+column behavior.
  • E2E tests (st_dataframe_selections_test.py): Comprehensive Playwright tests verifying auto-selection, visual style (via snapshots across browsers), keyboard interactions (Escape key not clearing required selection), combined multi-column selections, and sorting behavior.
  • Type tests (dataframe_types.py): Updated to include the new selection mode literal.

One gap noted: a targeted regression test for sort behavior when single-row-required is combined with cell selection mode(s) would strengthen confidence, particularly given the current restoration issue identified above.

Backwards Compatibility

No breaking changes. The new feature is opt-in via the "single-row-required" literal value for the selection_mode parameter. Existing selection modes ("single-row", "multi-row", "single-column", etc.) retain their current behavior. The protobuf enum extension is additive and compatible with existing values.

Security & Risk

No security concerns identified. Changes are confined to the dataframe element's selection state management and UI rendering. No modifications to routing, authentication, session management, WebSocket transport, file handling, or external asset serving. Input validation in _validate_selection_state correctly handles edge cases.

The primary risk is behavioral regression in the frontend selection remapping logic during sorting, as detailed in the Code Quality section.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence: The PR only modifies st.dataframe selection logic, protobuf enum, and React state handling. No changes to routing, auth, WebSocket transport, embedding, cross-origin behavior, security headers, or external asset serving.
  • Suggested external test focus areas: N/A
  • Confidence: High — all three completed reviews unanimously agreed no external test coverage is needed.

Accessibility

The circular checkbox style (checkboxStyle: "circle") provides a clear visual affordance that the selection behaves like a radio button (single required choice), which is a good accessibility practice. Keyboard interaction is correctly handled — the Escape key does not clear the required selection. No new non-semantic interactive DOM controls were introduced.

Recommendations

  1. Fix sort handler clearSelection calls: In both sort handlers in DataFrame.tsx (column-header click ~line 992 and column-menu sort ~line 1204), change clearSelection(true, false) to clearSelection(true, true) to preserve column selections and avoid a redundant backend sync with incorrect intermediate state.
  2. Stop restoring stale cell selection after sort: Set current: undefined in pendingRowSelectionRemapRef instead of capturing gridSelection.current, since cell selection row indices become invalid after sorting.
  3. Add regression test: Add a targeted test for sort behavior when single-row-required is combined with cell selection to prevent recurrence of the current restoration issue.

Verdict

CHANGES_REQUESTED: The feature is well-implemented with excellent test coverage, but a correctness issue in the sort/remap path — redundant backend syncs and stale cell selection restoration — needs to be addressed before merge. All completed reviews agreed on this assessment.


Review metadata

Reviewed by: gemini-3.1-pro, gpt-5.3-codex-high (consolidated by claude-4.6-opus-high-thinking)

Missing reviews: claude-4.6-opus-high-thinking failed to complete its individual review (note: this model served as consolidator instead).

Reviewer agreement: Both completed reviews independently identified the same blocking issue in the sort/remap path and agreed on all other assessments (test coverage, backwards compatibility, security, external test recommendation, accessibility). No disagreements to resolve.


This is an automated AI review consolidated by claude-4.6-opus-high-thinking. Please verify the feedback and use your judgment.

This review also includes 2 inline comment(s) on specific code lines.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Mar 23, 2026
Address AI review feedback: use clearSelection(true, true) instead of
clearSelection(true, false) to preserve both row and column selections
during sorting in single-row-required mode. This avoids an intermediate
state where columns are cleared then restored via remap, causing two
backend syncs. Also set current: undefined in pending remap ref since
cell selection coordinates become stale after sorting.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Mar 23, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 23, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Summary

This PR adds a new "single-row-required" selection mode to st.dataframe that enforces exactly one row is always selected (radio-like behavior). The changes span the full stack: a new protobuf enum value (SINGLE_ROW_REQUIRED = 6), Python backend auto-selection/validation logic, frontend selection handling (prevention of clearing, sort-remap preservation, circle checkbox style, toolbar button hiding), and comprehensive tests across all layers.

Reviewer consensus: All three reviewers (claude-4.6-opus-high-thinking, gemini-3.1-pro, gpt-5.3-codex-high) approved this PR unanimously. There were no disagreements on any findings.

Code Quality

The implementation is well-structured and follows existing Streamlit codebase patterns. All reviewers agreed on the high quality of the code. Specific positives noted:

  • Good use of _ROW_SELECTION_MODES set to consolidate row mode checks and avoid scattered string comparisons.
  • The useTimeout + callbackRef pattern for sort-remap correctly avoids stale closure issues — praised by multiple reviewers as "pragmatic and well-documented."
  • isRequiredRowSelectionActivated is derived during render (not via effect), following React best practices.
  • The WidgetInfo type assertion cleanup (as WidgetInfo?? undefined) is a welcome incidental improvement.

One reviewer (claude) noted that the sort-remap logic in DataFrame.tsx is duplicated between onHeaderClicked (~line 979) and the ColumnMenu's onSortColumn callback (~line 1193). While this follows the existing pattern for these two code paths, extracting a shared helper would improve maintainability. This is captured as an inline comment and is non-blocking.

Test Coverage

All reviewers rated test coverage as strong to excellent. Coverage spans:

  • Python unit tests: Auto-selection, valid selection preservation, empty dataframe edge case, single-row limit, combined modes, and invalid mode combinations.
  • TypeScript unit tests: Mode detection, clearing prevention, selection change, clearSelection preservation, and combined row+column mode sync.
  • E2E tests: Auto-selection on load, no-clear via Escape, no-deselect on same-row click, row change, sort preservation, visual snapshot, and combined mode with programmatic control.
  • Type tests: Added assert_type for the new selection mode.

The E2E tests include a few wait_for_timeout calls, which are generally discouraged. However, the inline comments justify this with the canvas-based rendering of glide-data-grid making DOM-based assertions unreliable for timing — all reviewers found this to be a reasonable exception.

Backwards Compatibility

Fully backwards compatible — all reviewers agreed unanimously:

  • The protobuf change is additive (new enum value 6); older clients simply ignore it.
  • The new "single-row-required" string literal extends the existing SelectionMode union without affecting existing modes.
  • Existing _validate_selection_state and DataframeSelectionSerde behavior is unchanged for existing modes.

Security & Risk

No security concerns identified by any reviewer. The changes are confined to the DataFrame widget's selection handling:

  • No new network endpoints, external requests, or file I/O.
  • No changes to authentication, CORS, CSP, or session management.
  • No dynamic code execution (eval, exec, etc.).
  • Input validation properly sanitizes row indices and enforces bounds.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence: All changes are within the st.dataframe widget's selection logic (protobuf enum, Python selection mode handling, React hooks, and grid rendering). No routing, auth, WebSocket, embedding, asset serving, cross-origin, storage, or security header changes.
  • Confidence: High (unanimous across all reviewers)
  • Assumptions and gaps: None — the feature is self-contained within the DataFrame widget.

Accessibility

  • The circle checkbox style for single-row-required mode provides visual differentiation from square checkboxes (multi-row mode), correctly conveying radio-like semantics.
  • The existing rowMarkers configuration with "checkbox-visible" kind maintains keyboard accessibility.
  • The "Clear selection" toolbar button is correctly hidden (removed from DOM, not just visually), avoiding confusion for screen reader users.
  • The underlying accessibility of canvas elements is handled by glide-data-grid, which remains unchanged.

Recommendations

  1. [Non-blocking] Consider extracting the duplicated sort-remap logic in DataFrame.tsx (in both onHeaderClicked and onSortColumn) into a shared helper function to reduce code duplication and maintenance risk.
  2. [Non-blocking] The DataframeSelectionState docstring (in arrow.py) contains a warning that "row selections will be reset" on sort, which is no longer fully accurate for single-row-required mode where selections are preserved. Consider updating to note the exception.

Verdict

APPROVED: Well-implemented feature with comprehensive test coverage, correct handling of edge cases (sort, clear, combined modes), full backwards compatibility, and no security concerns. All three reviewers approved unanimously. The duplicated sort-remap logic and minor docstring gap are non-blocking suggestions for future improvement.


This is a consolidated AI review by claude-4.6-opus-high-thinking, synthesizing reviews from: claude-4.6-opus-high-thinking, gemini-3.1-pro, gpt-5.3-codex-high (3/3 expected models completed).

This review also includes 3 inline comment(s) on specific code lines.

@github-actions github-actions bot removed the do-not-merge PR is blocked from merging label Mar 23, 2026
@lukasmasuch lukasmasuch added do-not-merge PR is blocked from merging and removed do-not-merge PR is blocked from merging labels Mar 24, 2026
@sfc-gh-lmasuch sfc-gh-lmasuch merged commit 43e4e9c into develop Mar 24, 2026
68 of 70 checks passed
@sfc-gh-lmasuch sfc-gh-lmasuch deleted the lukasmasuch/single-row-required branch March 24, 2026 01:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users

Projects

None yet

Development

Successfully merging this pull request may close these issues.

allow st.dataframe single-row selection to force a selection (per radio button behaviour)

4 participants