Skip to content

Commit 0bca81a

Browse files
authored
perf(chat): cap mobile memory with content-visibility on messages (#2391)
* perf(chat): cap mobile memory with content-visibility on messages Long conversations render every message at once (no virtualization). On mobile WebKit each tab renderer has a hard ~2 GB cap; a few hundred laid-out messages plus paint backing stores exceeds it, so iOS Safari jetsam-kills the tab and shows "A problem repeatedly occurred". Add content-visibility:auto + contain-intrinsic-size to each message wrapper so off-screen messages skip layout and paint, keeping peak renderer memory bounded. content-visibility implies paint containment, so overflow-clip-margin is added to keep the copy/retry/edit controls (which sit just below each message) painting. * fix(chat): converge scroll-to-bottom for content-visibility messages content-visibility:auto messages start at their contain-intrinsic-size estimate, so the single initial scrollToBottom on conversation load/switch could land above the true bottom once off-screen messages render at full height. On that path spacerActive is 0, so ChatWindow's corrective ResizeObserver is inactive and the action's own observer watches the h-full container that never resizes. Add a bounded (max 5 frame) re-scroll on the instant scroll paths (initial mount and conversation switch) that converges to the real bottom and bails early if the user scrolls or we are already at the bottom. The smooth new-message path is unchanged (ChatWindow's spacer observer already handles it). Addresses PR review feedback.
1 parent eb7d041 commit 0bca81a

2 files changed

Lines changed: 53 additions & 4 deletions

File tree

src/lib/actions/snapScrollToBottom.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,27 @@ export const snapScrollToBottom = (node: HTMLElement, dependency: MaybeScrollDep
9696
}
9797
};
9898

99+
const nextFrame = () =>
100+
new Promise<void>((resolve) => {
101+
if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => resolve());
102+
else resolve();
103+
});
104+
105+
// content-visibility:auto messages start at their contain-intrinsic-size
106+
// estimate, so a single scrollToBottom can land short of the true bottom: as
107+
// the bottom messages scroll into view they render at full height and grow
108+
// scrollHeight. Re-scroll across a few frames to converge, stopping early once
109+
// we reach the bottom or the user takes over. Bounded so it can never loop
110+
// indefinitely.
111+
const scrollToBottomConverged = async (behavior: ScrollBehavior = "instant") => {
112+
scrollToBottom(behavior);
113+
for (let i = 0; i < 5; i++) {
114+
await nextFrame();
115+
if (isDetached || userScrolling || isAtBottom()) return;
116+
scrollToBottom("instant");
117+
}
118+
};
119+
99120
const scheduleUserScrollEndCheck = () => {
100121
userScrolling = true;
101122
clearUserScrollTimeout();
@@ -176,7 +197,16 @@ export const snapScrollToBottom = (node: HTMLElement, dependency: MaybeScrollDep
176197
clearUserScrollTimeout();
177198

178199
await tick();
179-
scrollToBottom(getScrollBehavior(newDependency));
200+
const behavior = getScrollBehavior(newDependency);
201+
// Conversation switch/load uses "instant" and renders content-visibility
202+
// messages from their size estimate; converge to the real bottom. The
203+
// "smooth" path (new user message) is handled by ChatWindow's spacer
204+
// observer, so keep its single smooth scroll.
205+
if (behavior === "instant") {
206+
await scrollToBottomConverged(behavior);
207+
} else {
208+
scrollToBottom(behavior);
209+
}
180210
return true;
181211
}
182212

@@ -284,7 +314,7 @@ export const snapScrollToBottom = (node: HTMLElement, dependency: MaybeScrollDep
284314
if (dependency) {
285315
void (async () => {
286316
await tick();
287-
scrollToBottom();
317+
await scrollToBottomConverged();
288318
})();
289319
}
290320

src/lib/components/chat/ChatMessage.svelte

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@
374374
{#if message.from === "assistant"}
375375
<div
376376
bind:offsetWidth={messageWidth}
377-
class="group relative -mb-4 flex w-fit max-w-full items-start justify-start gap-4 pb-4 leading-relaxed max-sm:mb-1 {message.routerMetadata &&
377+
class="message-cv group relative -mb-4 flex w-fit max-w-full items-start justify-start gap-4 pb-4 leading-relaxed max-sm:mb-1 {message.routerMetadata &&
378378
messageInfoWidth >= messageWidth
379379
? 'mb-1'
380380
: ''}"
@@ -552,7 +552,7 @@
552552
{/if}
553553
{#if message.from === "user"}
554554
<div
555-
class="group relative {alternatives.length > 1 && editMsdgId === null
555+
class="message-cv group relative {alternatives.length > 1 && editMsdgId === null
556556
? 'mb-7'
557557
: ''} w-full items-start justify-start gap-4"
558558
data-message-id={message.id}
@@ -686,6 +686,25 @@
686686
{/if}
687687

688688
<style>
689+
/* Long conversations render every message at once (no virtualization). On
690+
mobile WebKit each tab's renderer has a hard ~2 GB memory ceiling, and a
691+
few hundred messages worth of laid-out DOM + paint backing stores blows
692+
past it, so iOS Safari jetsam-kills the tab ("A problem repeatedly
693+
occurred"). content-visibility:auto lets the engine skip layout and paint
694+
for off-screen messages, keeping peak memory bounded. contain-intrinsic-size
695+
gives skipped messages a stable placeholder height so the scrollbar and
696+
scroll position stay sensible. content-visibility implies paint containment,
697+
which would clip the copy/retry/edit controls that sit just below each
698+
message (they overflow by up to ~44px); overflow-clip-margin extends the
699+
clip region so those controls keep painting. Browsers without
700+
content-visibility support simply ignore these rules (progressive
701+
enhancement). */
702+
.message-cv {
703+
content-visibility: auto;
704+
contain-intrinsic-size: auto 200px;
705+
overflow-clip-margin: 3rem;
706+
}
707+
689708
@keyframes loading {
690709
to {
691710
stroke-dashoffset: 122.9;

0 commit comments

Comments
 (0)