Skip to content

Commit 3b19a0f

Browse files
authored
Merge pull request #109 from MongooseMoo/agent/issue-105-channel-save-debounce
Debounce channel history persistence
2 parents 49e6b1d + c6a4a41 commit 3b19a0f

2 files changed

Lines changed: 102 additions & 9 deletions

File tree

src/hooks/useChannelHistory.test.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ describe("useChannelHistory", () => {
188188
}
189189
});
190190

191+
// The save effect debounces writes by SAVE_DEBOUNCE_MS (500ms), so give
192+
// waitFor enough real time to let the trailing timer fire.
191193
await waitFor(() => {
192194
const saved = localStorage.getItem("channelHistory");
193195
expect(saved).not.toBeNull();
@@ -196,9 +198,35 @@ describe("useChannelHistory", () => {
196198
expect(parsed.version).toBe(1);
197199
expect(parsed.data.buffers.all.messages).toHaveLength(MAX_PERSISTED_ALL_MESSAGES);
198200
expect(parsed.data.buffers.gossip.messages).toHaveLength(MAX_PERSISTED_CHANNEL_MESSAGES);
199-
});
201+
}, { timeout: 2000 });
200202
});
201203

204+
it.each(["pagehide", "beforeunload"])(
205+
"flushes pending channel history on %s before the debounce expires",
206+
async (eventName) => {
207+
const { result } = renderHook(() => useChannelHistory());
208+
209+
act(() => {
210+
addChannelText("gossip", "Reader", "last-second message");
211+
});
212+
213+
await waitFor(() => {
214+
expect(result.current.buffers.get("all")?.messages).toHaveLength(1);
215+
});
216+
expect(localStorage.getItem("channelHistory")).toBeNull();
217+
218+
act(() => {
219+
window.dispatchEvent(new Event(eventName));
220+
});
221+
222+
const saved = localStorage.getItem("channelHistory");
223+
expect(saved).not.toBeNull();
224+
expect(JSON.parse(saved || "{}").data.buffers.all.messages[0].message).toBe(
225+
"last-second message"
226+
);
227+
}
228+
);
229+
202230
it("caps older localStorage history when loading it", () => {
203231
localStorage.setItem("channelHistory", JSON.stringify({
204232
buffers: {

src/hooks/useChannelHistory.tsx

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ const channelHistorySchema: LocalStorageSchema<StoredChannelHistory> = {
7474
},
7575
};
7676

77+
// Coalesce bursts of channel messages into one write instead of
78+
// re-serializing + persisting the whole history on every message. Mirrors
79+
// output.tsx's SAVE_DEBOUNCE_MS.
80+
const SAVE_DEBOUNCE_MS = 500;
81+
7782
export const MAX_ALL_BUFFER_MESSAGES = 1000;
7883
export const MAX_CHANNEL_BUFFER_MESSAGES = 500;
7984
export const MAX_PERSISTED_ALL_MESSAGES = 200;
@@ -168,6 +173,43 @@ export const useChannelHistory = () => {
168173
const [linkPickerLinks, setLinkPickerLinks] = useState<ExtractedLink[] | null>(null);
169174
const lastKeyPress = useRef<{ key: string; time: number; count: number } | null>(null);
170175
const lastProcessedChannelEntryId = useRef(0);
176+
const saveTimerRef = useRef<number | undefined>(undefined);
177+
// Always holds the latest live state so the debounced timer callback and
178+
// the unmount flush never persist stale values. Serialization happens in
179+
// flushSave, not per change — that's the expensive half of the work.
180+
const latestStateRef = useRef<{
181+
buffers: Map<string, Buffer>;
182+
bufferOrder: string[];
183+
currentBufferIndex: number;
184+
timestampsEnabled: boolean;
185+
} | null>(null);
186+
187+
const cancelScheduledSave = useCallback(() => {
188+
if (saveTimerRef.current !== undefined) {
189+
window.clearTimeout(saveTimerRef.current);
190+
saveTimerRef.current = undefined;
191+
}
192+
}, []);
193+
194+
const flushSave = useCallback(() => {
195+
const latest = latestStateRef.current;
196+
if (!latest) {
197+
return;
198+
}
199+
saveStoredValue(channelHistorySchema, {
200+
buffers: serializeBuffersForStorage(latest.buffers),
201+
bufferOrder: latest.bufferOrder,
202+
currentBufferIndex: latest.currentBufferIndex,
203+
timestampsEnabled: latest.timestampsEnabled,
204+
});
205+
}, []);
206+
207+
const flushPendingSave = useCallback(() => {
208+
if (saveTimerRef.current !== undefined) {
209+
cancelScheduledSave();
210+
flushSave();
211+
}
212+
}, [cancelScheduledSave, flushSave]);
171213

172214
// Load state through the shared versioned persistence owner on mount.
173215
useEffect(() => {
@@ -195,15 +237,38 @@ export const useChannelHistory = () => {
195237
setTimestampsEnabled(parsed.timestampsEnabled ?? true);
196238
}, []);
197239

198-
// Save state through the same schema whenever it changes.
240+
// Save state through the same schema whenever it changes, debounced so a
241+
// burst of channel messages coalesces into one write instead of
242+
// re-serializing the whole history on every message.
199243
useEffect(() => {
200-
saveStoredValue(channelHistorySchema, {
201-
buffers: serializeBuffersForStorage(buffers),
202-
bufferOrder,
203-
currentBufferIndex,
204-
timestampsEnabled,
205-
});
206-
}, [buffers, bufferOrder, currentBufferIndex, timestampsEnabled]);
244+
latestStateRef.current = {
245+
buffers,
246+
bufferOrder,
247+
currentBufferIndex,
248+
timestampsEnabled,
249+
};
250+
251+
cancelScheduledSave();
252+
saveTimerRef.current = window.setTimeout(() => {
253+
saveTimerRef.current = undefined;
254+
flushSave();
255+
}, SAVE_DEBOUNCE_MS);
256+
}, [buffers, bufferOrder, currentBufferIndex, timestampsEnabled, cancelScheduledSave, flushSave]);
257+
258+
// React root cleanup is not guaranteed during a reload or tab close, so
259+
// synchronously flush pending history from the browser lifecycle as well as
260+
// on unmount. The pending-timer check keeps consecutive lifecycle events
261+
// (for example, beforeunload followed by pagehide) idempotent.
262+
useEffect(() => {
263+
window.addEventListener("pagehide", flushPendingSave);
264+
window.addEventListener("beforeunload", flushPendingSave);
265+
266+
return () => {
267+
window.removeEventListener("pagehide", flushPendingSave);
268+
window.removeEventListener("beforeunload", flushPendingSave);
269+
flushPendingSave();
270+
};
271+
}, [flushPendingSave]);
207272

208273
// Handle channel messages. The "all" buffer is the aggregate of every
209274
// channel, so each channel message is appended both to its own channel

0 commit comments

Comments
 (0)