This commit is contained in:
Timothy Jaeryang Baek
2026-04-23 19:39:06 +09:00
parent f162d4de90
commit d56d74b387
2 changed files with 13 additions and 117 deletions

View File

@@ -60,102 +60,7 @@
export let messagesCount: number | null = 8;
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);
});
@@ -169,12 +74,6 @@
buildMessages();
// Show all messages during progressive loading (no culling)
visibleStart = 0;
visibleEnd = messages.length;
topSpacerHeight = 0;
bottomSpacerHeight = 0;
await tick();
messagesLoading = false;
@@ -201,7 +100,6 @@
}
messages = _messages.reverse();
visibleEnd = messages.length;
};
// Throttle message list rebuilds to once per animation frame during streaming.
@@ -220,8 +118,6 @@
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) {
@@ -570,13 +466,7 @@
</Loader>
{/if}
<ul role="log" aria-live="polite" aria-relevant="additions" aria-atomic="false">
<!-- 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}
{#each messages as message, messageIdx (message.id)}
<Message
{chatId}
bind:history
@@ -605,11 +495,6 @@
{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" />

View File

@@ -49,7 +49,7 @@
role="listitem"
class="flex flex-col justify-between px-5 mb-3 w-full {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-5xl'} mx-auto rounded-lg group"
: 'max-w-5xl'} mx-auto rounded-lg group message-listitem"
>
{#if history.messages[messageId]}
{#if history.messages[messageId].role === 'user'}
@@ -128,3 +128,14 @@
{/if}
{/if}
</div>
<style>
/* Browser-native virtualization: skip rendering of off-screen messages
without destroying their component trees. Replaces the JS-based
culling that caused catastrophic mount/destroy thrashing. */
.message-listitem {
content-visibility: auto;
contain-intrinsic-size: auto 150px;
}
</style>