fix: rollback appearance mutation cache updates (#25182)

## Summary

- Add appearance mutation context so optimistic cache updates can be
rolled back on failure.
- Restore previous appearance settings, or remove the optimistic cache
entry when no previous data exists.
- Merge successful server responses back into the appearance cache while
preserving request fields when responses are partial.

## Dependencies

- Stacked on #25076 because the tests and generated types use the new
appearance theme mode fields.

## Validation

- `pnpm -C site exec vitest run --project=unit
src/api/queries/users.test.ts`
- `pnpm -C site lint:types`
- Pre-commit hook passed on the branch commit.
This commit is contained in:
Jaayden Halko
2026-05-15 10:50:29 +01:00
committed by GitHub
parent 5f9b3220b5
commit 8650a2ee22
2 changed files with 151 additions and 9 deletions
+123
View File
@@ -0,0 +1,123 @@
import { QueryClient } from "react-query";
import { describe, expect, it } from "vitest";
import type {
UpdateUserAppearanceSettingsRequest,
UserAppearanceSettings,
} from "#/api/typesGenerated";
import { myAppearanceKey, updateAppearanceSettings } from "./users";
const appearanceSettings = (
overrides: Partial<UserAppearanceSettings> = {},
): UserAppearanceSettings => ({
theme_preference: "dark-tritan",
theme_mode: "sync",
theme_light: "light-tritan",
theme_dark: "dark-tritan",
terminal_font: "geist-mono",
...overrides,
});
const updateRequest = (
overrides: Partial<UpdateUserAppearanceSettingsRequest> = {},
): UpdateUserAppearanceSettingsRequest => ({
theme_preference: "dark",
theme_mode: "single",
theme_light: "light-tritan",
theme_dark: "dark-tritan",
terminal_font: "fira-code",
...overrides,
});
describe("updateAppearanceSettings", () => {
it("rolls back optimistic appearance updates when the mutation fails", async () => {
const queryClient = new QueryClient();
const previousSettings = appearanceSettings({
theme_light: "light-protan-deuter",
theme_dark: "dark-protan-deuter",
});
const optimisticSettings = updateRequest();
queryClient.setQueryData<UserAppearanceSettings>(
myAppearanceKey,
previousSettings,
);
const mutation = updateAppearanceSettings(queryClient);
const context = await mutation.onMutate?.(optimisticSettings);
expect(queryClient.getQueryData(myAppearanceKey)).toEqual(
optimisticSettings,
);
mutation.onError?.(new Error("failed"), optimisticSettings, context);
expect(queryClient.getQueryData(myAppearanceKey)).toEqual(previousSettings);
});
it("removes optimistic appearance data when rollback has no prior cache", async () => {
const queryClient = new QueryClient();
const optimisticSettings = updateRequest();
const mutation = updateAppearanceSettings(queryClient);
const context = await mutation.onMutate?.(optimisticSettings);
expect(queryClient.getQueryData(myAppearanceKey)).toEqual(
optimisticSettings,
);
mutation.onError?.(new Error("failed"), optimisticSettings, context);
expect(queryClient.getQueryData(myAppearanceKey)).toBeUndefined();
});
it("stores the server response after a successful appearance update", async () => {
const queryClient = new QueryClient();
const optimisticSettings = updateRequest();
const serverSettings = appearanceSettings({
theme_preference: "dark-protan-deuter",
theme_light: "light-protan-deuter",
theme_dark: "dark-protan-deuter",
});
const mutation = updateAppearanceSettings(queryClient);
const context = await mutation.onMutate?.(optimisticSettings);
if (!context) {
throw new Error("expected mutation context");
}
expect(queryClient.getQueryData(myAppearanceKey)).toEqual(
optimisticSettings,
);
mutation.onSuccess?.(serverSettings, optimisticSettings, context);
expect(queryClient.getQueryData(myAppearanceKey)).toEqual(serverSettings);
});
it("keeps patch values when a successful appearance update response is partial", async () => {
const queryClient = new QueryClient();
const optimisticSettings = updateRequest({
theme_mode: "sync",
theme_light: "light-protan-deuter",
theme_dark: "dark-protan-deuter",
});
const serverSettings = {
theme_preference: "dark-tritan",
terminal_font: "jetbrains-mono",
} satisfies Partial<UserAppearanceSettings>;
const mutation = updateAppearanceSettings(queryClient);
const context = await mutation.onMutate?.(optimisticSettings);
if (!context) {
throw new Error("expected mutation context");
}
mutation.onSuccess?.(
serverSettings as UserAppearanceSettings,
optimisticSettings,
context,
);
expect(queryClient.getQueryData(myAppearanceKey)).toEqual({
...optimisticSettings,
...serverSettings,
});
});
});
+28 -9
View File
@@ -262,7 +262,11 @@ export const updateProfile = (userId: string) => {
};
};
const myAppearanceKey = ["me", "appearance"];
export const myAppearanceKey = ["me", "appearance"] as const;
type AppearanceMutationContext = {
previousAppearanceSettings: UserAppearanceSettings | undefined;
};
export const appearanceSettings = (
metadata: MetadataState<UserAppearanceSettings>,
@@ -280,27 +284,42 @@ export const updateAppearanceSettings = (
UserAppearanceSettings,
unknown,
UpdateUserAppearanceSettingsRequest,
unknown
AppearanceMutationContext
> => {
return {
mutationFn: (req) => API.updateAppearanceSettings(req),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: myAppearanceKey });
const previousAppearanceSettings =
queryClient.getQueryData<UserAppearanceSettings>(myAppearanceKey);
// Mutate the `queryClient` optimistically to make the theme switcher
// more responsive.
queryClient.setQueryData(myAppearanceKey, {
queryClient.setQueryData<UserAppearanceSettings>(myAppearanceKey, {
theme_preference: patch.theme_preference,
theme_mode: patch.theme_mode,
theme_light: patch.theme_light,
theme_dark: patch.theme_dark,
terminal_font: patch.terminal_font,
});
return { previousAppearanceSettings };
},
onError: (_error, _patch, context) => {
if (context?.previousAppearanceSettings) {
queryClient.setQueryData<UserAppearanceSettings>(
myAppearanceKey,
context.previousAppearanceSettings,
);
return;
}
queryClient.removeQueries({ queryKey: myAppearanceKey, exact: true });
},
onSuccess: (settings, patch) => {
queryClient.setQueryData<UserAppearanceSettings>(myAppearanceKey, {
...patch,
...settings,
});
},
onSuccess: async () =>
// Could technically invalidate more, but we only ever care about the
// `theme_preference` for the `me` query.
await queryClient.invalidateQueries({
queryKey: myAppearanceKey,
}),
};
};