Skip to content

fix RangeSlider allow_cross - #3926

Open
Sasha-P wants to merge 1 commit into
plotly:devfrom
Sasha-P:dev
Open

fix RangeSlider allow_cross#3926
Sasha-P wants to merge 1 commit into
plotly:devfrom
Sasha-P:dev

Conversation

@Sasha-P

@Sasha-P Sasha-P commented Jul 29, 2026

Copy link
Copy Markdown

Fix RangeSlider crossing and interaction synchronization

Summary

This PR fixes dcc.RangeSlider interaction regressions around allowCross,
updatemode, direct input, and thumb identity.

  • Enforce allowCross=False consistently for pointer, keyboard, Home/End, and
    direct-input changes.
  • Preserve the existing default behavior of allowCross=True when the prop is
    omitted, while publishing crossed values in canonical ascending order.
  • Keep drag_value live during updatemode="mouseup" interactions and publish
    value only when the interaction commits.
  • Track the logical active thumb through crossings, duplicate values, track
    clicks, and mixed pointer/keyboard input.
  • Synchronize direct-input drafts and active interactions when an authoritative
    external value update arrives, including when the number of handles changes.
  • Resynchronize value and drag_value after direct-input blur, including when
    the constrained result returns to the interaction's starting value.

No public API is added or removed. The changes make the existing allowCross
and updatemode contracts behave consistently across all input methods.

Steps to reproduce

example.py
from dash import Dash, Input, Output, dcc, html


app = Dash()

app.layout = html.Div(
    style={"width": "620px", "margin": "80px auto", "fontFamily": "sans-serif"},
    children=[
        html.H3("RangeSlider allowCross=False updatemode=mouseup"),
        html.H4("Two Handles"),
        dcc.RangeSlider(
            id="two-handle-slider-up",
            min=0,
            max=100,
            step=1,
            value=[5, 15],
            allowCross=False,
            updatemode="mouseup",
            marks={
                0: {'label': ' ', 'style': {'color': '#77b0b1'}},
                26: {'label': ' '},
                37: {'label': ' '},
                100: {'label': ' ', 'style': {'color': '#f50'}}
            },
            tooltip={"always_visible": True},
        ),
        html.Pre(
            id="two-handle-out-up",
            style={"marginTop": "24px", "marginBottom": "48px", "fontSize": "18px"},
        ),
        html.H4("Multiple Handles"),
        dcc.RangeSlider(
            id="multiple-handle-slider-up",
            min=0,
            max=20,
            step=1,
            value=[4, 8, 12, 16],
            allowCross=False,
            updatemode="mouseup",
            tooltip={"always_visible": True},
        ),
        html.Pre(
            id="multiple-handle-out-up",
            style={"marginTop": "24px", "marginBottom": "48px", "fontSize": "18px"},
        ),
        html.H3("RangeSlider allowCross=False updatemode=drag"),
        html.H4("Two Handles"),
        dcc.RangeSlider(
            id="two-handle-slider",
            min=0,
            max=20,
            step=1,
            value=[5, 15],
            allowCross=False,
            updatemode="drag",
            tooltip={"always_visible": True},
        ),
        html.Pre(
            id="two-handle-out",
            style={"marginTop": "24px", "marginBottom": "48px", "fontSize": "18px"},
        ),
        html.H4("Multiple Handles"),
        dcc.RangeSlider(
            id="multiple-handle-slider",
            min=0,
            max=20,
            step=1,
            value=[4, 8, 12, 16],
            allowCross=False,
            updatemode="drag",
            tooltip={"always_visible": True},
        ),
        html.Pre(
            id="multiple-handle-out",
            style={"marginTop": "24px", "marginBottom": "48px", "fontSize": "18px"},
        ),
        html.H3("RangeSlider allowCross=True updatemode=drag"),
        html.H4("Two Handles"),
        dcc.RangeSlider(
            id="two-handle-allow-cross-slider",
            min=0,
            max=20,
            step=1,
            value=[5, 15],
            allowCross=True,
            updatemode="drag",
            tooltip={"always_visible": True},
        ),
        html.Pre(
            id="two-handle-allow-cross-out",
            style={"marginTop": "24px", "marginBottom": "48px", "fontSize": "18px"},
        ),
        html.H4("Multiple Handles"),
        dcc.RangeSlider(
            id="multiple-handle-allow-cross-slider",
            min=0,
            max=20,
            step=1,
            value=[4, 8, 12, 16],
            allowCross=True,
            updatemode="drag",
            tooltip={"always_visible": True},
        ),
        html.Pre(
            id="multiple-handle-allow-cross-out",
            style={"marginTop": "24px", "fontSize": "18px"},
        ),
    ],
)

@app.callback(Output("two-handle-out-up", "children"), Input("two-handle-slider-up", "value"))
def show_two_handle_value(value):
    return f"value = {value}"


@app.callback(
    Output("multiple-handle-out-up", "children"), Input("multiple-handle-slider-up", "value")
)
def show_multiple_handle_value(value):
    return f"value = {value}"


