Skip to content

fix(ColorPicker): fix state updates for react 19#2646

Merged
DakEnviy merged 1 commit intomainfrom
fix/color-picker-state
Apr 9, 2026
Merged

fix(ColorPicker): fix state updates for react 19#2646
DakEnviy merged 1 commit intomainfrom
fix/color-picker-state

Conversation

@DakEnviy
Copy link
Copy Markdown
Contributor

@DakEnviy DakEnviy commented Apr 8, 2026

Summary by Sourcery

Adjust ColorPicker state synchronization to be compatible with React 19 and reduce redundant updates.

Enhancements:

  • Rework HSVA and text input state updates to rely on debounced color propagation instead of internal update flags.
  • Simplify input mode and value handling by using direct state setters and always deriving text input from HSVA state.
  • Ensure HSVA state is derived from the current effective color while avoiding unnecessary state changes when the color is unchanged.
  • Introduce a shared debounce interval constant for color updates in the ColorPicker.

@DakEnviy DakEnviy requested a review from Estasie as a code owner April 8, 2026 16:44
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 8, 2026

Reviewer's Guide

Refactors ColorPicker’s internal color/HSVA/input synchronization to be React 19–friendly by removing the internal update ref, deriving state more predictably, and debouncing color updates, while adding a debounce constant.

Sequence diagram for HSVA updates with debounced color propagation

sequenceDiagram
    actor User
    participant ColorControl
    participant ColorPicker
    participant DebouncedUpdate as updateColorByHsva
    participant ParentComponent

    User->>ColorControl: change color (drag/interaction)
    ColorControl->>ColorPicker: updateHsva(updates)

    activate ColorPicker
    ColorPicker->>ColorPicker: setHsva(merge prevHsva, updates)
    ColorPicker->>ColorPicker: isValidHsva(nextHsva) && !isSameHsva
    ColorPicker->>DebouncedUpdate: schedule nextHsva
    deactivate ColorPicker

    Note over DebouncedUpdate: wait UPDATE_COLOR_DEBOUNCE_TIME

    DebouncedUpdate->>ColorPicker: setColor(withAlpha ? hsvaToHexa : hsvaToHex)
    ColorPicker->>ParentComponent: onUpdate(newColor) via useControlledState

    ParentComponent-->>ColorPicker: passes updated value prop
    ColorPicker->>ColorPicker: effectiveColor = color.trim() || DEFAULT_COLOR
    ColorPicker->>ColorPicker: effect parses effectiveColor to hsva
Loading

Class diagram for updated ColorPicker state management

classDiagram
    class ColorPicker {
        +anchor : HTMLDivElement | null
        +color : string
        +isOpen : boolean
        +modeState : Modes
        +hsva : HsvaColor
        +inputValue : string
        +withAlpha : boolean
        +effectiveColor : string
        +ColorPicker(props) React.FC
        +updateHsva(updates : Partial_HsvaColor_) void
        +resetInputValue() void
        +applyInputValue() void
    }

    class ColorPickerConstants {
        +DEFAULT_COLOR : string
        +UPDATE_COLOR_DEBOUNCE_TIME : number
    }

    class HsvaColor {
        +h : number
        +s : number
        +v : number
        +a : number
    }

    class Modes {
    }

    class HooksAndUtils {
        +useControlledState(value : string, defaultValue : string, onUpdate : Function) string
        +parseColorToHsva(color : string, withAlpha : boolean) ParsedColor
        +hsvaToHex(hsva : HsvaColor) string
        +hsvaToHexa(hsva : HsvaColor) string
        +getTextValueByMode(hsva : HsvaColor, mode : Modes, withAlpha : boolean) string
        +isSameHsva(a : HsvaColor, b : HsvaColor) boolean
        +isValidHsva(hsva : HsvaColor) boolean
        +debounce(fn : Function, delay : number) Function
    }

    class ParsedColor {
        +hsva : HsvaColor
        +isValid : boolean
    }

    class UpdateColorByHsva {
        +updateColorByHsva(nextHsva : HsvaColor) void
    }

    ColorPickerConstants <.. ColorPicker : uses
    HooksAndUtils <.. ColorPicker : uses
    ParsedColor <.. HooksAndUtils : returns
    HsvaColor <.. ParsedColor : contains
    HsvaColor <.. ColorPicker : state
    Modes <.. ColorPicker : state
    UpdateColorByHsva <.. ColorPicker : has
