feat(Brand kit): apply color edits optimistically with rollback

Fixes: #3591930

Why

Saving a Brand kit color edit blocked on the network before anything visible changed, and held the form open on a spinner until the response arrived. awaited , and only then did the tag invalidation refetch the Brand kit and update the color row — two serialized round trips before the user saw the color they picked.

Investigating and verifying this in a browser surfaced three further defects, all fixed here:

  • Color writes were invisible while a draft existed. Mutations invalidated only , but prefers . With any Brand kit auto-save draft present (a prior font edit creates one), a color write was not reflected at all.
  • The page preview never updated. It receives Brand kit colors as a server-rendered stylesheet attached by and only re-requests it on a full editor load. Confirmed in a browser: after changing a color to , the preview still reported until the editor was reloaded.
  • A rejected delete failed silently. The optimistic removal unmounts the row and the delete popover inside it, discarding the error — the row simply reappeared with no explanation.

What is not the problem

  • The picker is already instant. Dragging it only dispatches into 's local ; no request fires until Save. There is no per-pointer-move write storm, so no debouncing was added — it would be dead code.
  • Colors are outside the undo timeline. wraps only and in . Verified in the browser: two color edits left Undo and Redo disabled.

What changed

  • / patch the RTK Query cache in and undo on rejection.
  • Both cache entries the UI reads (, ) are patched, and all three color mutations also invalidate .
  • In-flight writes are retained and re-applied on top of any Brand kit response, so a read landing mid-write cannot revert the optimistic value.
  • The color form closes as soon as an edit is applied, instead of waiting on a round trip the user is no longer blocked by.
  • Update and delete both use shared s so the colors section can report a rejection after the form has closed or the row has gone. The reset-on-close no longer clears the update state, since closing is exactly when that error must survive; the next edit clears it.
  • The page preview gets the current colors injected as CSS custom properties, re-applied on preview load. The hook subscribes to the Brand kit query so its cache entry stays reconciled even when the Brand kit panel is closed.
  • Creation still waits: the server assigns the UUID, there is no optimistic row to show meanwhile, and closing early would discard a part-typed name, variable, and color on failure.

Hazards of this pattern, and how they are handled

An inverse patch can clobber a concurrent edit. The mutator changes one color in place, so immer records a patch at whose inverse touches only that path.

A stale failure can revert a newer edit. Each write records a token per color id and rolls back only while still newest.

A rollback can miss the preview. The preview must not read state that only the Brand kit panel maintains, nor an unsubscribed cache entry that invalidation never refetches — both leave a rejected or stale color on screen once the panel closes. Holding a subscription keeps it reconciled.

A shared cache key changes what a submit guard means. now reflects any in-flight edit, including one from a closed form, so the guard narrows to creation only; otherwise a second edit made while the first was still in flight would be silently dropped.

Tests

Seven Vitest cases in , driving the real mutation lifecycle against a stubbed transport that holds each write open so ordering is deterministic.

Each protection was confirmed load-bearing by removing it and watching the matching test fail:

Removed Test that fails
the guard
the in-place mutator (made coarse)
re-application on read

Full Vitest suite: no regressions — 46 failures before and after, all pre-existing on upstream (verified on a clean tree); passing count 720 → 727.

ESLint, Prettier, cspell and clean on the changed files.

Manual verification

Done in a real browser on a live ddev site, intercepting the write path to force failures:

  • Optimistic apply — the row swatch changed 8 ms after the click, before the response.
  • Form closes on save — a rejected edit traced white applied at 24 ms with the form already closed, rolled back at 70 ms, and reported "Failed to update color" in the colors section. A successful edit closed the form and persisted ( in row, preview, and storage), clearing the error.
  • Rollback — with the PATCH aborted, the swatch traced ; the server still held the original color.
  • Preview — tracked the change immediately (), matching row and storage.
  • Preview with the panel shut — a rejected edit made while the panel was closed mid-write still rolled the preview back to the stored color (), matching storage.
  • Rapid changes — three successive edits (blue, purple, yellow) settled on yellow in row, preview, and storage.
  • Delete — the row disappeared at once and reappeared 455 ms later when rejected, with the failure reported; a subsequent successful delete removed it and cleared the error.
  • Create — still waits, then closes and adds the row ( in storage).
  • Undo/redo — two color edits left Undo and Redo disabled. With a page-title change in the timeline, undo reverted the title and redo restored it; neither touched the colors.

Caveat

The three Playwright specs parse and register () but are not executed by this CI — the fork's job runs only (). The behaviors they cover were verified by hand as above, but the specs themselves are unproven until a full Playwright run exercises them.

🤖 Generated with Claude Code

Edited by Lauri Timmanee

Merge request reports

Loading