This commit is contained in:
Timothy Jaeryang Baek
2026-04-13 15:58:33 -05:00
parent 2991d9f1f0
commit 026903399b
+119 -6
View File
@@ -57,18 +57,119 @@
export let onSelect = (e) => {};
export let messagesCount: number | null = 20;
export let messagesCount: number | null = 10;
let messagesLoading = false;
// Off-screen message unloading. Heights are measured on scroll so spacers
// always match real sizes — no scroll jumps, no feedback loops needed.
const OVERSCAN = 3;
const DEFAULT_HEIGHT = 150;
let visibleStart = 0;
let visibleEnd = 0;
let messageHeights = new Map();
let topSpacerHeight = 0;
let bottomSpacerHeight = 0;
let pendingCull = null;
// Helper: get height for a message (cached or default)
const heightOf = (id) => messageHeights.get(id) ?? DEFAULT_HEIGHT;
/** Measure all currently rendered message elements and cache their heights */
const measureMessageHeights = () => {
const elements = document.getElementById('messages-container')?.querySelectorAll('[role="listitem"]');
if (!elements) return;
messageHeights = new Map([
...messageHeights,
...Array.from(elements)
.map((el, i) => [messages[visibleStart + i]?.id, el.getBoundingClientRect().height])
.filter(([id]) => id != null)
]);
};
/** Compute visible range from current scroll position and apply */
const updateVisibleRange = () => {
const container = document.getElementById('messages-container');
if (!container || messages.length === 0) return;
const st = container.scrollTop;
const ch = container.clientHeight;
// Build prefix sums from measured heights
const prefixSums = messages.reduce(
(acc, m) => [...acc, acc[acc.length - 1] + heightOf(m.id)],
[0]
);
const firstVisible = Math.max(0, prefixSums.findIndex((h) => h > st) - 1);
const lastVisible = prefixSums.findIndex((h) => h > st + ch);
// Only cull messages that have been measured (so spacer height is accurate)
// findIndex returns -1 when all are measured → no limit on culling
const firstUnmeasured = messages.findIndex((m) => !messageHeights.has(m.id));
const cullLimit = firstUnmeasured === -1 ? messages.length : firstUnmeasured;
visibleStart = Math.max(0, Math.min(firstVisible - OVERSCAN, cullLimit));
visibleEnd = Math.min(messages.length, (lastVisible === -1 ? messages.length : lastVisible) + OVERSCAN);
topSpacerHeight = prefixSums[visibleStart] ?? 0;
bottomSpacerHeight = (prefixSums[messages.length] ?? 0) - (prefixSums[visibleEnd] ?? 0);
};
/** Scroll handler: measure every frame, cull via rAF (same throttle as pendingRebuild) */
const handleContainerScroll = () => {
measureMessageHeights();
// Don't cull during progressive loading
if (messagesLoading) return;
if (!pendingCull) {
pendingCull = requestAnimationFrame(() => {
pendingCull = null;
updateVisibleRange();
});
}
};
let scrollListenerAttached = false;
const attachScrollListener = () => {
if (scrollListenerAttached) return;
const container = document.getElementById('messages-container');
if (!container) return;
container.addEventListener('scroll', handleContainerScroll, { passive: true });
scrollListenerAttached = true;
};
onMount(() => {
attachScrollListener();
});
onDestroy(() => {
const container = document.getElementById('messages-container');
if (container && scrollListenerAttached) {
container.removeEventListener('scroll', handleContainerScroll);
}
cancelAnimationFrame(pendingCull);
cancelAnimationFrame(pendingRebuild);
});
const loadMoreMessages = async () => {
// scroll slightly down to disable continuous loading
const element = document.getElementById('messages-container');
element.scrollTop = element.scrollTop + 100;
messagesLoading = true;
messagesCount += 20;
messagesCount += 10;
buildMessages();
// Show all messages during progressive loading (no culling)
visibleStart = 0;
visibleEnd = messages.length;
topSpacerHeight = 0;
bottomSpacerHeight = 0;
await tick();
messagesLoading = false;
@@ -95,6 +196,7 @@
}
messages = _messages.reverse();
visibleEnd = messages.length;
};
// Throttle message list rebuilds to once per animation frame during streaming.
@@ -113,6 +215,8 @@
cancelAnimationFrame(pendingRebuild);
pendingRebuild = null;
buildMessages();
// No explicit culling needed — scrollToBottom will fire a scroll event,
// which triggers handleContainerScroll → rAF → updateVisibleRange
} else if (_messages) {
// Content update (streaming) — throttle to once per frame
if (!pendingRebuild) {
@@ -426,9 +530,7 @@
showMessage({ id: parentMessageId }, false);
};
onDestroy(() => {
cancelAnimationFrame(pendingRebuild);
});
const triggerScroll = () => {
if (autoScroll) {
@@ -465,7 +567,13 @@
</Loader>
{/if}
<ul role="log" aria-live="polite" aria-relevant="additions" aria-atomic="false">
{#each messages as message, messageIdx (message.id)}
<!-- Top spacer: sum of cached heights for messages above visible range -->
{#if topSpacerHeight > 0}
<div style="height: {topSpacerHeight}px" aria-hidden="true" />
{/if}
{#each messages.slice(visibleStart, visibleEnd) as message, i (message.id)}
{@const messageIdx = visibleStart + i}
<Message
{chatId}
bind:history
@@ -494,6 +602,11 @@
{topPadding}
/>
{/each}
<!-- Bottom spacer: sum of cached heights for messages below visible range -->
{#if bottomSpacerHeight > 0}
<div style="height: {bottomSpacerHeight}px" aria-hidden="true" />
{/if}
</ul>
</section>
<div class="pb-18" />