Skip to content

[Color Picker] Only trigger onChange callback when internal change is made - #260

Open
joan-teriihoania wants to merge 1 commit into
shadcnblocks:mainfrom
joan-teriihoania:feature/color-picker-onchange-callback
Open

[Color Picker] Only trigger onChange callback when internal change is made#260
joan-teriihoania wants to merge 1 commit into
shadcnblocks:mainfrom
joan-teriihoania:feature/color-picker-onchange-callback

Conversation

@joan-teriihoania

@joan-teriihoania joan-teriihoania commented Sep 11, 2025

Copy link
Copy Markdown

Description

Hello!

Currently, the ColorPicker component will trigger the onChange callback when:

  1. the user interacts with the component and updates the hue, lightness, saturation, and alpha values
  2. the component is loaded (at initialisation)
  3. the value prop is updated

Semantically and logically, we should expect the onChange callback to be triggered when an internal value is changed by the user, and the parent component needs to be informed of that change. This is what point 1 is doing.

In the same thinking, I do not believe that the callback should be triggered at initialisation (point 2), I would expect something like onInit to be used for this.

Finally, onChange should not be triggered when the internal value state was updated by an external state change. In this case, point 3 is an externally-triggered state change that causes an internal update. Not only could this cause an infinite update loop, but this also goes against the usual behaviour of other HTML elements' onChange callbacks which are not triggered when the value is updated programatically, but only through an explicit user interaction. Plus, onChange usually serves as a way to inform the parent of a state change. But in this case, the state change was caused by the parent and thus doesn't need to be notified.

For all these reasons, I propose to:

  • only call the onChange callback if a state update was made by an internal state change call. This is what the isDirty ref is used for, and why I have made so that a setter call to the provider will mark the current state change as dirty which will allow for an onChange call;
  • and skip any state update that was caused by an update to the value prop. Which is why I am marking any state change made by it as "clean".

Checklist

  • My code follows the code style of this project.
  • I have performed a self-review of my code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have updated the documentation, if necessary.
  • I have added tests that prove my fix is effective or my feature works.
  • New and existing tests pass locally with my changes.

Additional notes

This is one my first few open-source MRs! Please let me know if I am doing something wrong!

Summary by CodeRabbit

  • Bug Fixes
    • Color Picker now triggers change events only for user interactions.
    • External updates to the selected color no longer emit change callbacks.
    • Improved synchronization of hue, saturation, lightness, and alpha when the controlled value updates.
    • More predictable behavior in controlled integrations with fewer unexpected callbacks.

…-tied internal change was made. Do not trigger it due to external change (aka: init, or value prop change)
@vercel

vercel Bot commented Sep 11, 2025

Copy link
Copy Markdown

@joan-teriihoania is attempting to deploy a commit to the Haste Ventures Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Sep 11, 2025

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Added internal dirty-tracking to distinguish user-initiated color changes from external prop updates. Wrapped setter methods to mark changes as dirty. On external value changes, state is marked clean before syncing color channels. onChange now fires only for dirty (user-driven) updates.

Changes

Cohort / File(s) Summary
Dirty-tracking and guarded callbacks
packages/color-picker/index.tsx
Added isDirty ref with markAsDirty/markAsClean. Wrapped setHue/SetSaturation/setLightness/setAlpha to mark dirty on user interactions. On value prop change, mark clean then update HSLA. onChange now invoked only when dirty. Added clarifying comments.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Prop as External prop (value)
  participant CP as ColorPicker
  participant State as HSLA State
  participant CB as onChange

  rect rgba(200,200,255,0.25)
  note over Prop,CP: External update flow (clean)
  Prop->>CP: value prop changes
  CP->>CP: markAsClean()
  CP->>State: update hue/sat/light/alpha
  CP--xCB: skip onChange (not dirty)
  end

  rect rgba(200,255,200,0.25)
  note over CP,CB: User interaction flow (dirty)
  CP->>CP: setHue/setSaturation/setLightness/setAlpha (wrapped)
  CP->>CP: markAsDirty()
  CP->>State: apply channel update
  alt onChange provided
    CP->>CB: onChange(newColor)
  else
    CP-->>CP: no-op
  end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.

Pre-merge checks (3 passed)

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly and concisely summarizes the primary change: the ColorPicker will only trigger onChange for internal (user-initiated) updates. It names the affected component and avoids unnecessary detail, making it easy for reviewers to understand the main intent at a glance.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
Description Check ✅ Passed The PR description clearly explains the change, motivation, and implementation (use of an isDirty ref and setter wrappers) and includes a checklist and additional notes, covering the template's main sections. It is missing the required "Related Issues" line (Closes #<issue_number>) and the checklist indicates tests have not been added, which should be addressed. Overall the description is sufficiently detailed and actionable for review.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/color-picker/index.tsx (1)

98-112: Critical: external value sync is mapping RGB → HSL state incorrectly (and alpha scale is wrong).

You’re setting HSL state using r/g/b channels and passing alpha in [0–1] to state that expects [0–100]. Convert the incoming value to HSL and scale alpha before updating.

Apply this diff:

   // Update color when controlled value changes
   useEffect(() => {
-    if (value) {
-      const color = Color.rgb(value).rgb().object();
-
-      // Changes to the value prop are considered external changes
-      // and should not trigger an onChange event.
-      markAsClean()
-
-      setHue(color.r);
-      setSaturation(color.g);
-      setLightness(color.b);
-      setAlpha(color.a);
-    }
+    if (value != null) {
+      const c = Color(value);
+      const [h, s, l] = c.hsl().array();
+
+      // External sync: do not emit onChange.
+      markAsClean();
+
+      setHue(h);
+      setSaturation(s);
+      setLightness(l);
+      setAlpha(Math.round(c.alpha() * 100));
+    }
   }, [value]);
🧹 Nitpick comments (1)
packages/color-picker/index.tsx (1)

132-147: Preserve function types; avoid rest parameters in setters.

Using (...args) widens the type and can mask mistakes. Keep the signature (n: number) => void per context contract.

-        setHue: (...args) => {
-          markAsDirty();
-          setHue(...args)
-        },
+        setHue: (n: number) => {
+          markAsDirty();
+          setHue(n);
+        },
-        setSaturation: (...args) => {
-          markAsDirty();
-          setSaturation(...args)
-        },
+        setSaturation: (n: number) => {
+          markAsDirty();
+          setSaturation(n);
+        },
-        setLightness: (...args) => {
-          markAsDirty();
-          setLightness(...args)
-        },
+        setLightness: (n: number) => {
+          markAsDirty();
+          setLightness(n);
+        },
-        setAlpha: (...args) => {
-          markAsDirty();
-          setAlpha(...args)
-        },
+        setAlpha: (n: number) => {
+          markAsDirty();
+          setAlpha(n);
+        },
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between df5213a and 3ec6f55.

📒 Files selected for processing (1)
  • packages/color-picker/index.tsx (4 hunks)
🔇 Additional comments (2)
packages/color-picker/index.tsx (2)

72-83: LGTM on dirty-tracking.

The isDirty ref + markAsDirty/markAsClean pattern cleanly gates user-initiated updates.


116-116: Gated onChange looks correct.

Emits only when dirty; aligns with the PR’s goal and avoids loops when parent echoes value back.

@vercel

vercel Bot commented Sep 19, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
kibo Ready Ready Preview Comment Sep 19, 2025 4:57am

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.

1 participant