fix live tape scroll stability

This commit is contained in:
dirtydishes 2026-05-17 03:33:06 -04:00
parent 1424a2716f
commit d334e16874
5 changed files with 298 additions and 14 deletions

View file

@ -1039,11 +1039,27 @@ h3 {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background-color: oklch(0.12 0.01 250);
}
.data-table-body {
position: relative;
min-width: 100%;
--tape-row-height: 36px;
--tape-row-double-height: 72px;
background:
repeating-linear-gradient(
to bottom,
oklch(0.98 0.008 250 / 0.01) 0,
oklch(0.98 0.008 250 / 0.01) calc(var(--tape-row-height) - 1px),
oklch(0.72 0.012 250 / 0.08) calc(var(--tape-row-height) - 1px),
oklch(0.72 0.012 250 / 0.08) var(--tape-row-height),
oklch(0.98 0.008 250 / 0.018) var(--tape-row-height),
oklch(0.98 0.008 250 / 0.018) calc(var(--tape-row-double-height) - 1px),
oklch(0.72 0.012 250 / 0.08) calc(var(--tape-row-double-height) - 1px),
oklch(0.72 0.012 250 / 0.08) var(--tape-row-double-height)
),
oklch(0.12 0.01 250);
}
.data-table-options {
@ -1137,6 +1153,14 @@ h3 {
height: 44px;
}
.data-table-flow .data-table-body,
.data-table-alerts .data-table-body,
.data-table-classifier .data-table-body,
.data-table-dark .data-table-body {
--tape-row-height: 44px;
--tape-row-double-height: 88px;
}
.data-table-row-classified {
background:
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.012 + var(--classifier-intensity, 0) * 0.06)), transparent 62%),

View file

