ui : mount chat messages lazily near the viewport

Every message row mounted its full component tree on load, so the cycle
collector, GC and layout invalidation kept walking every live object and
DOM node even for rows the user never scrolls to - which dominated the
profile of long conversations. Wrap each row in a placeholder with an
IntersectionObserver ( two viewport heights of runway ) that swaps in the
real ChatMessage when the row approaches the viewport; the row shell
keeps the content-visibility sizing, and rows stay mounted once
realized. Rows targeted by the pending-edit flow mount eagerly.

Assisted-by: pi:zai-org/GLM-5.3
This commit is contained in:
Aleksander Grygier
2026-09-06 01:26:16 +02:00
parent bb338bb5c8
commit e38e511c51
3 changed files with 109 additions and 25 deletions
@@ -404,7 +404,7 @@
}
</script>
<div class:chat-message--synthetic={isSynthetic} class="chat-message">
<div>
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem bind:textareaElement class={className} {message} />
{:else if mcpPromptExtra}
@@ -425,25 +425,3 @@
/>
{/if}
</div>
<style>
/*
* The browser skips layout and paint for messages outside the
* viewport. contain-intrinsic-size reuses the last rendered size
* once known; 500px sizes messages that have never been rendered.
*/
.chat-message {
--chat-message-intrinsic-size: 500px;
content-visibility: auto;
contain-intrinsic-size: auto var(--chat-message-intrinsic-size);
}
/*
* Synthetic rows (e.g. the working-directory change) are small, so an
* accurate placeholder keeps the injected row from inflating the
* auto-scroll offset; the 500px default is for ordinary bubbles.
*/
.chat-message--synthetic {
--chat-message-intrinsic-size: 40px;
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
import LazyChatMessage from './LazyChatMessage.svelte';
import { ChatMessageUserPending } from '$lib/components/app';
import { MessageRole } from '$lib/enums';
import { agenticStore, chatStore, conversationsStore, settingsStore } from '$lib/stores';
import type { ChatMessageActions } from '$lib/types';
@@ -238,7 +239,7 @@
<div>
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
<ChatMessage
<LazyChatMessage
{chatActions}
class="mx-auto mt-12 w-full max-w-3xl"
{isLastAssistantMessage}
@@ -0,0 +1,105 @@
<script lang="ts">
import ChatMessage from './ChatMessage/ChatMessage.svelte';
import { chatStore } from '$lib/stores';
import type { ChatMessageActions } from '$lib/types';
interface Props {
chatActions: ChatMessageActions;
class?: string;
isLastAssistantMessage?: boolean;
isLastUserMessage?: boolean;
message: DatabaseMessage;
nextAssistantMessage?: DatabaseMessage | null;
siblingInfo?: ChatMessageSiblingInfo | null;
toolMessages?: DatabaseMessage[];
}
let {
chatActions,
class: className = '',
isLastAssistantMessage = false,
isLastUserMessage = false,
message,
nextAssistantMessage = null,
siblingInfo = null,
toolMessages = []
}: Props = $props();
// A mounted message row is a whole component tree (contexts, effects,
// collapsibles, markdown blocks), and the cycle collector, GC and layout
// invalidation keep walking every live object and DOM node, even for
// rows the user never scrolls to. Mount the real tree only when the row
// approaches the viewport; until then the row is an empty placeholder
// that reserves its size through content-visibility.
let mounted = $state(false);
let wrapperEl: HTMLDivElement | undefined = $state();
$effect(() => {
if (mounted || !wrapperEl) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
mounted = true;
observer.disconnect();
}
},
// pre-mount a couple of viewport heights ahead of the scroll
// position so a fast scroll never meets an empty row
{ rootMargin: '200% 0px' }
);
observer.observe(wrapperEl);
return () => observer.disconnect();
});
// Flows that target a row by id (pending edit) expect the message
// component and its effects to exist; mount the target row first
$effect(() => {
if (chatStore.pendingEditMessageId === message.id) {
mounted = true;
}
});
</script>
<div
bind:this={wrapperEl}
class:chat-message--synthetic={Boolean(message.isSynthetic)}
class="chat-message"
>
{#if mounted}
<ChatMessage
{chatActions}
class={className}
{isLastAssistantMessage}
{isLastUserMessage}
{message}
{nextAssistantMessage}
{siblingInfo}
{toolMessages}
/>
{/if}
</div>
<style>
/*
* The browser skips layout and paint for messages outside the
* viewport. contain-intrinsic-size reuses the last rendered size
* once known; 500px sizes messages that have never been rendered.
*/
.chat-message {
--chat-message-intrinsic-size: 500px;
content-visibility: auto;
contain-intrinsic-size: auto var(--chat-message-intrinsic-size);
}
/*
* Synthetic rows (e.g. the working-directory change) are small, so an
* accurate placeholder keeps the injected row from inflating the
* auto-scroll offset; the 500px default is for ordinary bubbles.
*/
.chat-message--synthetic {
--chat-message-intrinsic-size: 40px;
}
</style>