Loading

Flow diagram for ColorPicker color, HSVA, and input synchronization

flowchart TD
    A[value prop or defaultValue] --> B["derive color via useControlledState"]
    B --> C["effectiveColor = color.trim() || DEFAULT_COLOR"]

    C --> D["effect: parseColorToHsva(effectiveColor, withAlpha)"]
    D -->|valid| E["setHsva(parsed.hsva) if different by color"]

    E --> F["effect: setInputValue(getTextValueByMode(hsva, modeState, withAlpha))"]

    subgraph UserInput
        G["user edits HexInput"] --> H["setInputValue(new text)"]
        H --> I["onBlur applyInputValue"]
        I --> J["parseColorToHsva(inputValue, withAlpha)"]
        J -->|valid| K["setHsva(normalized.hsva)"]
        K --> L["setColor(hsvaToHex or hsvaToHexa)"]
        K --> F
    end

    subgraph DragControls
        M["user drags picker controls"] --> N["updateHsva(partial hsva)"]
        N --> O["setHsva(merged hsva) if valid and changed"]
        O --> P["debounced updateColorByHsva(hsva)"]
        P --> L
        O --> F
    end
Loading

File-Level Changes

Change Details Files
Rework how HSVA state derives from the external color value to avoid stale or redundant updates.
  • Derives effectiveColor directly from the controlled color value instead of handling null/undefined and trimming in multiple places.
  • Initializes HSVA state from the current effectiveColor instead of a separate initialColor derived from value/defaultValue.
  • Updates HSVA in an effect by comparing the previous HSVA’s hex/hexA representation to effectiveColor to avoid unnecessary state changes.
src/components/lab/ColorPicker/ColorPicker.tsx
Replace the internal update ref and useUpdateEffect pattern with a debounced, memoized updater that syncs HSVA changes back to the controlled color value.
  • Removes isInternalUpdateRef and the useUpdateEffect that conditionally pushed HSVA changes to color based on that ref.
  • Introduces updateColorByHsva, a debounced function created with useMemo that converts HSVA to hex/hexA and calls setColor.
  • Integrates updateColorByHsva inside updateHsva’s state setter, only invoking it when the next HSVA is valid and different from the previous value.
  • Simplifies the flow where parsed input sets HSVA, color, and inputValue by removing reliance on the internal update ref.
src/components/lab/ColorPicker/ColorPicker.tsx
src/components/lab/ColorPicker/constants.ts
Simplify mode and input value handling to be derived consistently from HSVA and mode state.
  • Moves modeState and inputValue initialization after HSVA so the initial text value can be computed from the existing HSVA and mode.
  • Simplifies the effect that syncs inputValue from HSVA and mode to always set the derived value without diffing.
  • Inlines handleModeChange into a direct setModeState call and handleInputChange into direct setInputValue usage in JSX.
src/components/lab/ColorPicker/ColorPicker.tsx
Introduce a shared debounce timing constant for color updates.
  • Adds UPDATE_COLOR_DEBOUNCE_TIME constant in ColorPicker constants module with a value of 200ms.
  • Uses UPDATE_COLOR_DEBOUNCE_TIME when creating the debounced updateColorByHsva function.
src/components/lab/ColorPicker/constants.ts
src/components/lab/ColorPicker/ColorPicker.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The effectiveColor initialization now calls color.trim() directly, which will throw if color is undefined or null; consider restoring the defensive color?.trim() || DEFAULT_COLOR pattern or equivalent guarding.
  • The modeState state variable is declared after it is first used in the inputValue useState initializer (getTextValueByMode(hsva, modeState, withAlpha)), which will cause a runtime reference error due to the temporal dead zone; move the modeState declaration above any usage.
  • The new hsva sync effect compares prevColor (derived from prevHsva) to effectiveColor via strict string equality, which may fail for equivalent colors with different string representations (e.g., case differences or normalized alpha); consider normalizing both sides or comparing via parsed HSVA instead.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `effectiveColor` initialization now calls `color.trim()` directly, which will throw if `color` is `undefined` or `null`; consider restoring the defensive `color?.trim() || DEFAULT_COLOR` pattern or equivalent guarding.
