Skip to content

fix(react): apply controlled updates against the latest snapshot - #210

Merged
pbroom merged 4 commits into
mainfrom
fix/react-controlled-state
Oct 10, 2026
Merged

pbroom merged 4 commits into
mainfrom
fix/react-controlled-state

Conversation

@pbroom

@pbroom pbroom commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Controlled useColor/useMultiColor read state from the render closure, so
two updates in one tick (e.g. setChannel('l') then setChannel('c')) each
started from the same stale value and the second overwrote the first.

  • Add an internal useLatestSnapshot (color-store.ts): updates write a
    pending snapshot keyed to the committed value; reads return it until the
    controlled prop (or committed uncontrolled state) changes. Refs are only
    touched in event handlers and a layout effect, never during render.
  • onChange timing: both hooks now call onChange synchronously inside the
    setter, before React re-renders, in controlled and uncontrolled modes,
    once per effective update and never for no-ops. Uncontrolled useMultiColor
    previously emitted from a post-commit effect; it now computes the event
    eagerly from the latest snapshot and still commits via
    setInternalModel(prev => ...). Synchronous emission makes it StrictMode-
    safe without the post-commit sequence-dedupe queue, which is removed.
  • Move single-color transitions into driver reducers (setColorRequested,
    setColorChannel, setColorActiveGamut, setColorActiveView) and use them
    from useColor; no-op semantics are unchanged.
  • removeColor drops its unused source param (hard cut-over; removal has
    no entry left to carry a source).
  • useColor hex/rgb/hsl/hsv/oklch are lazy getters cached per requested
    color, so the non-reactive provider no longer converts every render.
  • Docs: document onChange timing and controlled composition.

Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with pnpm build and pnpm test; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack.

🤖 Generated with Claude Code

pbroom commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Refactors how controlled state updates compose in color hooks.

The PR should not merge until the new reducers honor explicit gamut-mapping overrides for unchanged colors.

Fix All in CursorFindings

  1. P1 Mapping override is ignored ▶

Summary

This PR composes same-tick controlled updates from a pending snapshot, makes multi-color notifications synchronous, adds single-color driver reducers, and lazily caches requested-color conversions.

  • The new reducers can ignore an explicit gamut-mapping override when the color or channel is unchanged.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Committed state] --> B[Read latest snapshot]
  B --> C[Apply reducer]
  C --> D{State changed?}
  D -- No --> E[Return without notification]
  D -- Yes --> F[Record pending update]
  F --> G[Notify onChange synchronously]
  F --> H[React commit]
  H --> I[Clear pending update; use committed state]
Loading

Reviews (1) · Last reviewed commit: "fix(react): apply controlled updates aga..."

Comment thread packages/driver/src/color-state.ts
@vercel

vercel Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
color-kit Ready Ready Preview Oct 10, 2026 7:13pm UTC

pbroom commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner Author

Merge activity

  • Oct 10, 6:47 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Oct 10, 7:08 PM UTC: Graphite rebased this pull request as part of a merge.
  • Oct 10, 7:09 PM UTC: @pbroom merged this pull request with Graphite.

@pbroom
pbroom changed the base branch from feat/core-gamut-map-method to graphite-base/210 October 10, 2026 19:05
@pbroom
pbroom changed the base branch from graphite-base/210 to main October 10, 2026 19:06
pbroom and others added 4 commits October 10, 2026 19:07
Controlled useColor/useMultiColor read `state` from the render closure, so
two updates in one tick (e.g. setChannel('l') then setChannel('c')) each
started from the same stale value and the second overwrote the first.

- Add an internal `useLatestSnapshot` (color-store.ts): updates write a
  pending snapshot keyed to the committed value; reads return it until the
  controlled prop (or committed uncontrolled state) changes. Refs are only
  touched in event handlers and a layout effect, never during render.
- onChange timing: both hooks now call onChange synchronously inside the
  setter, before React re-renders, in controlled and uncontrolled modes,
  once per effective update and never for no-ops. Uncontrolled useMultiColor
  previously emitted from a post-commit effect; it now computes the event
  eagerly from the latest snapshot and still commits via
  setInternalModel(prev => ...). Synchronous emission makes it StrictMode-
  safe without the post-commit sequence-dedupe queue, which is removed.
- Move single-color transitions into driver reducers (setColorRequested,
  setColorChannel, setColorActiveGamut, setColorActiveView) and use them
  from useColor; no-op semantics are unchanged.
- removeColor drops its unused `source` param (hard cut-over; removal has
  no entry left to carry a source).
- useColor hex/rgb/hsl/hsv/oklch are lazy getters cached per requested
  color, so the non-reactive <Color> provider no longer converts every render.
- Docs: document onChange timing and controlled composition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
setColorRequested and setColorChannel returned the old state when only
the requested gamutMapMethod differed, so the override was ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…fects

useLatestSnapshot refreshed its committed ref in a layout effect, which
runs after descendants' layout effects, so a child calling a setter from
useLayoutEffect composed onto the previous controlled state (l=0.2
instead of 0.8). Sync the ref in an insertion effect, which React runs
for the whole tree before any layout effect; no ref is touched during
render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…effects

useLatestSnapshot refreshed committedRef in an insertion effect but still
dropped the pending write in its layout effect, which runs after
descendants'. When a controlled parent rejected an update, a child that
called a setter from useLayoutEffect on that commit composed onto the
rejected write (l=0.9,h=90 instead of l=0.6,h=90), in useColor and
useMultiColor. Expire the pending write in the same insertion effect;
same-tick composition and reject-then-accept are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@pbroom
pbroom force-pushed the fix/react-controlled-state branch from 7e9b72f to 546f702 Compare October 10, 2026 19:07
@pbroom
pbroom merged commit 1a32135 into main Oct 10, 2026
6 of 7 checks passed
@pbroom
pbroom deleted the fix/react-controlled-state branch October 10, 2026 19:09

This branch was successfully deployed

1 active deployment
Preview — 546f7020 Deployed Oct 10, 2026 by vercel[bot]
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