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>
This commit is contained in:
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
2026-07-02 16:47:42 -04:00
parent b56b621195
commit 59daa7cc5b
3 changed files with 95 additions and 7 deletions
+9 -7
View File
@@ -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<RelayEvent[]>(
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<void>) | 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]);
}
@@ -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);
});
@@ -0,0 +1,36 @@
type TimerHost = {
setTimeout: (
handler: () => void,
delay: number,
) => ReturnType<typeof setTimeout>;
clearTimeout: (timer: ReturnType<typeof setTimeout>) => 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<string, ReturnType<typeof setTimeout>>();
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();