From 283cc018a892bd4ff5fb94fe776af7c597739ebc Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 5 Jun 2026 09:25:56 -0700 Subject: [PATCH] fix: slider value lost when adjusting before the node is selected (#13515) * fix: commit slider value before node selection consumes the interaction Sliders inside React Flow nodes (e.g. the URL component's Depth field) lost the value the user set when the node was not yet selected. React Flow selects a node on click and pans/drags it on pointer down, while Radix drives the slider with the same pointer events. The interactive SliderPrimitive.Root had no React Flow isolation, so the first interaction on an unselected node was consumed by node selection: the slider reacted visually but the chosen value reverted or snapped to wherever the pointer landed. Stop pointer/click propagation on the slider root and add React Flow's nodrag/nopan/noflow/nowheel opt-out classes (matching the slider's value-text input). Radix composes the handlers, so value setting is unaffected. Adds a regression test asserting slider pointer-down/click do not bubble to the node wrapper while unrelated children still do. * Update src/frontend/src/components/core/parameterRenderComponent/components/sliderComponent/__tests__/slider-node-selection.test.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * [autofix.ci] apply automated fixes --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../__tests__/slider-node-selection.test.tsx | 113 ++++++++++++++++++ .../components/sliderComponent/index.tsx | 11 +- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/components/core/parameterRenderComponent/components/sliderComponent/__tests__/slider-node-selection.test.tsx diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/sliderComponent/__tests__/slider-node-selection.test.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/sliderComponent/__tests__/slider-node-selection.test.tsx new file mode 100644 index 0000000000..f2832953fa --- /dev/null +++ b/src/frontend/src/components/core/parameterRenderComponent/components/sliderComponent/__tests__/slider-node-selection.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import SliderComponent from "../index"; + +/** + * Regression guard for the "Depth slider value lost on node selection" bug. + * + * Sliders live inside React Flow nodes. React Flow selects a node on `click` + * and pans/drags it on `pointerdown`, while Radix drives the slider with the + * same pointer events. When the interactive slider root does not isolate those + * events, the first interaction on an *unselected* node is consumed by node + * selection: the slider reacts visually but the value the user set is never + * committed (it reverts or snaps to wherever the pointer landed). + * + * The fix stops pointer/click propagation on `SliderPrimitive.Root` and adds + * React Flow's opt-out classes. These tests use a parent that stands in for the + * node wrapper and assert that slider interactions never reach it, while + * unrelated children still bubble normally. + */ + +beforeAll(() => { + // Radix Slider uses the Pointer Events API + pointer capture, which jsdom + // does not fully implement. Polyfill them so the Radix handlers can run + // during the test without throwing. MouseEvent is a constructible, bubbling + // stand-in for PointerEvent; the handlers only read event.pointerId, which is + // harmlessly undefined here. + if (!("PointerEvent" in window)) { + window.PointerEvent = window.MouseEvent as unknown as typeof PointerEvent; + } + if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = jest.fn(); + } + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = jest.fn(); + } + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = jest.fn(() => false); + } +}); + +const renderSliderInNode = () => { + const parentPointerDown = jest.fn(); + const parentClick = jest.fn(); + + const sliderProps: ComponentProps = { + id: "slider_test", + // The value prop type collapses to `never` (legacy generic: both + // BaseInputProps and SliderComponentType declare `value`). The component + // coerces it with Number() at runtime, so a scalar is what it expects. + value: 1 as never, + editNode: false, + disabled: false, + rangeSpec: { min: 1, max: 5, step: 1 }, + handleOnNewValue: jest.fn(), + }; + + render( + // Stand-in for the React Flow node wrapper: it selects the node on click + // and would drag it on pointer down. +
+ + +
, + ); + + return { parentPointerDown, parentClick }; +}; + +describe("SliderComponent — node-selection isolation", () => { + it("applies React Flow opt-out classes to the interactive slider root", () => { + renderSliderInNode(); + + const sliderRoot = screen.getByTestId("slider_track").parentElement; + + expect(sliderRoot).toHaveClass( + "nodrag", + "nopan", + "noflow", + "nowheel", + "nodelete", + ); + }); + + it("does not let a pointer-down on the slider bubble to the node wrapper", () => { + const { parentPointerDown } = renderSliderInNode(); + + fireEvent.pointerDown(screen.getByTestId("slider_thumb")); + + expect(parentPointerDown).not.toHaveBeenCalled(); + }); + + it("does not let a click on the slider bubble to the node wrapper (which would select the node)", () => { + const { parentClick } = renderSliderInNode(); + + fireEvent.click(screen.getByTestId("slider_thumb")); + + expect(parentClick).not.toHaveBeenCalled(); + }); + + it("still lets unrelated children bubble to the node wrapper (isolation is scoped to the slider)", () => { + const { parentClick } = renderSliderInNode(); + + fireEvent.click(screen.getByTestId("bubbling-control")); + + expect(parentClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/sliderComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/sliderComponent/index.tsx index 3fe50e08a1..67e6765242 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/sliderComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/sliderComponent/index.tsx @@ -269,10 +269,19 @@ export default function SliderComponent({
+ {/* + Isolate the slider from React Flow's node interactions. Radix drives the + slider with pointer events, while the node selects on click and pans/drags + on pointer down. Without stopping propagation (and the nodrag/nopan opt-out + classes), the first interaction on an unselected node is consumed by node + selection and the value the user set is silently lost or mis-registered. + */} e.stopPropagation()} + onClick={(e) => e.stopPropagation()} min={min} max={max} step={step}