From a51a1dd9ad04826add503344b10dcf02918d4b6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 20:32:02 +0000 Subject: [PATCH 1/2] fix(web): keep event color responsive during optimistic replace Discard grid drafts only after replace's optimistic cache write, matching create's onOptimisticApplied pattern, so color changes no longer flash the previous color while cancelQueries awaits. Co-authored-by: Tyler Dane --- .../mutations/useEventMutations.test.tsx | 82 ++++++++++ .../src/events/mutations/useEventMutations.ts | 15 +- .../src/views/Forms/hooks/useSaveEventForm.ts | 8 +- .../Forms/hooks/useSetEventColor.test.tsx | 149 ++++++++++++++++++ .../src/views/Forms/hooks/useSetEventColor.ts | 19 ++- 5 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 packages/web/src/views/Forms/hooks/useSetEventColor.test.tsx diff --git a/packages/web/src/events/mutations/useEventMutations.test.tsx b/packages/web/src/events/mutations/useEventMutations.test.tsx index e6cb16bfd6..fc543763e8 100644 --- a/packages/web/src/events/mutations/useEventMutations.test.tsx +++ b/packages/web/src/events/mutations/useEventMutations.test.tsx @@ -1310,6 +1310,88 @@ describe("useEventMutations", () => { }); context.pending.resolve(); }); + + test("runs replace onOptimisticApplied after the optimistic cache write", async () => { + const context = setup(); + const original = event({ + content: { + kind: "details", + title: "Original", + description: "", + color: "blue", + }, + }); + context.queryClient.setQueryData(calendarKey, normalized(original)); + + const onOptimisticApplied = mock(() => {}); + let colorAtCallback: unknown; + onOptimisticApplied.mockImplementation(() => { + colorAtCallback = ( + context.queryClient.getQueryData(calendarKey) + ?.entities[original.id].content as { color?: string } + ).color; + }); + + act(() => + context.hook.result.current.mutations.replace( + replacePayload(original.id, { + content: { + kind: "details", + title: "Original", + description: "", + location: "", + color: "coral", + }, + }), + { onOptimisticApplied }, + ), + ); + + await waitFor(() => { + expect(onOptimisticApplied).toHaveBeenCalledTimes(1); + }); + expect(colorAtCallback).toBe("coral"); + + context.pending.resolve(); + }); + + test("clears colorHex on optimistic replace when a slot color is written", async () => { + const context = setup(); + const original = event({ + content: { + kind: "details", + title: "Original", + description: "", + color: "blue", + colorHex: "#009688", + }, + }); + context.queryClient.setQueryData(calendarKey, normalized(original)); + + act(() => + context.hook.result.current.mutations.replace( + replacePayload(original.id, { + content: { + kind: "details", + title: "Original", + description: "", + location: "", + color: "coral", + }, + }), + ), + ); + + await waitFor(() => { + const content = + context.queryClient.getQueryData(calendarKey) + ?.entities[original.id].content; + expect(content).toMatchObject({ kind: "details", color: "coral" }); + expect(content).not.toHaveProperty("colorHex"); + }); + + context.pending.resolve(); + }); }); describe("undo history recording", () => { diff --git a/packages/web/src/events/mutations/useEventMutations.ts b/packages/web/src/events/mutations/useEventMutations.ts index 63153eb435..72daef54d7 100644 --- a/packages/web/src/events/mutations/useEventMutations.ts +++ b/packages/web/src/events/mutations/useEventMutations.ts @@ -169,7 +169,15 @@ function mergeReplaceContent( input: ReplaceEventInput["content"], ): Event["content"] { if (existing.kind !== "details") return input; - return { ...existing, ...input }; + const merged = { ...existing, ...input }; + // Slot writes (including null clear) supersede a provider custom hex on the + // optimistic card. Palette resolution prefers colorHex over color, so keeping + // the old hex would leave the prior fill until settle/refetch. + if (input.kind === "details" && input.color !== undefined) { + const { colorHex: _cleared, ...withoutHex } = merged; + return withoutHex; + } + return merged; } function mergeReplaceInput(existing: Event, input: ReplaceEventInput): Event { @@ -217,6 +225,7 @@ type ReplaceVariables = { writeKey: EventId; originalOverride?: Event; opportunityId?: number; + callbacks?: EventMutationCallbacks; }; type DeleteVariables = { id: EventId; @@ -732,8 +741,10 @@ export function useEventMutations( source, }); } + // callbacks rides in variables so onMutate can run onOptimisticApplied + // in the same task as the cache write (same pattern as create above). replaceMutation.mutate( - { ...payload, writeKey, opportunityId }, + { ...payload, writeKey, opportunityId, callbacks }, callbacks, ); }, diff --git a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts index ca86713f57..3226830981 100644 --- a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts +++ b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts @@ -72,8 +72,12 @@ export function useSaveEventForm() { if (parsed.mode === "edit") { clearFieldErrors(); - replace({ id: parsed.eventId, input: parsed.input }); - closeEventForm(); + // Same as create: keep the draft mounted until the optimistic replace + // exists so the grid never paints a frame with the pre-edit color. + replace( + { id: parsed.eventId, input: parsed.input }, + { onOptimisticApplied: closeEventForm }, + ); } }, [ diff --git a/packages/web/src/views/Forms/hooks/useSetEventColor.test.tsx b/packages/web/src/views/Forms/hooks/useSetEventColor.test.tsx new file mode 100644 index 0000000000..ed15ab6c50 --- /dev/null +++ b/packages/web/src/views/Forms/hooks/useSetEventColor.test.tsx @@ -0,0 +1,149 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { type PropsWithChildren } from "react"; +import { type Event } from "@core/types/event.contracts"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { editGridEventDraft } from "@web/events/grid-event-draft.adapter"; +import { eventQueryKeys } from "@web/events/queries/event.query.keys"; +import { type NormalizedEventQueryData } from "@web/events/queries/event.query.types"; +import { + draftActions, + initialDraftState, + useDraftStore, +} from "@web/events/stores/draft.store"; +import { useSetEventColor } from "./useSetEventColor"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const calendarKey = eventQueryKeys.week({ + source: "local", + start: "2026-07-01T00:00:00.000Z", + end: "2026-07-08T00:00:00.000Z", +}); + +const normalized = (...events: Event[]): NormalizedEventQueryData => ({ + ids: events.map(({ id }) => id), + entities: Object.fromEntries(events.map((item) => [item.id, item])), +}); + +function createWrapper(events: Event[]) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + queryClient.setQueryData(calendarKey, normalized(...events)); + + function Wrapper({ children }: PropsWithChildren) { + return ( + {children} + ); + } + + return { queryClient, Wrapper }; +} + +describe("useSetEventColor", () => { + beforeEach(() => { + draftActions.discard(); + }); + + it("paints the new color on the draft before the optimistic replace lands", async () => { + const existing = createMockEvent({ + content: { + kind: "details", + title: "All day", + description: "", + color: "blue", + }, + schedule: { + kind: "allDay", + start: "2026-07-02" as never, + end: "2026-07-03" as never, + }, + }); + const { queryClient, Wrapper } = createWrapper([existing]); + const { result } = renderHook(() => useSetEventColor(existing.id), { + wrapper: Wrapper, + }); + + act(() => { + result.current("coral"); + }); + + expect(useDraftStore.getState().gridDraft?.values.color).toBe("coral"); + + await waitFor(() => { + expect( + ( + queryClient.getQueryData(calendarKey) + ?.entities[existing.id].content as { color?: string } + ).color, + ).toBe("coral"); + }); + + await waitFor(() => { + expect(useDraftStore.getState()).toEqual(initialDraftState); + }); + }); + + it("discards immediately when the color is unchanged", () => { + const existing = createMockEvent({ + content: { + kind: "details", + title: "All day", + description: "", + color: "coral", + }, + }); + const draft = editGridEventDraft(existing); + if (!draft) throw new Error("expected edit draft"); + draftActions.startGridDraft({ activity: "eventRightClick", draft }); + + const { Wrapper } = createWrapper([existing]); + const { result } = renderHook(() => useSetEventColor(existing.id), { + wrapper: Wrapper, + }); + + act(() => { + result.current("coral"); + }); + + expect(useDraftStore.getState()).toEqual(initialDraftState); + }); + + it("updates a right-click draft that still held the old color", async () => { + const existing = createMockEvent({ + content: { + kind: "details", + title: "Timed", + description: "", + color: "blue", + }, + schedule: { + kind: "timed", + start: "2026-07-02T16:00:00.000Z" as never, + end: "2026-07-02T17:00:00.000Z" as never, + timeZone: "UTC" as never, + }, + }); + const draft = editGridEventDraft(existing); + if (!draft) throw new Error("expected edit draft"); + expect(draft.values.color).toBe("blue"); + draftActions.startGridDraft({ activity: "eventRightClick", draft }); + + const { Wrapper } = createWrapper([existing]); + const { result } = renderHook(() => useSetEventColor(existing.id), { + wrapper: Wrapper, + }); + + act(() => { + result.current("mint"); + }); + + expect(useDraftStore.getState().gridDraft?.values.color).toBe("mint"); + // Still drafting until optimistic apply discards — not a sync discard. + expect(useDraftStore.getState()).not.toEqual(initialDraftState); + + await waitFor(() => { + expect(useDraftStore.getState()).toEqual(initialDraftState); + }); + }); +}); diff --git a/packages/web/src/views/Forms/hooks/useSetEventColor.ts b/packages/web/src/views/Forms/hooks/useSetEventColor.ts index 67e8958187..7103924fd4 100644 --- a/packages/web/src/views/Forms/hooks/useSetEventColor.ts +++ b/packages/web/src/views/Forms/hooks/useSetEventColor.ts @@ -10,7 +10,8 @@ import { draftActions } from "@web/events/stores/draft.store"; /** * Immediately replaces an event's color tag (or clears it with null), then - * discards any right-click / grid draft. Used by the event context menu. + * discards any right-click / grid draft once the optimistic cache write lands. + * Used by the event context menu. */ export function useSetEventColor(_id: string) { const existingEvent = useEventById(_id); @@ -32,14 +33,22 @@ export function useSetEventColor(_id: string) { const draft = editGridEventDraft(existingEvent); if (!draft || draft.kind !== "edit") return; - const parsed = parseGridEventDraft({ + const patchedDraft = { ...draft, values: { ...draft.values, color }, - }); + }; + // Paint the new color on the draft card before replace's async onMutate + // finishes cancelQueries + optimistic cache write (Day may have no draft + // yet; Week's right-click draft still holds the old color). + draftActions.setGridDraft(patchedDraft); + + const parsed = parseGridEventDraft(patchedDraft); if (parsed.ok && parsed.mode === "edit") { - replace({ id: parsed.eventId, input: parsed.input }); + replace( + { id: parsed.eventId, input: parsed.input }, + { onOptimisticApplied: () => draftActions.discard() }, + ); } - draftActions.discard(); }, [existingEvent, replace], ); From 4bc98cc125fbafaf32eee34c3923a13ba817ccb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 20:40:36 +0000 Subject: [PATCH 2/2] refactor(web): simplify event color optimistic replace Reuse patchGridDraftFields for color patches, drop colorHex before merge, and trim redundant test scaffolding. Co-authored-by: Tyler Dane --- .../mutations/useEventMutations.test.tsx | 3 +-- .../src/events/mutations/useEventMutations.ts | 7 +++--- .../Forms/hooks/useSetEventColor.test.tsx | 3 +-- .../src/views/Forms/hooks/useSetEventColor.ts | 24 +++++++++---------- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/web/src/events/mutations/useEventMutations.test.tsx b/packages/web/src/events/mutations/useEventMutations.test.tsx index fc543763e8..481d3ca800 100644 --- a/packages/web/src/events/mutations/useEventMutations.test.tsx +++ b/packages/web/src/events/mutations/useEventMutations.test.tsx @@ -1323,9 +1323,8 @@ describe("useEventMutations", () => { }); context.queryClient.setQueryData(calendarKey, normalized(original)); - const onOptimisticApplied = mock(() => {}); let colorAtCallback: unknown; - onOptimisticApplied.mockImplementation(() => { + const onOptimisticApplied = mock(() => { colorAtCallback = ( context.queryClient.getQueryData(calendarKey) ?.entities[original.id].content as { color?: string } diff --git a/packages/web/src/events/mutations/useEventMutations.ts b/packages/web/src/events/mutations/useEventMutations.ts index 72daef54d7..a78271c27a 100644 --- a/packages/web/src/events/mutations/useEventMutations.ts +++ b/packages/web/src/events/mutations/useEventMutations.ts @@ -169,15 +169,14 @@ function mergeReplaceContent( input: ReplaceEventInput["content"], ): Event["content"] { if (existing.kind !== "details") return input; - const merged = { ...existing, ...input }; // Slot writes (including null clear) supersede a provider custom hex on the // optimistic card. Palette resolution prefers colorHex over color, so keeping // the old hex would leave the prior fill until settle/refetch. if (input.kind === "details" && input.color !== undefined) { - const { colorHex: _cleared, ...withoutHex } = merged; - return withoutHex; + const { colorHex: _cleared, ...withoutHex } = existing; + return { ...withoutHex, ...input }; } - return merged; + return { ...existing, ...input }; } function mergeReplaceInput(existing: Event, input: ReplaceEventInput): Event { diff --git a/packages/web/src/views/Forms/hooks/useSetEventColor.test.tsx b/packages/web/src/views/Forms/hooks/useSetEventColor.test.tsx index ed15ab6c50..3c7c6cf16e 100644 --- a/packages/web/src/views/Forms/hooks/useSetEventColor.test.tsx +++ b/packages/web/src/views/Forms/hooks/useSetEventColor.test.tsx @@ -138,9 +138,8 @@ describe("useSetEventColor", () => { result.current("mint"); }); + // Draft keeps the new color until onOptimisticApplied discards it. expect(useDraftStore.getState().gridDraft?.values.color).toBe("mint"); - // Still drafting until optimistic apply discards — not a sync discard. - expect(useDraftStore.getState()).not.toEqual(initialDraftState); await waitFor(() => { expect(useDraftStore.getState()).toEqual(initialDraftState); diff --git a/packages/web/src/views/Forms/hooks/useSetEventColor.ts b/packages/web/src/views/Forms/hooks/useSetEventColor.ts index 7103924fd4..2e99c3ab9a 100644 --- a/packages/web/src/views/Forms/hooks/useSetEventColor.ts +++ b/packages/web/src/views/Forms/hooks/useSetEventColor.ts @@ -3,6 +3,7 @@ import { type EventColorSlot } from "@core/types/event-color.contracts"; import { editGridEventDraft, parseGridEventDraft, + patchGridDraftFields, } from "@web/events/grid-event-draft.adapter"; import { useEventMutations } from "@web/events/mutations/useEventMutations"; import { useEventById } from "@web/events/queries/useEventById"; @@ -33,22 +34,21 @@ export function useSetEventColor(_id: string) { const draft = editGridEventDraft(existingEvent); if (!draft || draft.kind !== "edit") return; - const patchedDraft = { - ...draft, - values: { ...draft.values, color }, - }; + const patchedDraft = patchGridDraftFields(draft, { color }); + const parsed = parseGridEventDraft(patchedDraft); + if (!parsed.ok || parsed.mode !== "edit") { + draftActions.discard(); + return; + } + // Paint the new color on the draft card before replace's async onMutate // finishes cancelQueries + optimistic cache write (Day may have no draft // yet; Week's right-click draft still holds the old color). draftActions.setGridDraft(patchedDraft); - - const parsed = parseGridEventDraft(patchedDraft); - if (parsed.ok && parsed.mode === "edit") { - replace( - { id: parsed.eventId, input: parsed.input }, - { onOptimisticApplied: () => draftActions.discard() }, - ); - } + replace( + { id: parsed.eventId, input: parsed.input }, + { onOptimisticApplied: () => draftActions.discard() }, + ); }, [existingEvent, replace], );