Skip to content

Commit 987883e

Browse files
authored
perf(ui): coalesce persisted-store writes before they hit host storage (#3299)
1 parent 6830391 commit 987883e

3 files changed

Lines changed: 233 additions & 50 deletions

File tree

packages/ui/src/features/settings/settingsStore.test.ts

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { registerRendererStateStorage } from "@posthog/ui/shell/rendererStorage";
1+
import {
2+
flushRendererStateWrites,
3+
registerRendererStateStorage,
4+
} from "@posthog/ui/shell/rendererStorage";
25
import { beforeEach, describe, expect, it, vi } from "vitest";
36
import {
47
type CompletionSound,
@@ -12,6 +15,28 @@ const removeItem = vi.fn();
1215

1316
registerRendererStateStorage({ getItem, setItem, removeItem });
1417

18+
// Lands any coalesced write from the previous test on the old mocks (so a
19+
// pending value cannot leak into this test's reads or assertions), then
20+
// resets them.
21+
async function resetPersistenceMocks() {
22+
await flushRendererStateWrites();
23+
getItem.mockReset();
24+
setItem.mockReset();
25+
removeItem.mockReset();
26+
getItem.mockResolvedValue(null);
27+
setItem.mockResolvedValue(undefined);
28+
removeItem.mockResolvedValue(undefined);
29+
}
30+
31+
// Persisted writes are debounced; flush while polling so the assertion sees
32+
// the coalesced write as soon as the store has queued it.
33+
async function waitForPersistedWrite() {
34+
await vi.waitFor(async () => {
35+
await flushRendererStateWrites();
36+
expect(setItem).toHaveBeenCalled();
37+
});
38+
}
39+
1540
// Runs before any test mutates the store singleton, so getState() still
1641
// reflects the initial values.
1742
describe("feature settingsStore defaults", () => {
@@ -25,13 +50,8 @@ describe("feature settingsStore defaults", () => {
2550
});
2651

2752
describe("feature settingsStore cloud selections", () => {
28-
beforeEach(() => {
29-
getItem.mockReset();
30-
setItem.mockReset();
31-
removeItem.mockReset();
32-
getItem.mockResolvedValue(null);
33-
setItem.mockResolvedValue(undefined);
34-
removeItem.mockResolvedValue(undefined);
53+
beforeEach(async () => {
54+
await resetPersistenceMocks();
3555

3656
useSettingsStore.setState({
3757
allowBypassPermissions: false,
@@ -43,9 +63,7 @@ describe("feature settingsStore cloud selections", () => {
4363
it("persists the last used cloud repository", async () => {
4464
useSettingsStore.getState().setLastUsedCloudRepository("posthog/posthog");
4565

46-
await vi.waitFor(() => {
47-
expect(setItem).toHaveBeenCalled();
48-
});
66+
await waitForPersistedWrite();
4967

5068
const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1];
5169
const persisted = JSON.parse(lastCall[1]);
@@ -82,9 +100,7 @@ describe("feature settingsStore cloud selections", () => {
82100
},
83101
});
84102

85-
await vi.waitFor(() => {
86-
expect(setItem).toHaveBeenCalled();
87-
});
103+
await waitForPersistedWrite();
88104

89105
const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1];
90106
const persisted = JSON.parse(lastCall[1]);
@@ -170,13 +186,8 @@ describe("feature settingsStore cloud selections", () => {
170186
});
171187

172188
describe("feature settingsStore custom sounds", () => {
173-
beforeEach(() => {
174-
getItem.mockReset();
175-
setItem.mockReset();
176-
removeItem.mockReset();
177-
getItem.mockResolvedValue(null);
178-
setItem.mockResolvedValue(undefined);
179-
removeItem.mockResolvedValue(undefined);
189+
beforeEach(async () => {
190+
await resetPersistenceMocks();
180191

181192
useSettingsStore.setState({ customSounds: [], completionSound: "none" });
182193
});
@@ -239,9 +250,7 @@ describe("feature settingsStore custom sounds", () => {
239250
it("persists custom sounds", async () => {
240251
useSettingsStore.getState().addCustomSound(sound);
241252

242-
await vi.waitFor(() => {
243-
expect(setItem).toHaveBeenCalled();
244-
});
253+
await waitForPersistedWrite();
245254

246255
const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1];
247256
const persisted = JSON.parse(lastCall[1]);
@@ -284,13 +293,8 @@ describe("feature settingsStore custom sounds", () => {
284293
});
285294

286295
describe("feature settingsStore terminal font", () => {
287-
beforeEach(() => {
288-
getItem.mockReset();
289-
setItem.mockReset();
290-
removeItem.mockReset();
291-
getItem.mockResolvedValue(null);
292-
setItem.mockResolvedValue(undefined);
293-
removeItem.mockResolvedValue(undefined);
296+
beforeEach(async () => {
297+
await resetPersistenceMocks();
294298

295299
useSettingsStore.setState({
296300
terminalFont: "berkeley-mono",
@@ -307,9 +311,7 @@ describe("feature settingsStore terminal font", () => {
307311
useSettingsStore.getState().setTerminalFont("custom");
308312
useSettingsStore.getState().setTerminalCustomFontFamily("Fira Code");
309313

310-
await vi.waitFor(() => {
311-
expect(setItem).toHaveBeenCalled();
312-
});
314+
await waitForPersistedWrite();
313315

314316
const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1];
315317
const persisted = JSON.parse(lastCall[1]);

packages/ui/src/shell/rendererStorage.test.ts

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
22
import { create } from "zustand";
33
import { persist } from "zustand/middleware";
4+
import { WRITE_DEBOUNCE_MS } from "./rendererStorage";
45

56
type RendererStorageModule = typeof import("./rendererStorage");
67

@@ -9,6 +10,15 @@ async function importFreshRendererStorage(): Promise<RendererStorageModule> {
910
return await import("./rendererStorage");
1011
}
1112

13+
/** Fresh module with fake timers and a backend already registered. */
14+
async function setupRegisteredBackend(data: Record<string, string> = {}) {
15+
vi.useFakeTimers();
16+
const module = await importFreshRendererStorage();
17+
const backend = fakeBackend(data);
18+
module.registerRendererStateStorage(backend);
19+
return { module, storage: jsonStorageOf(module), backend };
20+
}
21+
1222
function fakeBackend(data: Record<string, string>) {
1323
return {
1424
getItem: vi.fn(async (key: string) => data[key] ?? null),
@@ -30,6 +40,10 @@ function jsonStorageOf(module: RendererStorageModule) {
3040
}
3141

3242
describe("rendererStorage", () => {
43+
afterEach(() => {
44+
vi.useRealTimers();
45+
});
46+
3347
it("serves reads issued before the host registers its storage", async () => {
3448
const module = await importFreshRendererStorage();
3549
const storage = jsonStorageOf(module);
@@ -51,6 +65,7 @@ describe("rendererStorage", () => {
5165
});
5266

5367
it("drops writes racing the initial read, then writes through", async () => {
68+
vi.useFakeTimers();
5469
const module = await importFreshRendererStorage();
5570
const storage = jsonStorageOf(module);
5671
const backend = fakeBackend({
@@ -65,31 +80,35 @@ describe("rendererStorage", () => {
6580

6681
module.registerRendererStateStorage(backend);
6782
await Promise.all([read, racingWrite]);
83+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
6884
expect(backend.setItem).not.toHaveBeenCalled();
6985

7086
await storage.setItem("settings-storage", {
7187
state: { mode: "changed" },
7288
version: 0,
7389
});
90+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
7491
expect(backend.setItem).toHaveBeenCalledTimes(1);
7592
});
7693

7794
it("passes writes through for keys that were never read", async () => {
95+
vi.useFakeTimers();
7896
const module = await importFreshRendererStorage();
7997
const storage = jsonStorageOf(module);
8098
const backend = fakeBackend({});
8199

82-
const write = storage.setItem("write-only", {
100+
await storage.setItem("write-only", {
83101
state: { value: 1 },
84102
version: 0,
85103
});
86104
module.registerRendererStateStorage(backend);
87-
await write;
105+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
88106

89107
expect(backend.setItem).toHaveBeenCalledTimes(1);
90108
});
91109

92110
it("settles concurrent initial reads of the same key once", async () => {
111+
vi.useFakeTimers();
93112
const module = await importFreshRendererStorage();
94113
const storage = jsonStorageOf(module);
95114
const backend = fakeBackend({
@@ -110,10 +129,12 @@ describe("rendererStorage", () => {
110129
state: { mode: "changed" },
111130
version: 0,
112131
});
132+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
113133
expect(backend.setItem).toHaveBeenCalledTimes(1);
114134
});
115135

116136
it("marks a key settled when the initial read rejects so later writes pass", async () => {
137+
vi.useFakeTimers();
117138
const module = await importFreshRendererStorage();
118139
const storage = jsonStorageOf(module);
119140
const backend = fakeBackend({});
@@ -127,6 +148,7 @@ describe("rendererStorage", () => {
127148
state: { mode: "changed" },
128149
version: 0,
129150
});
151+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
130152
expect(backend.setItem).toHaveBeenCalledTimes(1);
131153
});
132154

@@ -196,12 +218,84 @@ describe("rendererStorage", () => {
196218
});
197219

198220
useStore.setState({ defaultInitialTaskMode: "plan" });
199-
await vi.waitFor(() => {
221+
await vi.waitFor(async () => {
222+
await module.flushRendererStateWrites();
200223
expect(backend.setItem).toHaveBeenCalled();
201224
});
202225
const persisted = JSON.parse(
203226
backend.setItem.mock.calls[backend.setItem.mock.calls.length - 1][1],
204227
);
205228
expect(persisted.state.defaultInitialTaskMode).toBe("plan");
206229
});
230+
231+
it("coalesces a burst of writes into one backend write with the latest value", async () => {
232+
const { storage, backend } = await setupRegisteredBackend();
233+
234+
await storage.setItem("drafts", { state: { value: 1 }, version: 0 });
235+
await storage.setItem("drafts", { state: { value: 2 }, version: 0 });
236+
await storage.setItem("drafts", { state: { value: 3 }, version: 0 });
237+
238+
expect(backend.setItem).not.toHaveBeenCalled();
239+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
240+
expect(backend.setItem).toHaveBeenCalledTimes(1);
241+
const persisted = JSON.parse(backend.setItem.mock.calls[0][1]);
242+
expect(persisted.state.value).toBe(3);
243+
});
244+
245+
it("flushes at the max-wait bound during sustained writes", async () => {
246+
const { storage, backend } = await setupRegisteredBackend();
247+
248+
// Write every 500ms for 6s: a plain trailing debounce would never fire,
249+
// the max-wait bound forces a flush mid-burst.
250+
for (let i = 0; i < 12; i++) {
251+
await storage.setItem("drafts", { state: { value: i }, version: 0 });
252+
await vi.advanceTimersByTimeAsync(500);
253+
}
254+
expect(backend.setItem.mock.calls.length).toBeGreaterThanOrEqual(1);
255+
256+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
257+
const last = backend.setItem.mock.calls.at(-1);
258+
expect(JSON.parse(last?.[1] ?? "{}").state.value).toBe(11);
259+
});
260+
261+
it("lands the pending coalesced write before serving a read", async () => {
262+
const { storage, backend } = await setupRegisteredBackend({
263+
drafts: JSON.stringify({ state: { value: "stale" }, version: 0 }),
264+
});
265+
266+
await storage.setItem("drafts", { state: { value: "fresh" }, version: 0 });
267+
268+
await expect(storage.getItem("drafts")).resolves.toMatchObject({
269+
state: { value: "fresh" },
270+
});
271+
expect(backend.setItem).toHaveBeenCalledTimes(1);
272+
273+
// The read consumed the pending write; the debounce timer must not fire
274+
// a duplicate.
275+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
276+
expect(backend.setItem).toHaveBeenCalledTimes(1);
277+
});
278+
279+
it("cancels a pending write when the key is removed", async () => {
280+
const { storage, backend } = await setupRegisteredBackend();
281+
282+
await storage.setItem("drafts", { state: { value: 1 }, version: 0 });
283+
await storage.removeItem("drafts");
284+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
285+
286+
expect(backend.setItem).not.toHaveBeenCalled();
287+
expect(backend.removeItem).toHaveBeenCalledTimes(1);
288+
});
289+
290+
it("flushRendererStateWrites persists pending state immediately", async () => {
291+
const { module, storage, backend } = await setupRegisteredBackend();
292+
293+
await storage.setItem("drafts", { state: { value: 1 }, version: 0 });
294+
await module.flushRendererStateWrites();
295+
expect(backend.setItem).toHaveBeenCalledTimes(1);
296+
297+
// The debounce timer was cancelled by the flush; no duplicate write.
298+
await vi.advanceTimersByTimeAsync(WRITE_DEBOUNCE_MS);
299+
expect(backend.setItem).toHaveBeenCalledTimes(1);
300+
});
207301
});

0 commit comments

Comments
 (0)