From 59daa7cc5b9fe680a25e2a11e5b080afad385ae7 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 2 Jul 2026 16:47:42 -0400 Subject: [PATCH] fix(desktop): defer timeline trim past StrictMode probe Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- desktop/src/features/messages/hooks.ts | 16 +++--- .../lib/deferredTimelineTrim.test.mjs | 50 +++++++++++++++++++ .../messages/lib/deferredTimelineTrim.ts | 36 +++++++++++++ 3 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 desktop/src/features/messages/lib/deferredTimelineTrim.test.mjs create mode 100644 desktop/src/features/messages/lib/deferredTimelineTrim.ts diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 7510651bc..2ac01a03a 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -33,6 +33,7 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill"; import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages"; +import { deferredTimelineTrim } from "@/features/messages/lib/deferredTimelineTrim"; import { mergeHistoryOverSnapshot, readMessageSnapshot, @@ -325,12 +326,7 @@ export function useChannelSubscription(channel: Channel | null) { } }); - // Leaving the channel is the safe moment to enforce the timeline cap: - // nothing is rendered from this cache anymore, so trimming to the newest - // MAX_TIMELINE_MESSAGES window cannot evict rows out from under a - // scrolled-back reader. Merges while the channel is open are deliberately - // uncapped for the same reason. - const trimTimelineOnLeave = useEffectEvent((leftChannelId: string) => { + const trimTimelineCache = useEffectEvent((leftChannelId: string) => { queryClient.setQueryData( channelMessagesKey(leftChannelId), (current) => @@ -345,6 +341,10 @@ export function useChannelSubscription(channel: Channel | null) { return; } + // StrictMode immediately re-runs this setup after its synthetic cleanup. + // Cancel the deferred trim while this channel remains active. + deferredTimelineTrim.cancel(channelId); + let isDisposed = false; let cleanup: (() => Promise) | undefined; const disposeReconnectListener = relayClient.subscribeToReconnects(() => { @@ -392,7 +392,9 @@ export function useChannelSubscription(channel: Channel | null) { if (cleanup) { void cleanup(); } - trimTimelineOnLeave(channelId); + deferredTimelineTrim.schedule(channelId, () => { + trimTimelineCache(channelId); + }); }; }, [channelId, channelType]); } diff --git a/desktop/src/features/messages/lib/deferredTimelineTrim.test.mjs b/desktop/src/features/messages/lib/deferredTimelineTrim.test.mjs new file mode 100644 index 000000000..65cd084cb --- /dev/null +++ b/desktop/src/features/messages/lib/deferredTimelineTrim.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createDeferredTimelineTrim } from "./deferredTimelineTrim.ts"; + +function fakeTimerHost() { + let nextId = 1; + const callbacks = new Map(); + return { + host: { + clearTimeout(id) { + callbacks.delete(id); + }, + setTimeout(callback) { + const id = nextId++; + callbacks.set(id, callback); + return id; + }, + }, + flush() { + for (const [id, callback] of [...callbacks]) { + callbacks.delete(id); + callback(); + } + }, + }; +} + +test("StrictMode setup cancels the synthetic cleanup trim", () => { + const timers = fakeTimerHost(); + const scheduler = createDeferredTimelineTrim(timers.host); + let trims = 0; + + scheduler.schedule("channel-a", () => trims++); + scheduler.cancel("channel-a"); + timers.flush(); + + assert.equal(trims, 0); +}); + +test("a genuine channel departure trims after the deferred task", () => { + const timers = fakeTimerHost(); + const scheduler = createDeferredTimelineTrim(timers.host); + let trims = 0; + + scheduler.schedule("channel-a", () => trims++); + timers.flush(); + + assert.equal(trims, 1); +}); diff --git a/desktop/src/features/messages/lib/deferredTimelineTrim.ts b/desktop/src/features/messages/lib/deferredTimelineTrim.ts new file mode 100644 index 000000000..f9441a9fe --- /dev/null +++ b/desktop/src/features/messages/lib/deferredTimelineTrim.ts @@ -0,0 +1,36 @@ +type TimerHost = { + setTimeout: ( + handler: () => void, + delay: number, + ) => ReturnType; + clearTimeout: (timer: ReturnType) => void; +}; + +/** + * Defers leave-only cache trims by one task. React StrictMode immediately + * re-runs an effect after its synthetic cleanup, so the matching setup can + * cancel that trim while a real channel departure lets it run. + */ +export function createDeferredTimelineTrim(host: TimerHost = globalThis) { + const pending = new Map>(); + + return { + cancel(channelId: string) { + const timer = pending.get(channelId); + if (timer !== undefined) { + host.clearTimeout(timer); + pending.delete(channelId); + } + }, + schedule(channelId: string, trim: () => void) { + this.cancel(channelId); + const timer = host.setTimeout(() => { + pending.delete(channelId); + trim(); + }, 0); + pending.set(channelId, timer); + }, + }; +} + +export const deferredTimelineTrim = createDeferredTimelineTrim();