Skip to content

Commit 8c3d0c9

Browse files
wpfleger96npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7npub1067mpdna4vy22u9eltmmhtdfw56kww6ve2aze025dtfm4pxg07nqztjfhm
authored
feat(desktop): re-land virtualized timeline to fix macOS beachball (#1250)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1067mpdna4vy22u9eltmmhtdfw56kww6ve2aze025dtfm4pxg07nqztjfhm <7ebdb0b67dab08a570b9faf7bbada97535673b4ccaba2cbd546ad3ba84c87fa6@sprout-oss.stage.blox.sqprod.co>
1 parent 1a61d78 commit 8c3d0c9

19 files changed

Lines changed: 2637 additions & 517 deletions

desktop/src/features/channels/ui/ChannelPane.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -669,9 +669,6 @@ export const ChannelPane = React.memo(function ChannelPane({
669669
hasComposerOverlay={hasMainComposerOverlay}
670670
hasOlderMessages={hasOlderMessages}
671671
isFetchingOlder={isFetchingOlder}
672-
layoutShiftKey={
673-
useSplitAuxiliaryPane ? (openThreadHeadId ?? "closed") : "overlay"
674-
}
675672
isFollowingThreadById={isFollowingThreadById}
676673
isMessageUnreadById={isMessageUnreadById}
677674
personaLookup={personaLookup}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { CONVERGENCE_FRAME_CAP, convergenceStep } from "./scrollConvergence.ts";
5+
6+
function input(overrides) {
7+
return {
8+
targetMessageId: "target",
9+
indexByMessageId: new Map([["target", 100]]),
10+
lastIssuedIndex: null,
11+
librarySettled: false,
12+
stalledOffTarget: false,
13+
framesUsed: 0,
14+
...overrides,
15+
};
16+
}
17+
18+
// --- re-aim / staleness guard ------------------------------------------------
19+
20+
test("convergenceStep: first frame aims at the resolved index, not yet done", () => {
21+
const step = convergenceStep(input({ lastIssuedIndex: null }));
22+
assert.equal(step.nextIndex, 100);
23+
assert.equal(step.done, false);
24+
assert.equal(step.converged, false);
25+
});
26+
27+
test("convergenceStep: re-resolves a shifted index from the map each frame", () => {
28+
// A prepend shifted the target from 100 to 105. The library is still chasing
29+
// the old index (lastIssuedIndex 100); the reducer must aim at the NEW index
30+
// so the adapter re-issues scrollToIndex(105). This is the staleness guard.
31+
const step = convergenceStep(
32+
input({
33+
indexByMessageId: new Map([["target", 105]]),
34+
lastIssuedIndex: 100,
35+
}),
36+
);
37+
assert.equal(step.nextIndex, 105);
38+
assert.equal(step.done, false);
39+
assert.equal(step.converged, false);
40+
});
41+
42+
test("convergenceStep: target removed mid-settle stops with converged=false", () => {
43+
// Target deleted from the map while the loop was chasing it. Terminate so the
44+
// adapter clears the highlight instead of chasing a vanished row.
45+
const step = convergenceStep(
46+
input({
47+
indexByMessageId: new Map(), // target gone
48+
lastIssuedIndex: 100,
49+
framesUsed: 3,
50+
}),
51+
);
52+
assert.equal(step.nextIndex, null);
53+
assert.equal(step.done, true);
54+
assert.equal(step.converged, false);
55+
});
56+
57+
// --- settle ------------------------------------------------------------------
58+
59+
test("convergenceStep: library settled while aiming at current index converges", () => {
60+
const step = convergenceStep(
61+
input({ lastIssuedIndex: 100, librarySettled: true }),
62+
);
63+
assert.equal(step.nextIndex, 100);
64+
assert.equal(step.done, true);
65+
assert.equal(step.converged, true);
66+
});
67+
68+
test("convergenceStep: a settle reported WHILE re-aiming is ignored", () => {
69+
// The index just moved (105) but the library reports settled — that settle is
70+
// on the OLD index (100), so it must NOT count as convergence. The reducer
71+
// keeps going and aims at the new index.
72+
const step = convergenceStep(
73+
input({
74+
indexByMessageId: new Map([["target", 105]]),
75+
lastIssuedIndex: 100,
76+
librarySettled: true,
77+
}),
78+
);
79+
assert.equal(step.nextIndex, 105);
80+
assert.equal(step.done, false);
81+
assert.equal(step.converged, false);
82+
});
83+
84+
test("convergenceStep: aiming at current but not yet settled keeps waiting", () => {
85+
// Library is chasing the right index but its offset hasn't stabilized. The
86+
// reducer returns the same index (so the adapter re-issues NOTHING — issuing
87+
// would reset the library's stableFrames and prevent settling) and waits.
88+
const step = convergenceStep(
89+
input({ lastIssuedIndex: 100, librarySettled: false }),
90+
);
91+
assert.equal(step.nextIndex, 100);
92+
assert.equal(step.reissue, false);
93+
assert.equal(step.done, false);
94+
assert.equal(step.converged, false);
95+
});
96+
97+
// --- off-target stall (liveness) ---------------------------------------------
98+
99+
test("convergenceStep: stalled off-target while aiming at current re-issues same index", () => {
100+
// The library's offset stopped moving but never reached the current index's
101+
// target (its internal reconcile deadlocked after rows re-measured). The
102+
// reducer signals a same-index re-issue to kick it — the loop continues.
103+
const step = convergenceStep(
104+
input({ lastIssuedIndex: 100, stalledOffTarget: true }),
105+
);
106+
assert.equal(step.nextIndex, 100);
107+
assert.equal(step.reissue, true);
108+
assert.equal(step.done, false);
109+
assert.equal(step.converged, false);
110+
});
111+
112+
test("convergenceStep: a stall reported WHILE re-aiming does not re-issue", () => {
113+
// The index just moved (105) but the library reports a stall on the OLD index
114+
// (100). The reducer re-aims at the new index normally; the stale stall must
115+
// NOT trigger a same-index kick (there is no current-index stall to kick).
116+
const step = convergenceStep(
117+
input({
118+
indexByMessageId: new Map([["target", 105]]),
119+
lastIssuedIndex: 100,
120+
stalledOffTarget: true,
121+
}),
122+
);
123+
assert.equal(step.nextIndex, 105);
124+
assert.equal(step.reissue, false);
125+
assert.equal(step.done, false);
126+
assert.equal(step.converged, false);
127+
});
128+
129+
test("convergenceStep: a settle takes priority over a concurrent stall flag", () => {
130+
// Defensive: the adapter computes settle and stall as mutually exclusive, but
131+
// if both arrive, a genuine settle must win (converge) rather than spin on a
132+
// pointless re-issue.
133+
const step = convergenceStep(
134+
input({
135+
lastIssuedIndex: 100,
136+
librarySettled: true,
137+
stalledOffTarget: true,
138+
}),
139+
);
140+
assert.equal(step.done, true);
141+
assert.equal(step.converged, true);
142+
assert.equal(step.reissue, false);
143+
});
144+
145+
// --- frame cap ---------------------------------------------------------------
146+
147+
test("convergenceStep: terminates at the frame cap without converging", () => {
148+
// A row that never settles (librarySettled stays false) must still stop at the
149+
// cap rather than spin forever.
150+
const step = convergenceStep(
151+
input({
152+
lastIssuedIndex: 100,
153+
librarySettled: false,
154+
framesUsed: CONVERGENCE_FRAME_CAP - 1,
155+
}),
156+
);
157+
assert.equal(step.done, true);
158+
assert.equal(step.converged, false);
159+
assert.equal(step.nextIndex, 100);
160+
});
161+
162+
test("convergenceStep: frame cap bounds a perpetually shifting target", () => {
163+
// Drive the loop the way the adapter would: the target index moves every
164+
// frame, so the library never settles. The loop must terminate at the cap.
165+
let lastIssuedIndex = null;
166+
let framesUsed = 0;
167+
let done = false;
168+
let converged = true;
169+
170+
while (framesUsed < CONVERGENCE_FRAME_CAP + 5) {
171+
const movingIndex = 100 + framesUsed; // shifts every frame
172+
const step = convergenceStep(
173+
input({
174+
indexByMessageId: new Map([["target", movingIndex]]),
175+
lastIssuedIndex,
176+
librarySettled: false,
177+
framesUsed,
178+
}),
179+
);
180+
lastIssuedIndex = step.nextIndex;
181+
framesUsed += 1;
182+
if (step.done) {
183+
done = step.done;
184+
converged = step.converged;
185+
break;
186+
}
187+
}
188+
189+
assert.equal(done, true);
190+
assert.equal(converged, false);
191+
assert.ok(framesUsed <= CONVERGENCE_FRAME_CAP);
192+
});
193+
194+
test("convergenceStep: converges once a re-aimed index then settles", () => {
195+
// Realistic flow: frame 0 aims (lastIssued null -> 100), frame 1 the library
196+
// is chasing 100 and reports settled -> converged.
197+
const aim = convergenceStep(input({ lastIssuedIndex: null }));
198+
assert.equal(aim.nextIndex, 100);
199+
assert.equal(aim.done, false);
200+
201+
const settle = convergenceStep(
202+
input({
203+
lastIssuedIndex: aim.nextIndex,
204+
librarySettled: true,
205+
framesUsed: 1,
206+
}),
207+
);
208+
assert.equal(settle.done, true);
209+
assert.equal(settle.converged, true);
210+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Pure staleness + termination decision for scrolling a virtualized timeline to
3+
* a message that may be far off-screen.
4+
*
5+
* @tanstack/react-virtual already owns the OFFSET convergence: a single
6+
* `scrollToIndex(index)` captures that index in `scrollState`, and its internal
7+
* `reconcileScroll` rAF loop re-runs `getOffsetForIndex(index)` every frame —
8+
* re-aiming as off-screen rows mount and `measureElement` corrects their
9+
* heights — until the offset is stable (or a 5s safety valve fires). We do NOT
10+
* recompute offsets; duplicating `getOffsetForIndex` against the library's own
11+
* `measurementsCache`/`scrollMargin`/`scrollPadding` would only drift.
12+
*
13+
* What the library does NOT do: it chases the INDEX captured at call time, with
14+
* no concept of a message id. If the data shifts mid-settle — a prepend or a
15+
* delete above the target — the captured index now points at the wrong row and
16+
* the library happily settles on it. This reducer owns exactly that gap: each
17+
* frame it re-resolves the target's CURRENT index from the live map and decides
18+
* whether the adapter must re-aim the library, let it settle, or stop.
19+
*
20+
* Two correctness properties this enforces and the `.mjs` suite gates:
21+
* - The target index is re-resolved by id every frame (never frozen), so a
22+
* concurrent prepend/delete that shifts the target re-aims the library at the
23+
* new index instead of stranding it on the old one.
24+
* - If the target id leaves the data mid-settle (deleted), the loop terminates
25+
* with `converged: false` rather than chasing a vanished row to the cap.
26+
*
27+
* Plus one liveness property: a large windowed-out jump can leave the library's
28+
* own reconcile deadlocked off-target (offset stable but short of the target
29+
* after rows re-measured under it). When that happens the reducer signals a
30+
* same-index re-issue (`reissue: true`) to restart the library's reconcile —
31+
* the single case where re-issuing an unchanged index is correct.
32+
*/
33+
34+
/** Where a scroll target should land in the viewport. Mirrors the library's align. */
35+
export type ConvergenceAlign = "start" | "center" | "end";
36+
37+
export type ConvergenceInput = {
38+
/** Id of the message to settle on — re-resolved against the map each frame. */
39+
targetMessageId: string;
40+
/** Live message-id -> item-index map; re-read every frame (staleness guard). */
41+
indexByMessageId: Map<string, number>;
42+
/**
43+
* Index the library is currently chasing (the last index the adapter issued
44+
* via `scrollToIndex`), or `null` before the first issue. Lets the reducer
45+
* tell a re-aim (index moved) from a steady settle (index unchanged).
46+
*/
47+
lastIssuedIndex: number | null;
48+
/**
49+
* Whether the library's offset reached the current index's target this frame
50+
* and stopped moving. Only meaningful once the library is chasing the CURRENT
51+
* index; a settle reported while re-aiming is ignored.
52+
*/
53+
librarySettled: boolean;
54+
/**
55+
* Whether the library's offset has stopped moving but is NOT at the current
56+
* index's target — it stalled mid-reconcile (its internal re-aim deadlocked
57+
* after rows re-measured under it). The reducer kicks it with a fresh re-issue
58+
* at the same index. Mutually exclusive with `librarySettled`.
59+
*/
60+
stalledOffTarget: boolean;
61+
/** Frames already spent in the loop (the adapter increments per rAF). */
62+
framesUsed: number;
63+
};
64+
65+
export type ConvergenceDecision = {
66+
/**
67+
* Index the adapter should be aiming the library at, or `null` when the
68+
* target is gone. The adapter only re-issues `scrollToIndex` when this differs
69+
* from `lastIssuedIndex`, so a steady settle issues no redundant scroll (which
70+
* would reset the library's `stableFrames` and prevent it from ever settling).
71+
*/
72+
nextIndex: number | null;
73+
/**
74+
* True when the adapter must re-issue `scrollToIndex(nextIndex)` even though
75+
* the index is unchanged — used to kick the library out of an off-target
76+
* stall. A normal steady settle leaves this false so no redundant scroll
77+
* resets the library's `stableFrames`.
78+
*/
79+
reissue: boolean;
80+
/** True once the loop must stop (settled, target gone, or frame cap hit). */
81+
done: boolean;
82+
/** True only when the loop stopped because the target row actually settled. */
83+
converged: boolean;
84+
};
85+
86+
/**
87+
* Hard cap on frames so a perpetually re-measuring row, or a target whose index
88+
* keeps shifting, can't spin the loop forever. The library has its own 5s valve;
89+
* this is the adapter-side bound expressed in frames for deterministic testing.
90+
*/
91+
export const CONVERGENCE_FRAME_CAP = 32;
92+
93+
/**
94+
* One frame of the convergence loop. Pure: given the live map and the library's
95+
* settle state, decides the index to aim at and whether to stop.
96+
*/
97+
export function convergenceStep(input: ConvergenceInput): ConvergenceDecision {
98+
const currentIndex = input.indexByMessageId.get(input.targetMessageId);
99+
100+
// Target left the data mid-settle (deleted) — stop without converging so the
101+
// adapter clears the highlight instead of chasing a vanished row.
102+
if (currentIndex === undefined) {
103+
return { nextIndex: null, reissue: false, done: true, converged: false };
104+
}
105+
106+
const aimingAtCurrent = input.lastIssuedIndex === currentIndex;
107+
108+
// The library only settles meaningfully once it is chasing the CURRENT index.
109+
// A settle reported while we are still re-aiming (index just moved) is stale.
110+
if (aimingAtCurrent && input.librarySettled) {
111+
return {
112+
nextIndex: currentIndex,
113+
reissue: false,
114+
done: true,
115+
converged: true,
116+
};
117+
}
118+
119+
// Frame cap: accept the best index we have rather than spin forever on a row
120+
// whose height never settles or a target whose index keeps shifting.
121+
if (input.framesUsed + 1 >= CONVERGENCE_FRAME_CAP) {
122+
return {
123+
nextIndex: currentIndex,
124+
reissue: false,
125+
done: true,
126+
converged: false,
127+
};
128+
}
129+
130+
// Library stalled off-target: its offset stopped moving but never reached the
131+
// current index (its internal reconcile deadlocked after rows re-measured).
132+
// Re-issue the SAME index to restart its reconcile — the one case where a
133+
// same-index re-issue is correct rather than a stableFrames-resetting bug.
134+
if (aimingAtCurrent && input.stalledOffTarget) {
135+
return {
136+
nextIndex: currentIndex,
137+
reissue: true,
138+
done: false,
139+
converged: false,
140+
};
141+
}
142+
143+
// Either the index moved (adapter will re-issue scrollToIndex) or the library
144+
// is still settling on the current index (adapter issues nothing, just waits).
145+
return {
146+
nextIndex: currentIndex,
147+
reissue: false,
148+
done: false,
149+
converged: false,
150+
};
151+
}

0 commit comments

Comments
 (0)