authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 17:22:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:22:17-07:00
logcaf4981e32f4fd3c9adb8339b5bec2d95d09b7aa
tree8abacfbf6416b411755fc0bb06ece8f3591f9f4c
parenta5bbad910f78cc14eef8baa94fe6f46676f78d5a
signature Signed by PGP key B5690EEEBB952194

fix(web): reload saved colors when reopening the theme editor (#9847)


2 files changed, 209 insertions(+), 9 deletions(-)

apps/web/src/components/settings/ThemeEditorHost.test.tsx created+191
...@@ -0,0 +1,191 @@
1import type { ReactElement } from "react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { reactHookHarness as hooks } from "../../test/reactHookHarness";
5import {
6 getThemeDefinition,
7 installCustomTheme,
8 invalidateCustomThemes,
9 parseThemeFile,
10 removeCustomTheme,
11 THEME_FILE_VERSION,
12 themeColorToHex,
13 updateCustomTheme,
14 type ThemeDefinition,
15} from "../../themePalette";
16import type { ThemeEditorSession } from "./themeEditorStore";
17
18const state = vi.hoisted(() => ({
19 session: null as ThemeEditorSession | null,
20 closeThemeEditor: vi.fn(),
21 onStoreChange: vi.fn(),
22 subscriptions: new Set<() => void>(),
23 theme: {
24 theme: "system",
25 themeHalves: null,
26 setTheme: vi.fn(),
27 refreshTheme: vi.fn(),
28 },
29}));
30
31vi.mock("react", async (importOriginal) => {
32 const actual = await importOriginal<typeof import("react")>();
33 const { reactHookHarness } = await import("../../test/reactHookHarness");
34 return {
35 ...actual,
36 useCallback: reactHookHarness.useCallback,
37 useSyncExternalStore: (
38 subscribe: (listener: () => void) => () => void,
39 getSnapshot: () => unknown,
40 ) => {
41 const subscription = reactHookHarness.useRef<(() => void) | null>(null);
42 if (!subscription.current) {
43 subscription.current = subscribe(() => state.onStoreChange());
44 state.subscriptions.add(subscription.current);
45 }
46 return getSnapshot();
47 },
48 };
49});
50
51vi.mock("react/compiler-runtime", async () => {
52 const { reactHookHarness } = await import("../../test/reactHookHarness");
53 return { c: reactHookHarness.useMemoCache };
54});
55
56vi.mock("../../hooks/useTheme", () => ({ useTheme: () => state.theme }));
57vi.mock("./themeEditorStore", () => ({
58 useThemeEditorStore: (select: (store: typeof state) => unknown) => select(state),
59}));
60vi.mock("../ui/toast", () => ({
61 toastManager: { add: vi.fn() },
62 stackedThreadToast: (value: unknown) => value,
63}));
64
65import { ThemeEditorHost } from "./ThemeEditorHost";
66
67function renderEditor() {
68 hooks.beginRender();
69 const host = ThemeEditorHost() as ReactElement<{
70 children: ReactElement<{
71 editingTheme: ThemeDefinition | null;
72 seedTheme: ThemeDefinition | null;
73 }>;
74 }> | null;
75 return host?.props.children.props ?? null;
76}
77
78describe("ThemeEditorHost", () => {
79 beforeEach(() => {
80 hooks.reset();
81 state.session = null;
82 state.onStoreChange.mockReset();
83 const storage = new Map<string, string>();
84 vi.stubGlobal("window", {
85 localStorage: {
86 getItem: (key: string) => storage.get(key) ?? null,
87 setItem: (key: string, value: string) => storage.set(key, value),
88 },
89 addEventListener: vi.fn(),
90 removeEventListener: vi.fn(),
91 });
92 invalidateCustomThemes();
93 });
94
95 afterEach(() => {
96 for (const unsubscribe of state.subscriptions) unsubscribe();
97 state.subscriptions.clear();
98 vi.unstubAllGlobals();
99 invalidateCustomThemes();
100 });
101
102 it.each(["editingTheme", "seedTheme"] as const)(
103 "reopens the same %s with its saved colors",
104 (field) => {
105 const theme = installCustomTheme(
106 parseThemeFile({
107 version: THEME_FILE_VERSION,
108 id: "saved-colors",
109 name: "Saved colors",
110 appearance: "dark",
111 colors: { accent: "#1f6e4a" },
112 }),
113 );
114 const session = {
115 id: 1,
116 editingThemeId: field === "editingTheme" ? theme.id : null,
117 seedThemeId: field === "seedTheme" ? theme.id : null,
118 seedName: null,
119 initialAppearance: "dark" as const,
120 };
121 state.session = session;
122 expect(themeColorToHex(renderEditor()?.[field]?.colors.accent ?? "")).toBe("#1f6e4a");
123
124 updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } });
125 expect(themeColorToHex(getThemeDefinition(theme.id)?.colors.accent ?? "")).toBe("#7241b8");
126 state.session = null;
127 expect(renderEditor()).toBeNull();
128 state.session = { ...session, id: 2 };
129
130 expect(themeColorToHex(renderEditor()?.[field]?.colors.accent ?? "")).toBe("#7241b8");
131 },
132 );
133
134 it.each(["editingTheme", "seedTheme"] as const)(
135 "refreshes an open %s when the library changes",
136 (field) => {
137 const theme = installCustomTheme(
138 parseThemeFile({
139 version: THEME_FILE_VERSION,
140 id: "updated-theme",
141 name: "Updated theme",
142 appearance: "dark",
143 colors: { accent: "#1f6e4a" },
144 }),
145 );
146 state.session = {
147 id: 1,
148 editingThemeId: field === "editingTheme" ? theme.id : null,
149 seedThemeId: field === "seedTheme" ? theme.id : null,
150 seedName: null,
151 initialAppearance: "dark",
152 };
153 let editor = renderEditor();
154 state.onStoreChange.mockImplementation(() => {
155 editor = renderEditor();
156 });
157
158 updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } });
159
160 expect(themeColorToHex(editor?.[field]?.colors.accent ?? "")).toBe("#7241b8");
161 },
162 );
163
164 it("does not keep editing a theme removed from the library", () => {
165 const theme = installCustomTheme(
166 parseThemeFile({
167 version: THEME_FILE_VERSION,
168 id: "removed-theme",
169 name: "Removed theme",
170 appearance: "dark",
171 colors: { accent: "#1f6e4a" },
172 }),
173 );
174 state.session = {
175 id: 1,
176 editingThemeId: theme.id,
177 seedThemeId: null,
178 seedName: null,
179 initialAppearance: "dark",
180 };
181 let editor = renderEditor();
182 expect(editor?.editingTheme?.id).toBe(theme.id);
183 state.onStoreChange.mockImplementation(() => {
184 editor = renderEditor();
185 });
186
187 removeCustomTheme(theme.id);
188
189 expect(editor?.editingTheme).toBeNull();
190 });
191});
apps/web/src/components/settings/ThemeEditorHost.tsx+18-9
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import { lazy, Suspense, useCallback } from "react";1import { lazy, Suspense, useCallback, useSyncExternalStore } from "react";
22
3import { useTheme } from "../../hooks/useTheme";3import { useTheme } from "../../hooks/useTheme";
4import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette";4import {
5 getThemeDefinition,
6 subscribeToCustomThemes,
7 type ThemeAppearance,
8 type ThemeDefinition,
9} from "../../themePalette";
5import { stackedThreadToast, toastManager } from "../ui/toast";10import { stackedThreadToast, toastManager } from "../ui/toast";
6import { useThemeEditorStore } from "./themeEditorStore";11import { useThemeEditorStore } from "./themeEditorStore";
712
...@@ -12,6 +17,14 @@ const ThemeEditorPanel = lazy(() =>...@@ -12,6 +17,14 @@ const ThemeEditorPanel = lazy(() =>
12 import("./ThemeEditorPanel").then((module) => ({ default: module.ThemeEditorPanel })),17 import("./ThemeEditorPanel").then((module) => ({ default: module.ThemeEditorPanel })),
13);18);
1419
20function useThemeDefinition(id: string | null | undefined) {
21 return useSyncExternalStore(
22 subscribeToCustomThemes,
23 () => (id ? (getThemeDefinition(id) ?? null) : null),
24 () => null,
25 );
26}
27
15/**28/**
16 * Renders the theme editor above the router. The editor paints its draft on29 * Renders the theme editor above the router. The editor paints its draft on
17 * the live app, so it has to outlive the settings route: the point is to walk30 * the live app, so it has to outlive the settings route: the point is to walk
...@@ -21,6 +34,9 @@ export function ThemeEditorHost() {...@@ -21,6 +34,9 @@ export function ThemeEditorHost() {
21 const session = useThemeEditorStore((store) => store.session);34 const session = useThemeEditorStore((store) => store.session);
22 const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor);35 const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor);
23 const { theme, setTheme, themeHalves, refreshTheme } = useTheme();36 const { theme, setTheme, themeHalves, refreshTheme } = useTheme();
37 // A saved definition can change without its id changing between sessions.
38 const editingTheme = useThemeDefinition(session?.editingThemeId);
39 const seedTheme = useThemeDefinition(session?.seedThemeId);
2440
25 // The panel reports which path it actually took: a theme removed while its41 // The panel reports which path it actually took: a theme removed while its
26 // editor is open resolves to null there, so the save becomes a create even42 // editor is open resolves to null there, so the save becomes a create even
...@@ -95,13 +111,6 @@ export function ThemeEditorHost() {...@@ -95,13 +111,6 @@ export function ThemeEditorHost() {
95111
96 if (!session) return null;112 if (!session) return null;
97113
98 // Resolve on every render: an edit or import can change the stored
99 // definitions while a session is open.
100 const editingTheme = session.editingThemeId
101 ? (getThemeDefinition(session.editingThemeId) ?? null)
102 : null;
103 const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null;
104
105 return (114 return (
106 <Suspense fallback={null}>115 <Suspense fallback={null}>
107 <ThemeEditorPanel116 <ThemeEditorPanel