- The `modeState` state variable is declared after it is first used in the `inputValue` `useState` initializer (`getTextValueByMode(hsva, modeState, withAlpha)`), which will cause a runtime reference error due to the temporal dead zone; move the `modeState` declaration above any usage.
- The new `hsva` sync effect compares `prevColor` (derived from `prevHsva`) to `effectiveColor` via strict string equality, which may fail for equivalent colors with different string representations (e.g., case differences or normalized alpha); consider normalizing both sides or comparing via parsed HSVA instead.

## Individual Comments

### Comment 1
<location path="src/components/lab/ColorPicker/ColorPicker.tsx" line_range="133-134" />
<code_context>
-            setColor(nextHexValue);
-        }
-    }, [hsva, withAlpha, color, setColor]);
+    const updateColorByHsva = React.useMemo(() => {
+        return debounce((nextHsva: HsvaColor) => {
+            setColor(withAlpha ? hsvaToHexa(nextHsva) : hsvaToHex(nextHsva));
+        }, UPDATE_COLOR_DEBOUNCE_TIME);
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The debounced `updateColorByHsva` function may outlive the component lifecycle and call `setColor` after unmount.

Unless this `debounce` implementation cancels pending calls on unmount, delayed invocations can still call `setColor` after unmount or after `withAlpha` has changed. Consider canceling the debounced function in a cleanup effect, or using a debounce helper that does this automatically, to avoid state updates on unmounted components and race conditions when `withAlpha` toggles.

Suggested implementation:

```typescript
    const updateColorByHsva = React.useMemo(() => {
        return debounce((nextHsva: HsvaColor) => {
            setColor(withAlpha ? hsvaToHexa(nextHsva) : hsvaToHex(nextHsva));
        }, UPDATE_COLOR_DEBOUNCE_TIME);
    }, [setColor, withAlpha]);

    React.useEffect(() => {
        // Ensure no pending debounced calls fire after unmount or when the debounced
        // instance is replaced (e.g. when `withAlpha` changes).
        return () => {
            if (typeof (updateColorByHsva as any)?.cancel === 'function') {
                (updateColorByHsva as any).cancel();
            }
        };
    }, [updateColorByHsva]);

    const updateHsva = React.useCallback(

```

If your `debounce` helper exposes a different API for canceling (e.g. `clear` instead of `cancel`), update the cleanup accordingly:

- Replace `.cancel()` with the appropriate method name.
- If the debounced function type is already declared somewhere in the codebase, you can replace the `any` cast with that proper type for stronger typing.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +133 to +134
const updateColorByHsva = React.useMemo(() => {
return debounce((nextHsva: HsvaColor) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): The debounced updateColorByHsva function may outlive the component lifecycle and call setColor after unmount.

Unless this debounce implementation cancels pending calls on unmount, delayed invocations can still call setColor after unmount or after withAlpha has changed. Consider canceling the debounced function in a cleanup effect, or using a debounce helper that does this automatically, to avoid state updates on unmounted components and race conditions when withAlpha toggles.

Suggested implementation:

    const updateColorByHsva = React.useMemo(() => {
        return debounce((nextHsva: HsvaColor) => {
            setColor(withAlpha ? hsvaToHexa(nextHsva) : hsvaToHex(nextHsva));
        }, UPDATE_COLOR_DEBOUNCE_TIME);
    }, [setColor, withAlpha]);

    React.useEffect(() => {
        // Ensure no pending debounced calls fire after unmount or when the debounced
        // instance is replaced (e.g. when `withAlpha` changes).
        return () => {
            if (typeof (updateColorByHsva as any)?.cancel === 'function') {
                (updateColorByHsva as any).cancel();
            }
        };
    }, [updateColorByHsva]);

    const updateHsva = React.useCallback(

If your debounce helper exposes a different API for canceling (e.g. clear instead of cancel), update the cleanup accordingly:

  • Replace .cancel() with the appropriate method name.
  • If the debounced function type is already declared somewhere in the codebase, you can replace the any cast with that proper type for stronger typing.

@gravity-ui
Copy link
Copy Markdown
Contributor

gravity-ui Bot commented Apr 8, 2026

Preview is ready.

@gravity-ui
Copy link
Copy Markdown
Contributor

gravity-ui Bot commented Apr 8, 2026

🎭 Component Tests Report is ready.

@DakEnviy DakEnviy merged commit add88fd into main Apr 9, 2026
6 of 7 checks passed
@DakEnviy DakEnviy deleted the fix/color-picker-state branch April 9, 2026 11:56
@gravity-ui gravity-ui Bot mentioned this pull request Apr 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants