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.