@ -24,6 +24,7 @@ import {
getLiveManifest,
getRouteFeatures,
getTapeVirtualConfig,
mergeHeldTapeHistory,
mergeNewestWithOverflow,
normalizeAlertSeverity,
normalizeTickerFilterInput,
@ -394,12 +395,12 @@ describe("route feature map", () => {
describe("fixed tape virtualization config", () => {
it("uses expected fixed row heights and overscan by table", () => {
expect(getTapeVirtualConfig("options")).toEqual({ rowHeight: 36, overscan: 24, debugLabel: "options" });
expect(getTapeVirtualConfig("equities")).toEqual({ rowHeight: 36, overscan: 20, debugLabel: "equities" });
expect(getTapeVirtualConfig("flow")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "flow" });
expect(getTapeVirtualConfig("alerts")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "alerts" });
expect(getTapeVirtualConfig("classifier")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "classifier" });
expect(getTapeVirtualConfig("dark")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "dark" });
expect(getTapeVirtualConfig("options")).toEqual({ rowHeight: 36, overscan: 44, debugLabel: "options" });
expect(getTapeVirtualConfig("equities")).toEqual({ rowHeight: 36, overscan: 36, debugLabel: "equities" });
expect(getTapeVirtualConfig("flow")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "flow" });
expect(getTapeVirtualConfig("alerts")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "alerts" });
expect(getTapeVirtualConfig("classifier")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "classifier" });
expect(getTapeVirtualConfig("dark")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "dark" });
});
});
@ -683,6 +684,53 @@ describe("live tape history helpers", () => {
const nextKeys = ["anchor", "after-1", "after-2", "older-1", "older-2"];
expect(findAnchorRestoreIndex(nextKeys, "anchor", ["anchor", "after-1", "after-2"])).toBe(0);
});
it("keeps held ClickHouse history stable when newer live overflow arrives", () => {
const frozenLive = [makeItem("hot-5", 5, 500), makeItem("hot-4", 4, 400)];
const displayed = [makeItem("hist-3", 3, 300), makeItem("hist-2", 2, 200)];
const incoming = [
makeItem("overflow-newer", 6, 600),
makeItem("hot-4", 4, 400),
makeItem("hist-3", 3, 300),
makeItem("hist-2", 2, 200)
];
expect(mergeHeldTapeHistory(displayed, incoming, frozenLive).map((item) => item.trace_id)).toEqual([
"hist-3",
"hist-2"
]);
});
it("appends truly older lazy-loaded rows to the held history tail", () => {
const frozenLive = [makeItem("hot-5", 5, 500), makeItem("hot-4", 4, 400)];
const displayed = [makeItem("hist-3", 3, 300), makeItem("hist-2", 2, 200)];
const incoming = [
makeItem("hist-3", 3, 300),
makeItem("hist-2", 2, 200),
makeItem("older-1", 1, 100),
makeItem("older-0", 0, 50)
];
expect(mergeHeldTapeHistory(displayed, incoming, frozenLive).map((item) => item.trace_id)).toEqual([
"hist-3",
"hist-2",
"older-1",
"older-0"
]);
});
it("resyncs buffered live history by replacing the held segment after resume", () => {
const frozenLive = [makeItem("hot-5", 5, 500), makeItem("hot-4", 4, 400)];
const held = mergeHeldTapeHistory(
[makeItem("hist-3", 3, 300), makeItem("hist-2", 2, 200)],
[makeItem("overflow-newer", 6, 600), makeItem("hist-3", 3, 300), makeItem("older-1", 1, 100)],
frozenLive
);
const resynced = appendHistoryTail([], [makeItem("overflow-newer", 6, 600), ...held], [], 0);
expect(held.map((item) => item.trace_id)).toEqual(["hist-3", "hist-2", "older-1"]);
expect(resynced.map((item) => item.trace_id)).toEqual(["overflow-newer", "hist-3", "hist-2", "older-1"]);
});
});
describe("options display formatters", () => {

View file

@ -142,12 +142,12 @@ type TapeVirtualListConfig = {
};
const TAPE_VIRTUAL_CONFIG: Record<TapeVirtualPane, TapeVirtualListConfig> = {
options: { rowHeight: 36, overscan: 24, debugLabel: "options" },
equities: { rowHeight: 36, overscan: 20, debugLabel: "equities" },
flow: { rowHeight: 44, overscan: 16, debugLabel: "flow" },
alerts: { rowHeight: 44, overscan: 16, debugLabel: "alerts" },
classifier: { rowHeight: 44, overscan: 16, debugLabel: "classifier" },
dark: { rowHeight: 44, overscan: 16, debugLabel: "dark" }
options: { rowHeight: 36, overscan: 44, debugLabel: "options" },
equities: { rowHeight: 36, overscan: 36, debugLabel: "equities" },
flow: { rowHeight: 44, overscan: 24, debugLabel: "flow" },
alerts: { rowHeight: 44, overscan: 24, debugLabel: "alerts" },
classifier: { rowHeight: 44, overscan: 24, debugLabel: "classifier" },
dark: { rowHeight: 44, overscan: 24, debugLabel: "dark" }
};
export const getTapeVirtualConfig = (pane: TapeVirtualPane): TapeVirtualListConfig =>
@ -844,6 +844,30 @@ export const appendHistoryTail = <T extends SortableItem>(
return cap > 0 ? combined.slice(0, cap) : combined;
};
export const mergeHeldTapeHistory = <T extends SortableItem>(
displayedHistory: T[],
incomingHistory: T[],
frozenLiveHead: T[]
): T[] => {
if (displayedHistory.length === 0) {
return appendHistoryTail([], incomingHistory, frozenLiveHead, 0);
}
const sortedDisplayed = appendHistoryTail([], displayedHistory, frozenLiveHead, 0);
const tail = sortedDisplayed.at(-1);
const tailTs = tail ? extractSortTs(tail) : Number.POSITIVE_INFINITY;
const tailSeq = tail ? extractSortSeq(tail) : Number.POSITIVE_INFINITY;
const olderIncoming = incomingHistory.filter((item) => {
const itemTs = extractSortTs(item);
if (itemTs < tailTs) {
return true;
}
return itemTs === tailTs && extractSortSeq(item) < tailSeq;
});
return appendHistoryTail(sortedDisplayed, olderIncoming, frozenLiveHead, 0);
};
export const getLiveHistoryRetentionCap = (subscription: LiveSubscription): number => {
switch (subscription.channel) {
case "options":
@ -2491,6 +2515,7 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
config: PausableTapeViewConfig<T>
): TapeState<T> => {
const [data, setData] = useState<PausableTapeData<T>>(EMPTY_PAUSABLE_TAPE);
const displayedHistoryRef = useRef<T[]>([]);
const holdForScroll = config.enabled ? (config.shouldHold ? config.shouldHold() : false) : false;
useEffect(() => {
@ -2557,13 +2582,31 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
const status = config.enabled ? config.sourceStatus : "disconnected";
const projected = projectPausableTapeState(data.visible, status, config.lastUpdate);
const historyItems = config.historyTail ?? [];
const items = useMemo(() => composeTapeItems([], projected.items, historyItems), [projected.items, historyItems]);
const displayedHistoryItems = useMemo(() => {
if (!config.enabled) {
displayedHistoryRef.current = [];
return [];
}
if (!holdForScroll) {
displayedHistoryRef.current = historyItems;
return historyItems;
}
const next = mergeHeldTapeHistory(displayedHistoryRef.current, historyItems, projected.items);
displayedHistoryRef.current = next;
return next;
}, [config.enabled, historyItems, holdForScroll, projected.items]);
const items = useMemo(
() => composeTapeItems([], projected.items, displayedHistoryItems),
[projected.items, displayedHistoryItems]
);
return {
status,
items,
liveItems: projected.items,
historyItems,
historyItems: displayedHistoryItems,
lastUpdate: projected.lastUpdate,
replayTime: null,
replayComplete: false,