From 4e591503ce4e17fe9be242b8b5e2d3134a4fba38 Mon Sep 17 00:00:00 2001 From: Leonid <127580858+bashrusakh@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:49:19 +1100 Subject: [PATCH] fix(number-input): stepper drift on rapid clicks (closes #2053) (#2059) * fix(number-input): stepper drift on rapid clicks (closes #2053) The shared NumberInput stepper buttons (-/+) computed the next value from `baseValue`, a useMemo of the controlled `value` prop. When the user pressed - and + in rapid succession, both inline closures read the same pre-update `baseValue` because the prop round-trip (click -> onValueChange -> store/persistence update -> re-render) had not landed. Net result: rapid alternation drifted or oscillated instead of returning to the start value. Route the stepper math through a new `committedValueRef` updated synchronously inside `commitValue`, and re-sync the ref from `baseValue` via a useEffect so external mutations (the reset button next to each stepper, undo, multi-instance sync) keep the ref aligned. Keep `baseValue` for the `disabled` predicate so the prop still gates the buttons at the bounds. For the same invariant, route `handleBlur`'s finite-parse branch through `commitValue` so a typed value followed by a stepper click does not compute from a stale ref. The empty-draft `onClear` early-return relies on the baseValue useEffect to re-sync. Cover the path with a new bun:test suite that drives the real onClick closures through createRoot with a minimal document/window stub (no new deps). Tests assert rapid --+ and +-- sequences net to the start value, a sustained 6-click alternation does not drift, sequential clicks with a re-render between them settle correctly, and a typed-then-stepper sequence uses the typed base. * test(number-input): restore DOM globals and guard empty recorded arrays Follow-up to the stepper-drift fix on the same PR. - installDomStub now captures the previous values of document/window/navigator/IS_REACT_ACT_ENVIRONMENT before overwriting and exposes a restore() function. withHandle calls stub.restore() in finally after unmount(), so the test process no longer leaks a fake DOM across tests. - Replace the four 'recorded[length-1]!' non-null assertions with a lastCommit(handle) helper that throws a clear error if the parent never produced a commit. A regression that drops the first commit fails loudly instead of silently coercing to undefined. - Add a short comment on the useEffect re-sync documenting the controlled-parent assumption (ref can briefly lead the prop if a parent ever rejects or debounces onValueChange; no production caller does today). --------- Co-authored-by: bashrusakh --- .../src/components/ui/number-input.test.tsx | 559 ++++++++++++++++++ .../ui/src/components/ui/number-input.tsx | 37 +- 2 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/components/ui/number-input.test.tsx diff --git a/packages/ui/src/components/ui/number-input.test.tsx b/packages/ui/src/components/ui/number-input.test.tsx new file mode 100644 index 00000000..af4525c5 --- /dev/null +++ b/packages/ui/src/components/ui/number-input.test.tsx @@ -0,0 +1,559 @@ +/** + * Regression test for issue #2053: stale-closure bug in NumberInput's + * stepper buttons when clicked in rapid succession. + * + * The desktop stepper buttons (`+` / `-`) read their base value from a + * closure captured at render time. When the user clicks `-` and `+` in rapid + * succession, the parent state round-trip (click → onValueChange → parent + * store update → re-render with new value) hasn't completed yet, so both + * closures read the same stale value. The fix routes the stepper math + * through a ref that is updated synchronously inside `commitValue`, so + * back-to-back clicks within the same render cycle operate on the most + * recent committed value. + * + * The test mounts the real `NumberInput` via `createRoot` against a minimal + * `document`/`window` stub (Bun's test runner does not provide a DOM by + * default) and drives the stepper buttons by invoking the React-internal + * `__reactProps$*` onClick handlers directly. This lets us call the exact + * closure that the production code binds to the button — which is the + * only way to exercise the closure-staleness bug deterministically. + */ + +import { describe, expect, mock, test } from "bun:test"; +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n"; +import { NumberInput } from "./number-input"; + +// --- Minimal DOM stub ---------------------------------------------------- + +interface FakeNode { + nodeType: number; + nodeName: string; + tagName: string; + ownerDocument: FakeDocument; + parentNode: FakeNode | null; + childNodes: FakeNode[]; + style: Record; + classList: FakeClassList; + [key: string]: unknown; +} + +interface FakeDocument extends FakeNode { + defaultView: FakeWindow; + body: FakeNode; + documentElement: FakeNode; + createElement(tag: string): FakeNode; + createElementNS(_: string, tag: string): FakeNode; + createTextNode(text: string): FakeNode; + getElementById(_: string): FakeNode | null; + activeElement: FakeNode | null; + HTMLIFrameElement: unknown; + HTMLFrameSetElement: unknown; + HTMLInputElement: unknown; + HTMLTextAreaElement: unknown; + HTMLSelectElement: unknown; + HTMLOptionElement: unknown; + HTMLAnchorElement: unknown; +} + +interface FakeWindow { + document: FakeDocument; + navigator: { userAgent: string; platform: string; maxTouchPoints: number }; + matchMedia(query: string): { matches: boolean; addEventListener(): void; removeEventListener(): void }; + addEventListener(): void; + removeEventListener(): void; + HTMLIFrameElement: unknown; + HTMLFrameSetElement: unknown; + HTMLInputElement: unknown; + HTMLTextAreaElement: unknown; + HTMLSelectElement: unknown; + HTMLOptionElement: unknown; + HTMLAnchorElement: unknown; +} + +class FakeClassList { + private readonly classes = new Set(); + add(...c: string[]): void { c.forEach((x) => this.classes.add(x)); } + remove(...c: string[]): void { c.forEach((x) => this.classes.delete(x)); } + contains(c: string): boolean { return this.classes.has(c); } + toString(): string { return [...this.classes].join(" "); } +} + +function makeNode(tag: string, owner: FakeDocument): FakeNode { + const style: Record = { + setProperty() { /* noop */ }, + getPropertyValue() { return ""; }, + }; + const node: FakeNode = { + nodeType: 1, + nodeName: tag.toUpperCase(), + tagName: tag.toUpperCase(), + ownerDocument: owner, + parentNode: null, + childNodes: [], + style, + classList: new FakeClassList(), + setAttribute() { /* noop */ }, + removeAttribute() { /* noop */ }, + hasAttribute() { return false; }, + getAttribute() { return null; }, + addEventListener() { /* noop */ }, + removeEventListener() { /* noop */ }, + appendChild(c: FakeNode) { this.childNodes.push(c); c.parentNode = this; return c; }, + insertBefore(c: FakeNode, ref: FakeNode) { + const i = this.childNodes.indexOf(ref); + if (i < 0) this.childNodes.push(c); else this.childNodes.splice(i, 0, c); + c.parentNode = this; + return c; + }, + removeChild(c: FakeNode) { + const i = this.childNodes.indexOf(c); + if (i >= 0) this.childNodes.splice(i, 1); + c.parentNode = null; + return c; + }, + contains() { return false; }, + cloneNode() { return node; }, + compareDocumentPosition() { return 0; }, + focus() { /* noop */ }, + blur() { /* noop */ }, + click() { /* noop */ }, + textContent: "", + innerHTML: "", + }; + return node; +} + +function installDomStub(): { document: FakeDocument; restore: () => void } { + const document = { + nodeType: 9, + nodeName: "#document", + tagName: "#document", + parentNode: null, + childNodes: [], + style: {}, + classList: new FakeClassList(), + setAttribute() { /* noop */ }, + getAttribute() { return null; }, + addEventListener() { /* noop */ }, + removeEventListener() { /* noop */ }, + appendChild() { return undefined; }, + insertBefore() { return undefined; }, + removeChild() { return undefined; }, + getElementById() { return null; }, + createTextNode(text: string) { + return { nodeType: 3, nodeName: "#text", textContent: text, parentNode: null } as unknown as FakeNode; + }, + createElement(tag: string) { return makeNode(tag, document as unknown as FakeDocument); }, + createElementNS(_: string, tag: string) { return makeNode(tag, document as unknown as FakeDocument); }, + activeElement: null, + HTMLIFrameElement: class {}, + HTMLFrameSetElement: class {}, + HTMLInputElement: class { setSelectionRange() { /* noop */ } }, + HTMLTextAreaElement: class { setSelectionRange() { /* noop */ } }, + HTMLSelectElement: class {}, + HTMLOptionElement: class {}, + HTMLAnchorElement: class {}, + } as unknown as FakeDocument; + + document.defaultView = { + document: document as unknown as FakeDocument, + navigator: { userAgent: "test", platform: "test", maxTouchPoints: 0 }, + matchMedia() { return { matches: false, addEventListener() {}, removeEventListener() {} }; }, + addEventListener() { /* noop */ }, + removeEventListener() { /* noop */ }, + HTMLIFrameElement: class {}, + HTMLFrameSetElement: class {}, + HTMLInputElement: class { setSelectionRange() { /* noop */ } }, + HTMLTextAreaElement: class { setSelectionRange() { /* noop */ } }, + HTMLSelectElement: class {}, + HTMLOptionElement: class {}, + HTMLAnchorElement: class {}, + } as unknown as FakeWindow; + (document.defaultView as unknown as FakeWindow).document = document as unknown as FakeDocument; + + document.body = makeNode("body", document as unknown as FakeDocument); + document.documentElement = makeNode("html", document as unknown as FakeDocument); + + // Capture previous globals so the test process is left untouched after + // each test runs. Bun's test runner shares globalThis across all tests in + // a file, so a leaked DOM stub (or a sticky IS_REACT_ACT_ENVIRONMENT) would + // bleed into unrelated tests. + const g = globalThis as unknown as { + document?: FakeDocument; + window?: FakeWindow; + navigator?: FakeWindow["navigator"]; + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + const previous = { + document: g.document, + window: g.window, + navigator: g.navigator, + IS_REACT_ACT_ENVIRONMENT: g.IS_REACT_ACT_ENVIRONMENT, + }; + + g.IS_REACT_ACT_ENVIRONMENT = true; + g.document = document; + g.window = document.defaultView; + g.navigator = document.defaultView.navigator; + + return { + document, + restore() { + g.document = previous.document; + g.window = previous.window; + g.navigator = previous.navigator; + g.IS_REACT_ACT_ENVIRONMENT = previous.IS_REACT_ACT_ENVIRONMENT; + }, + }; +} + +// --- Module mocks -------------------------------------------------------- + +// Mock the device module to return a stable non-mobile DeviceInfo so the +// SUT takes the desktop branch (which contains the bug). The mock must be +// registered before the SUT is imported. +mock.module("@/lib/device", () => ({ + useDeviceInfo: () => ({ + isMobile: false, + isTablet: false, + isDesktop: true, + deviceType: "desktop" as const, + screenWidth: 1024, + breakpoint: "lg" as const, + hasTouchInput: false, + hasTouchOnlyPointer: false, + }), + DEFAULT_DEVICE_INFO: { + isMobile: false, + isTablet: false, + isDesktop: true, + deviceType: "desktop" as const, + screenWidth: 1024, + breakpoint: "lg" as const, + hasTouchInput: false, + hasTouchOnlyPointer: false, + }, + isMobileDeviceViaCSS: () => false, + useTabletStandalonePwaRuntime: () => false, +})); + +// --- Test harness -------------------------------------------------------- + +interface ControlledProps { + initialValue: number; + min?: number; + max?: number; + step?: number; +} + +interface ControlledHandle { + recorded: number[]; + clickDecrease(): void; + clickIncrease(): void; + rerenderWith(value: number): void; + getButton(label: string): FakeNode | null; + getButtonDisabled(label: string): boolean; + typeInput(value: string): void; + blurInput(): void; + unmount(): void; +} + +function mountControlled(props: ControlledProps): ControlledHandle { + const doc = (globalThis as unknown as { document: FakeDocument }).document; + const container = doc.createElement("div"); + const root: Root = createRoot(container as unknown as Element); + + // The current value the controlled parent renders with. The parent + // deliberately DOES NOT update this synchronously inside onValueChange + // to simulate the rapid-click scenario where the prop round-trip hasn't + // completed yet. Tests that need a "re-render between clicks" must call + // `rerenderWith` explicitly. + let currentValue = props.initialValue; + const recorded: number[] = []; + + const Controlled = () => + React.createElement( + I18nProvider, + null, + React.createElement(NumberInput, { + value: currentValue, + min: props.min, + max: props.max, + step: props.step, + onValueChange: (v: number) => recorded.push(v), + }), + ); + + act(() => { + root.render(React.createElement(Controlled)); + }); + + function findButtonByAriaLabel(label: string): FakeNode | null { + function visit(node: FakeNode): FakeNode | null { + const propsKey = Object.keys(node).find((k) => k.startsWith("__reactProps")); + if (propsKey) { + const p = (node as unknown as Record)[propsKey]; + if (p && p["aria-label"] === label) return node; + } + for (const child of node.childNodes) { + const found = visit(child); + if (found) return found; + } + return null; + } + return visit(container); + } + + function clickButton(label: string): void { + const btn = findButtonByAriaLabel(label); + if (!btn) throw new Error(`Button with aria-label "${label}" not found`); + const propsKey = Object.keys(btn).find((k) => k.startsWith("__reactProps")); + if (!propsKey) throw new Error("Button has no __reactProps"); + const props = (btn as unknown as Record void }>)[propsKey]; + props.onClick({ preventDefault() { /* noop */ }, stopPropagation() { /* noop */ } }); + } + + // The is the only node in the subtree that has an `onChange` handler + // attached. Walk the container to find it. + function findInputNode(): FakeNode { + function visit(node: FakeNode): FakeNode | null { + const propsKey = Object.keys(node).find((k) => k.startsWith("__reactProps")); + if (propsKey) { + const p = (node as unknown as Record)[propsKey]; + if (p && typeof p.onChange === "function") return node; + } + for (const child of node.childNodes) { + const found = visit(child); + if (found) return found; + } + return null; + } + const node = visit(container); + if (!node) throw new Error("Input not found in container"); + return node; + } + + function readInputProps(): { + onChange: (e: unknown) => void; + onBlur: (e: unknown) => void; + } { + const input = findInputNode(); + const propsKey = Object.keys(input).find((k) => k.startsWith("__reactProps")); + if (!propsKey) throw new Error("Input has no __reactProps"); + return (input as unknown as Record void; + onBlur: (e: unknown) => void; + }>)[propsKey]; + } + + return { + recorded, + clickDecrease() { clickButton("Decrease value"); }, + clickIncrease() { clickButton("Increase value"); }, + rerenderWith(value: number) { + currentValue = value; + act(() => { + root.render(React.createElement(Controlled)); + }); + }, + getButton(label: string) { + return findButtonByAriaLabel(label); + }, + getButtonDisabled(label: string) { + const btn = findButtonByAriaLabel(label); + if (!btn) throw new Error(`Button with aria-label "${label}" not found`); + const propsKey = Object.keys(btn).find((k) => k.startsWith("__reactProps")); + if (!propsKey) throw new Error("Button has no __reactProps"); + const props = (btn as unknown as Record)[propsKey]; + return Boolean(props.disabled); + }, + typeInput(value: string) { + // Look the input up fresh each time so we always invoke the handler + // currently bound by the most recent render. + const props = readInputProps(); + act(() => { + props.onChange({ target: { value } }); + }); + }, + blurInput() { + // Re-lookup: a re-render between the last user event and blur replaces + // the props object, and we want the handler bound to the latest draft. + const props = readInputProps(); + act(() => { + props.onBlur({}); + }); + }, + unmount() { + act(() => { + root.unmount(); + }); + }, + }; +} + +// Helper: returns the most recent recorded value, or throws if the parent +// never produced a commit. Replaces the old `recorded[len-1]!` non-null +// assertions so a regression that drops the first commit fails loudly +// instead of being silently coerced to `undefined`. +function lastCommit(handle: ControlledHandle): number { + const value = handle.recorded[handle.recorded.length - 1]; + if (value === undefined) { + throw new Error("expected a recorded commit before the next step"); + } + return value; +} + +// Helper: mount, run body, unmount — avoids leaking roots between tests. +// Also restores the global DOM/window/navigator/IS_REACT_ACT_ENVIRONMENT +// globals to their pre-test values so the test process stays clean. +function withHandle(props: ControlledProps, body: (h: ControlledHandle) => T): T { + const stub = installDomStub(); + const handle = mountControlled(props); + try { + return body(handle); + } finally { + try { handle.unmount(); } catch { /* ignore */ } + stub.restore(); + } +} + +// --- Tests --------------------------------------------------------------- + +describe("NumberInput rapid-click stepper", () => { + test("markup: decrease and increase buttons render with correct aria-labels", () => { + const markup = renderToStaticMarkup( + React.createElement( + I18nProvider, + null, + React.createElement(NumberInput, { + value: 100, + min: 0, + max: 200, + step: 5, + onValueChange: () => {}, + }), + ), + ); + expect(markup).toContain('aria-label="Decrease value"'); + expect(markup).toContain('aria-label="Increase value"'); + }); + + test("rapid `-` then `+` settles to the start value (post-fix contract)", () => { + withHandle({ initialValue: 100, min: 0, max: 200, step: 5 }, (handle) => { + handle.clickDecrease(); + handle.clickIncrease(); + + // Pre-fix behavior would record [95, 105] (both clicks read the stale + // value 100). Post-fix the second click reads the committed value 95 + // from the ref, so the sequence nets to the start value 100. + expect(handle.recorded).toEqual([95, 100]); + }); + }); + + test("rapid `+` then `-` settles to the start value (post-fix contract)", () => { + withHandle({ initialValue: 100, min: 0, max: 200, step: 5 }, (handle) => { + handle.clickIncrease(); + handle.clickDecrease(); + + expect(handle.recorded).toEqual([105, 100]); + }); + }); + + test("sustained 6-click alternation settles to the start value (no drift)", () => { + withHandle({ initialValue: 100, min: 0, max: 200, step: 5 }, (handle) => { + handle.clickDecrease(); + handle.clickIncrease(); + handle.clickDecrease(); + handle.clickIncrease(); + handle.clickDecrease(); + handle.clickIncrease(); + + // Each `-` should land on a value 5 below the previous, each `+` on a + // value 5 above the previous, with the ref catching up after every + // commit. The 6 clicks net to start (100), not drift to 70 or 130. + expect(handle.recorded).toEqual([95, 100, 95, 100, 95, 100]); + }); + }); + + test("sequential clicks with a re-render between them settle correctly", () => { + withHandle({ initialValue: 100, min: 0, max: 200, step: 5 }, (handle) => { + handle.clickDecrease(); + // Simulate the real-world prop round-trip: parent re-renders with the + // latest committed value. + handle.rerenderWith(lastCommit(handle)); + handle.clickIncrease(); + handle.rerenderWith(lastCommit(handle)); + handle.clickIncrease(); + + expect(handle.recorded).toEqual([95, 100, 105]); + }); + }); + + test("clamp at min: decrease button is rendered as disabled at the lower bound", () => { + withHandle({ initialValue: 50, min: 50, max: 200, step: 5 }, (handle) => { + // The desktop stepper relies on the `disabled` attribute (browser-level + // gating) to prevent the click from firing when at the bound. The mobile + // path additionally guards inside the handler. Either way, the user + // cannot push the value below `min` through the stepper UI. + expect(handle.getButton("Decrease value")).not.toBeNull(); + expect(handle.getButtonDisabled("Decrease value")).toBe(true); + + // Sanity check: the increase button at the lower bound is enabled. + expect(handle.getButtonDisabled("Increase value")).toBe(false); + }); + }); + + test("typed-then-stepper uses the typed base (post-fix contract)", () => { + // Regression: handleBlur used to call onValueChange directly without + // updating committedValueRef, so a typed value followed by a stepper + // click would compute from the stale pre-typed ref and drift. + withHandle({ initialValue: 100, min: 0, max: 200, step: 5 }, (handle) => { + // 1) User types "110" into the input. handleChange parses a finite + // number and commits via commitValue (which writes the ref). + handle.typeInput("110"); + // 2) The parent re-renders with the latest committed value, mirroring + // a real React parent's setState round-trip. This is what makes + // `value` and `ref` agree before blur runs. + handle.rerenderWith(lastCommit(handle)); + // 3) User blurs. handleBlur sees the typed draft, the ref is already + // at 110, and `normalized !== value` is false — no duplicate commit. + handle.blurInput(); + // 4) User clicks the increase button. The stepper must read the + // ref (110) and produce 115, not 105 (which is what the pre-fix + // behavior would have produced using the stale ref=100). + handle.clickIncrease(); + + // The exact recorded sequence after a full prop round-trip: one + // commit from the typed change, none from the blur (suppressed by + // the `normalized !== value` gate), and one from the stepper. + expect(handle.recorded).toEqual([110, 115]); + }); + }); + + test("typed value below min is clamped on blur and the stepper respects the clamped base", () => { + withHandle({ initialValue: 100, min: 50, max: 200, step: 5 }, (handle) => { + // 1) User types "20" (below min). handleChange commits 20, which + // commitValue clamps to 50 before emitting onValueChange. + handle.typeInput("20"); + // 2) Parent re-renders with the clamped value 50. + handle.rerenderWith(lastCommit(handle)); + // 3) Blur: the draft "20" parses to 20, clamps to 50, normalizes to 50. + // `50 !== value(50)` is false — no duplicate commit. + handle.blurInput(); + // 4) Click increase: stepper reads the ref (50) and produces 55. + handle.clickIncrease(); + + expect(handle.recorded).toEqual([50, 55]); + + // At the lower bound, the decrease button must be disabled. + expect(handle.getButtonDisabled("Decrease value")).toBe(true); + // And the increase button must remain enabled. + expect(handle.getButtonDisabled("Increase value")).toBe(false); + }); + }); +}); diff --git a/packages/ui/src/components/ui/number-input.tsx b/packages/ui/src/components/ui/number-input.tsx index 9c83c4b3..d582c4bf 100644 --- a/packages/ui/src/components/ui/number-input.tsx +++ b/packages/ui/src/components/ui/number-input.tsx @@ -110,10 +110,26 @@ const NumberInput = React.forwardRef( return 0 }, [fallbackValue, min, value]) + // Tracks the most recent user-committed snapshot so back-to-back clicks + // within the same render cycle operate on the latest value, not the stale + // `value` prop (which only updates after the parent re-renders in response + // to onValueChange). See packages/ui/src/components/ui/number-input.test.tsx. + const committedValueRef = React.useRef(baseValue) + + // Assumes a well-behaved controlled parent: when the parent updates the + // `value` prop, the effect syncs the ref. If a parent ever rejects or + // debounces `onValueChange`, the ref can briefly lead the prop. Today no + // production caller rejects; revisit if a debounced caller is added. + React.useEffect(() => { + committedValueRef.current = baseValue + }, [baseValue]) + const commitValue = React.useCallback( (rawValue: number) => { const clamped = clamp(rawValue, min, max) - onValueChange(normalizeToStep(clamped, step)) + const normalized = normalizeToStep(clamped, step) + committedValueRef.current = normalized + onValueChange(normalized) }, [max, min, onValueChange, step] ) @@ -155,14 +171,21 @@ const NumberInput = React.forwardRef( const clamped = clamp(parsed, min, max) const normalized = normalizeToStep(clamped, step) if (normalized !== value) { - onValueChange(normalized) + // Route through commitValue so committedValueRef stays in sync with + // the typed value. Without this, a typed-then-stepper sequence + // would read a stale ref and drift. See number-input.test.tsx. + commitValue(parsed) + } else { + // No effective change, but keep the ref aligned with the prop in + // case it diverged via the baseValue useEffect. + committedValueRef.current = normalized } setDraft(String(normalized)) } onBlur?.(event) }, - [draft, max, min, onBlur, onClear, onValueChange, step, value] + [commitValue, draft, max, min, onBlur, onClear, step, value] ) const incrementDisabled = Boolean(disabled || baseValue >= max) @@ -170,13 +193,13 @@ const NumberInput = React.forwardRef( const handleMobileDecrement = () => { if (!decrementDisabled) { - commitValue(baseValue - step) + commitValue(committedValueRef.current - step) } } const handleMobileIncrement = () => { if (!incrementDisabled) { - commitValue(baseValue + step) + commitValue(committedValueRef.current + step) } } @@ -269,7 +292,7 @@ const NumberInput = React.forwardRef( type="button" aria-label={t('numberInput.actions.decreaseAria')} disabled={decrementDisabled} - onClick={() => commitValue(baseValue - step)} + onClick={() => commitValue(committedValueRef.current - step)} className={cn( "flex h-full w-7 items-center justify-center overflow-x-hidden overflow-y-hidden border-r border-border p-0 leading-none touch-manipulation", "text-muted-foreground hover:bg-interactive-hover hover:text-foreground", @@ -303,7 +326,7 @@ const NumberInput = React.forwardRef( type="button" aria-label={t('numberInput.actions.increaseAria')} disabled={incrementDisabled} - onClick={() => commitValue(baseValue + step)} + onClick={() => commitValue(committedValueRef.current + step)} className={cn( "flex h-full w-7 items-center justify-center overflow-x-hidden overflow-y-hidden border-l border-border p-0 leading-none touch-manipulation", "text-muted-foreground hover:bg-interactive-hover hover:text-foreground",