fix(chat): keep scroll container mounted during initial-load skeleton

The initial-load skeleton was rendered as an early return that replaced
the JSX containing `containerRef` / `contentRef`. On slow loads, the
200ms threshold flipped the skeleton on before messages arrived, so when
messages did arrive every scroll-affecting effect (Effect A, the
ResizeObserver, the load handler, scrollend) re-fired exactly once
against null refs and bailed — and never re-attached because no dep
changed when the skeleton finally cleared. Net: chat opened scrolled to
the top instead of the bottom; saved-anchor restore was equally broken.

Render the skeleton as an absolutely-positioned overlay alongside the
(always-mounted) scroll container so all refs stay live across the
loading transition. Encode the constraint in the spec as the
"ContainerRef invariant" so future loading/empty/error UI doesn't
reintroduce the early-return pattern.
This commit is contained in:
Jannis Braun
2026-05-05 00:19:00 +02:00
parent b3cbe9403f
commit c05c04181d
2 changed files with 38 additions and 18 deletions
@@ -569,24 +569,16 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
);
}
if (showInitialSkeleton) {
return (
<div className="flex-1 flex flex-col justify-end px-4 pb-6" role="status" aria-label="Loading messages">
{Array.from({ length: 7 }, (_, i) => (
<div key={i} className="flex gap-3 mb-5" style={{ animationDelay: `${i * 0.15}s` }}>
<div className="skeleton skeleton-circle w-10 h-10 flex-shrink-0" style={{ animationDelay: `${i * 0.15}s` }} />
<div className="flex-1 space-y-2 pt-1">
<div className="skeleton skeleton-bar" style={{ width: `${20 + (i * 7) % 20}%`, animationDelay: `${i * 0.15}s` }} />
<div className="skeleton skeleton-bar h-2.5" style={{ width: `${50 + (i * 13) % 40}%`, animationDelay: `${i * 0.15}s` }} />
{i % 2 === 0 && (
<div className="skeleton skeleton-bar h-2.5" style={{ width: `${30 + (i * 11) % 35}%`, animationDelay: `${i * 0.15}s` }} />
)}
</div>
</div>
))}
</div>
);
}
// The initial-load skeleton is rendered as an absolutely-positioned overlay
// (NOT an early return) so that the scroll container below — and its
// `containerRef` / `contentRef` — stay mounted across the loading transition.
// Auto-scroll effects (initial snap, ResizeObserver, load-handler, scrollend)
// are keyed on `[messages.length, channelId]` / `[hasMessages, channelId]`,
// and each commits its only re-fire signal during the load window. If the
// refs were null at that moment (which they are if the skeleton replaces the
// container via early-return), every effect bails on its null guard and never
// re-attaches once the skeleton clears, leaving the user scrolled to the top.
// See docs/systems/message-list.md "ContainerRef invariant".
return (
<div className="flex-1 relative min-h-0">
@@ -658,6 +650,27 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
<div ref={bottomRef} />
</div>
{showInitialSkeleton && (
<div
className="absolute inset-0 z-10 bg-surface-chat flex flex-col justify-end px-4 pb-6 pointer-events-none"
role="status"
aria-label="Loading messages"
>
{Array.from({ length: 7 }, (_, i) => (
<div key={i} className="flex gap-3 mb-5" style={{ animationDelay: `${i * 0.15}s` }}>
<div className="skeleton skeleton-circle w-10 h-10 flex-shrink-0" style={{ animationDelay: `${i * 0.15}s` }} />
<div className="flex-1 space-y-2 pt-1">
<div className="skeleton skeleton-bar" style={{ width: `${20 + (i * 7) % 20}%`, animationDelay: `${i * 0.15}s` }} />
<div className="skeleton skeleton-bar h-2.5" style={{ width: `${50 + (i * 13) % 40}%`, animationDelay: `${i * 0.15}s` }} />
{i % 2 === 0 && (
<div className="skeleton skeleton-bar h-2.5" style={{ width: `${30 + (i * 11) % 35}%`, animationDelay: `${i * 0.15}s` }} />
)}
</div>
</div>
))}
</div>
)}
{!isNearBottom && messages.length > 0 && (
<button
onClick={() => {