KnowMore v0.7.3 - Chat Virtual Scrolling & Gallery Architecture

Published on Apr 3, 2026

KnowMore v0.7.3 - Chat Virtual Scrolling & Gallery Architecture

Two separate but complementary improvements land in v0.7.3. Chat gets virtual scrolling using canvas-based height estimation so only the visible bubbles are in the DOM. The gallery is restructured around per-album routes and a team-aware store, shedding over 500 lines from the page component while fixing three production-breaking Svelte 5 reactive patterns along the way.

Table of Contents

Chat Virtual Scrolling

The Problem

Before this release, ChatThread.svelte rendered all loaded messages with {#each messages}. Every loadMore() call prepended the previous batch to the list and kept all of them in the DOM. For a club that has been using the app for months, opening a chat thread could mount several hundred message bubbles at once, each with reactions, read receipts, and embedded rich content cards.

The symptom was not a hard crash but a gradual accumulation: the first open was fine, the fifth was sluggish, and smooth scrolling became difficult after a few loadMore() calls.

Scroll anchoring was a second problem. When loading older messages, the thread saved containerRef.scrollHeight before the fetch and restored position with scrollTop = newScrollHeight - oldScrollHeight after tick(). This worked in most cases but occasionally flickered because the DOM height measurement happened before the browser finished laying out the new nodes.

Pretext: Canvas-Based Height Estimation

The @chenglou/pretext library measures paragraph height using canvas β€” no DOM access, no reflow. It requires one prepare() pass per font specification (cached at module level), then layout(prepared, width, lineHeight) for each individual string.

The height estimation function (src/lib/utils/pretextMeasure.ts) breaks a bubble into fixed and variable parts:

const HEADER_HEIGHT = 20;       // name + timestamp, only on firstInGroup
const REPLY_HEIGHT = 56;        // quoted block, only with reply_to
const REACTION_HEIGHT = 32;     // reaction row, only when reactions exist
const PADDING_HEIGHT = 8;       // mt-2 / mt-0.5
const HEIGHT_RICH_ITEM = 120;   // event / image / gallery / location card estimate

export function estimateBubbleHeight(
    message: MessagesResponse,
    containerWidth: number,
    isFirstInGroup: boolean
): number {
    const maxBubbleWidth = containerWidth * 0.8;
    
    const richMatches = (message.content || '').match(RICH_CONTENT_PATTERN);
    const richHeight = (richMatches?.length ?? 0) * HEIGHT_RICH_ITEM;
    
    const plainText = stripRichContent(message.content || '');
    const textHeight = plainText
        ? measureText(plainText, maxBubbleWidth)
        : 0;

    return (
        (isFirstInGroup ? HEADER_HEIGHT : 0) +
        (message.reply_to ? REPLY_HEIGHT : 0) +
        textHeight +
        richHeight +
        (message.reactions?.length ? REACTION_HEIGHT : 0) +
        PADDING_HEIGHT
    );
}

Heights are cached by message ID and evicted on edit, delete, or container resize. The cache key includes the container width so narrowing the viewport invalidates stale measurements.

TanStack Virtual Integration

ChatThread.svelte now uses @tanstack/svelte-virtual. The virtualizer gets the estimated heights via estimateSize and corrects them after first render via measureElement:

const virtualizer = createVirtualizer({
    get count() { return messages.length; },
    getScrollElement: () => containerRef,
    estimateSize: (index) => {
        const msg = messages[index];
        const isFirst = index === 0 || messages[index - 1]?.sender !== msg.sender;
        return estimateBubbleHeight(msg, containerWidth, isFirst);
    },
    measureElement: (el) => el.getBoundingClientRect().height,
    overscan: 5,
    scrollMargin: containerRef?.offsetTop ?? 0
});

estimateSize runs without touching the DOM. measureElement runs after first render to correct the estimate with the actual browser height. TanStack Virtual handles the reconciliation and updates the item’s offset so subsequent scroll math is accurate.

Only the visible items plus the overscan buffer (5 items above and below the viewport) are rendered at any time. The DOM node count stays roughly constant regardless of how many messages are loaded.

Scroll Anchoring Without DOM Measurement

With pretext heights available before mounting, the loadMore() anchor no longer reads scrollHeight from the DOM:

async function loadMore() {
    const prevCount = messages.length;
    await chatStore.loadMoreMessages();
    
    await tick();
    
    // Estimate the height of the newly prepended batch
    const newMessages = messages.slice(0, messages.length - prevCount);
    const batchHeight = newMessages.reduce((sum, msg, i) => {
        const isFirst = i === 0 || newMessages[i-1]?.sender !== msg.sender;
        return sum + estimateBubbleHeight(msg, containerWidth, isFirst);
    }, 0);

    containerRef.scrollTop += batchHeight;
}

The batch height comes from the pretext cache (already populated when the messages were first loaded into the store), so the scroll adjustment happens synchronously before the browser repaints. No flicker.

Viewport Fill on Mount

A subtler problem: the initial batch of messages was exactly what was fetched from the server β€” often 20-30 messages. If the viewport was tall enough to display all of them with room to spare, the loadMore trigger at the top of the scroll container was immediately visible, but the initial scrollTo(0, bottom) call had already run. The user would see the bottom of the conversation with an empty space above it rather than the thread filling to the top.

The fillContainer() function runs after initial render and checks whether the total estimated height is less than the container height. If so, it calls loadMore() and repeats:

async function fillContainer() {
    if (!containerRef || !chatStore.hasMore) return;
    
    const totalEstimated = messages.reduce((sum, msg, i) => {
        const isFirst = i === 0 || messages[i-1]?.sender !== msg.sender;
        return sum + estimateBubbleHeight(msg, containerWidth, isFirst);
    }, 0);
    
    if (totalEstimated < containerRef.clientHeight) {
        await loadMore();
        await tick();
        fillContainer();
    }
}

This replaces the setTimeout(..., 100) timing workaround that was there before.

What Was Wrong

The gallery was a single 1198-line page component handling store initialization, filter management, masonry grid, lightbox/carousel, likes and comments, bulk download, sharing, and the onboarding tour. The script block alone was 689 lines and contained 8 $effect calls, two of which had production-breaking patterns.

The most damaging bug was introduced in an earlier fix commit:

// THE BUG β€” reading and writing the same $state in one $effect
let albumInitialized = $state(false);
$effect(() => {
    const _album = albumFilter;
    if (!albumInitialized) { albumInitialized = true; return; }
    filters.tag = '';  // internally calls goto() inside the effect
});

Because albumInitialized was $state, the effect registered it as a dependency. Writing albumInitialized = true scheduled the effect to re-run, and on re-run it called filters.tag = '' β€” which internally called goto() via useSearchParams. Calling goto() inside an effect during component initialization caused state_unsafe_mutation in production and missing_context in development.

A second bug: the albumStores map in the store manager was a SvelteMap. When getOrCreate() was called inside the albumStore = $derived(...) expression, it called SvelteMap.set() inside a derived computation β€” another state_unsafe_mutation.

Route Restructure

The new routing separates concerns at the URL level:

/gallery              β†’ overview: team album tiles grid
/gallery/[albumId]    β†’ single album: full gallery, filters, lightbox, social

Switching albums is now navigation, not filter state. The old β€œclear tag on album change” effect disappears entirely because navigating to a new album route mounts a fresh page component with a clean filter state. The URL format changes from /gallery?album=xxx&tag=yyy to /gallery/xxx?tag=yyy.

For backward compatibility, existing !gallery[album=xxx&tag=yyy] chat messages still work. ChatImagePreview.svelte parses the album parameter out of the filter string and constructs the new URL:

function handleClick() {
    if (isGallery && filters) {
        const params = new URLSearchParams(filters);
        const albumId = params.get('album');
        params.delete('album');
        params.delete('count');
        const qs = params.toString();
        goto(albumId ? `/gallery/${albumId}${qs ? '?' + qs : ''}` : '/gallery');
    }
}

New shares generated by ShareToChatModal continue including album=xxx in the filter string so old and new clients can both decode it.

TeamAlbumStore

The old AlbumStore was keyed by an opaque album ID string and knew nothing about its team β€” tags, people, and activity data were all managed in the page component.

TeamAlbumStore takes team context at construction and owns all album-scoped data:

export class TeamAlbumStore {
    readonly albumId: string;
    readonly teamId: string | null;
    readonly teamName: string | null;
    private readonly manager: ImmichManager;

    // Pagination
    assets = $state<GalleryAsset[]>([]);
    total = $state(0);
    page = $state(0);
    loading = $state(false);

    // Activity data β€” moved from page component
    activityMap = $state(new Map<string, ActivitySummary>());
    activitiesLoaded = $state(false);

    // Tag tree navigation: root β†’ teamName β†’ event tags (returned)
    get tags(): AssetTag[] {
        if (!this.teamName) return [];
        const allTags = this.manager.tags;
        const teamTag = allTags.find(t => t.name === this.teamName && t.parentId);
        if (!teamTag) return [];
        return allTags
            .filter(t => t.parentId === teamTag.id)
            .sort((a, b) => b.name.localeCompare(a.name));
    }

    isValidTag(tagId: string): boolean {
        return this.tags.some(t => t.id === tagId);
    }
}

The tags getter walks the Immich tag tree (knowmore β†’ teamName β†’ event tags) without any page-side logic. isValidTag() lets the page validate a URL tag parameter without needing to call goto() β€” if the tag is not valid for this album, it’s treated as null:

// Replaces the buggy albumInitialized $state effect
const activeTagId = $derived(
    filters.tag && albumStore?.isValidTag(filters.tag) ? filters.tag : null
);

Activity operations (preload, toggle like, add/remove comment) are now store methods that also update activityMap internally and return fresh per-asset activities for the lightbox.

ImmichManager

AlbumStoreManager becomes ImmichManager with two changes:

First, init() now fetches tags in the same Promise.all as albums and people, removing the ad-hoc listTags() call from the page:

async init(): Promise<void> {
    if (this.initialized || !isImmichInitialized()) return;
    const [albumsList, peopleResult, tagsList] = await Promise.all([
        getAlbums(),
        getPeople(1, 200),
        fetchTags()
    ]);
    this.albums = albumsList || [];
    this.people = peopleItems.filter(p => p.name && !p.isHidden);
    this.tags = tagsList || [];
    this.initialized = true;
}

Second, albumStores changes from SvelteMap to Map. This is the state_unsafe_mutation fix β€” the map is private and never iterated reactively, so the SvelteMap wrapper was both unnecessary and harmful when getOrCreate() was called inside a $derived:

// Before β€” causes state_unsafe_mutation when called from $derived
private albumStores = new SvelteMap<string, TeamAlbumStore>();

// After β€” plain Map is safe
private albumStores = new Map<string, TeamAlbumStore>();

Individual TeamAlbumStore instances have their own $state fields, which is where the reactivity lives. The container map does not need to be reactive.

GalleryLightbox Component

The 400-line carousel, preview, and social section is extracted into GalleryLightbox.svelte. Its interface is explicit:

interface Props {
    assets: GalleryAsset[];
    albumStore: TeamAlbumStore;
    currentUserId: string;
    selectedIndex: number;  // bindable
    windowWidth: number;
    onClose: () => void;
    onShare: (assetId: string) => void;
}

The lightbox manages its own preview state (image URL, video blob URL with cleanup, loading flag), activity state (per-asset likes and comments), thumbnail scroll, and browser history entry (history.pushState on open, popstate handler for back-button close). The page component only tracks selectedIndex:

// Page:
let selectedIndex = $state<number | null>(null);

function openFullSize(index: number) { selectedIndex = index; }
function closeFullSize() { selectedIndex = null; }
{#if selectedIndex !== null && albumStore}
    <GalleryLightbox
        assets={displayedAssets}
        {albumStore}
        {currentUserId}
        bind:selectedIndex
        {windowWidth}
        onClose={closeFullSize}
        onShare={handleShareImage}
    />
{/if}

Inside the lightbox, prevSelectedIndex is a plain let β€” not $state β€” so writing to it does not trigger the effect again:

let prevSelectedIndex: number | null = null;

$effect(() => {
    const idx = selectedIndex;
    if (idx === prevSelectedIndex || !assets[idx]) return;
    prevSelectedIndex = idx;          // not reactive β€” no re-run
    loadPreview(assets[idx]);
    fetchActivities(assets[idx].id);
    scrollThumbnailToCenter();
});

Effect Count

The page script went from 689 lines and 8 effects to 420 lines and 5 effects, with no problematic reactive patterns remaining:

EffectBeforeAfter
Window resizeplain DOM subscriptionunchanged
Container ResizeObserverplain DOM subscriptionunchanged
Apply filters on changefine, but redundant with initGallery()merged with init, single path
Preload activitiescalled page-local fncalls albumStore.preloadActivities()
Clear tag on album change$state bug + goto() in effectdeleted β€” replaced by activeTagId derived
Load preview on selectprevSelectedIndex = $state bugmoved to GalleryLightbox as plain let
Scroll thumbnailssetTimeout(50) antipatterntick() in GalleryLightbox
Tour triggerfineunchanged

Three Svelte 5 Patterns to Avoid

These bugs came from three related misunderstandings about Svelte 5 reactivity.

Reading and Writing the Same $state in One $effect

// WRONG
let initialized = $state(false);
$effect(() => {
    const dep = someReactiveDep;
    if (!initialized) { initialized = true; return; }
    doSomething();
});

Because initialized is $state and the effect reads it, Svelte tracks it as a dependency. Writing initialized = true schedules the effect to re-run. On re-run, initialized is true so doSomething() is called β€” including during the initial page load, before the user has done anything.

The fix is a plain let. A plain variable is captured by the closure but not tracked as a reactive dependency:

// CORRECT
let initialized = false;
$effect(() => {
    const dep = someReactiveDep;
    if (!initialized) { initialized = true; return; }
    doSomething();
});

Calling goto() From an Effect

useSearchParams from runed writes to a $state (#localCache) when a filter is set, which internally schedules a goto() via SvelteKit’s router. Calling this from inside an $effect creates a chain: effect runs β†’ goto() called β†’ URL updates β†’ page store changes β†’ effects re-run. In some execution orders this triggers state_unsafe_mutation because the goto() side effect lands while a derived is being evaluated.

The fix was architectural: tag clearing moved from an effect into a $derived that validates the URL parameter, so goto() is never called from reactive code.

SvelteMap.set() Inside $derived

// WRONG β€” store method with side effects called from $derived
const albumStore = $derived(
    immichStore.getOrCreate(albumId, teamId, teamName)  // calls SvelteMap.set()
);

SvelteMap.set() is a $state mutation. Calling it inside a $derived computation throws state_unsafe_mutation. The fix is to make the container map a plain Map when it does not need to be reactive:

// CORRECT β€” Map.set() is not reactive, safe inside $derived
private albumStores = new Map<string, TeamAlbumStore>();

What’s Next

The statistics page still uses SQL views from before the team-owns-players relationship change β€” that needs a rebuild. Beyond that, the federation calendar view is getting navigation improvements so coaches can jump between rounds without going back to the standings page.


This is the eighth post in the KnowMore series. For the previous release covering the Immich integration and Go migration, see KnowMore v0.6-v0.7.