@app.callback(Output("two-handle-out", "children"), Input("two-handle-slider", "value"))
def show_two_handle_value(value):
    return f"value = {value}"


@app.callback(
    Output("multiple-handle-out", "children"), Input("multiple-handle-slider", "value")
)
def show_multiple_handle_value(value):
    return f"value = {value}"


@app.callback(
    Output("two-handle-allow-cross-out", "children"),
    Input("two-handle-allow-cross-slider", "value"),
)
def show_two_handle_allow_cross_value(value):
    return f"value = {value}"


@app.callback(
    Output("multiple-handle-allow-cross-out", "children"),
    Input("multiple-handle-allow-cross-slider", "value"),
)
def show_multiple_handle_allow_cross_value(value):
    return f"value = {value}"


if __name__ == "__main__":
    app.run(debug=True)
  1. From the workspace root, check out the parent revision and start the manual
    RangeSlider app:

    git switch --detach 3d3a9f70f452fba4dc385db94590009ce44031fc
    <rebuild dash>
    python3 example.py
  2. Open http://127.0.0.1:8050 and find the
    RangeSlider allowCross=False updatemode=drag example.

  3. Drag the lower handle past the upper handle.

  4. Before this fix, the lower handle crosses the upper handle even though
    allowCross=False, and the callback value changes from [5, 15] to a
    crossed range such as [15, 18].

  5. Stop the server, check out this commit, and repeat the same interaction:

    git switch --detach 41cd7f87f54e39100cc2983f320ce7333020ee99
    <rebuild dash>
    python3 example.py
  6. After this fix, the lower handle stops at the upper handle and the callback
    value remains constrained, for example [15, 15].

Before and after recording

The left panel is the parent revision (3d3a9f70f); the right panel is this
commit (41cd7f87f). Both panels replay the same pointer path with
allowCross=False.

range-slider-before-after-41cd7f87f

Implementation notes

  • Introduced explicit pointer and keyboard interaction transactions so
    mouseup commits use the interaction's authoritative start and final values.
  • Added stable, tagged sorting to retain active-thumb identity while values
    cross or become equal.
  • Matched Radix Slider's pointer-to-thumb selection, including first-thumb tie
    resolution for duplicate values, horizontal/vertical orientation, and
    reversed sliders.
  • Added step-aware Home/End handling and prevented the custom keyboard path from
    updating disabled sliders.
  • Added local-value echo tracking so component-originated setProps updates are
    distinguished from same-value authoritative external updates.
  • Reworked direct-input editing to retain temporary text while editing and
    publish constrained, canonical values at the correct update boundary.

Test coverage

Added a focused 15-test Firefox integration suite covering:

  • click and direct-input callbacks;
  • crossing and clamping with explicit and default allowCross;
  • drag and mouseup update modes;
  • mouseup release commits and mixed pointer/keyboard gestures;
  • keyboard clamping, Home/End, off-step endpoints, and disabled sliders;
  • equal and duplicate thumb identity in both movement directions;
  • no-op track starts and duplicate-value tie selection;
  • external value updates, stale direct-input drafts, and handle-count shrink;
  • drag_value resynchronization after direct-input blur.

The existing keyboard-input test now sets allowCross=False explicitly where it
asserts no-cross behavior.

Verification

../.venv/bin/python -m pytest \
  components/dash-core-components/tests/integration/sliders/test_rangeslider_allow_cross.py \
  --webdriver Firefox --headless -xvs

15 passed
npm run build:js

webpack 5.104.1 compiled with 2 asset-size warnings

Additional checks:

  • Black: passed
  • Prettier: passed
  • ESLint: 0 errors (one pre-existing unused count warning)
  • git diff --check: passed
  • Chromium browser smoke test using manual_rangeslider_allow_cross.py: passed

Contributor Checklist

  • I have broken down my PR scope into the following tasks
    • Fix RangeSlider crossing and interaction lifecycle behavior
    • Synchronize direct inputs and external prop updates
    • Preserve thumb identity across duplicate and crossed values
    • Add regression coverage for all fixed interaction paths
  • I have run the tests locally and they passed.
  • I have added tests, or extended existing tests, to cover the bugs fixed in
    this PR.

Optionals

  • I have added an entry in CHANGELOG.md.
  • This PR requires a follow-up in Dash documentation or a community thread.

@sonarqubecloud

Copy link
Copy Markdown

@camdecoster

Copy link
Copy Markdown
Contributor

Thanks for the PR! Could you please add some examples of the issues with screenshots and/or videos? This is a lot of code to review and the examples would help us target our review.

@Sasha-P

Sasha-P commented Jul 31, 2026

Copy link
Copy Markdown
Author

@camdecoster description updated. added #Steps to reproduce and #Before and after recording

@camdecoster

Copy link
Copy Markdown
Contributor

Thanks. One last request: could you please open an issue that this PR closes? It's part of our record keeping process. Our team has this in our list to look at, but it might take some time to get to.

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.

3 participants