Skip to content

web-next: timeline mount cost — per-card primitives never unmount (follow-up to #308) #333

Description

@nyanrus

Context

Continues the /feed Firefox-profiler investigation behind #308 (sustained +10 MB/s heap growth during interaction, flat when idle). Two pieces already landed: #308 cached the two profiled allocation hotspots (Intl formatters), and the per-snapshot <Show keyed> re-mount churn was fixed upstream in solid-relay#68 (bumped to beta.26 on main). What's left of the interaction-time DOM + heap growth is mount cost that never comes back down — primitives that mount and stay mounted. This issue tracks that.

This issue is for improving web-next's performance and prevent OOM.

Items

  • Window / evict long lists (the root). PersonalTimeline / PublicTimeline / NotificationList defer first mount (LazyMount + createChunkedVisibleCount, lib/deferredRender.ts:49) but never unmount scrolled-past cards — visibleCount/total only climb (PersonalTimeline.tsx:296).
    Why it matters: over a session every PostCard ever scrolled stays in the DOM, each holding its Relay fragment subscriptions and a self-rescheduling per-card <time> timer (Timestamp.tsx). DOM nodes and heap grow monotonically on the busiest route — unbounded growth, not a constant factor. Long feed sessions degrade: scroll and input get heavier as the DOM balloons, with OOM risk on lower-memory devices (mobile first). (The codebase already ships a clientMemoryWatchdog for exactly this growth — it detects it, but can't fix it.)
    Fix: window to a viewport band (mount only a visible slice), or cap retained edges and drop off-screen ones. This also collapses the per-card <time> timers for free.

  • Lazy-mount Tooltips in PostEngagementBar. ReplyControl + SplitControl (×3) each wrap their button in a Kobalte <Tooltip> → ~100 mounted on a 25-card timeline, each carrying FloatingUI + Portal + Presence + DismissableLayer + a hover-delay timer.
    Why it matters: the tooltip text just duplicates the button's existing aria-label, so it's ~100 always-mounted primitives whose only added value is a desktop hover label.
    Fix: gate <Tooltip> behind a hovered() signal flipped on onPointerEnter / onFocusIn (the pattern the emoji popover already uses via emojiPickerMounted), or drop it for native title="". Same shape in VisibilityTag.tsx, ProfileCard.tsx.

  • Lazy-mount ActorHoverCard. Wraps every author avatar (×25) plus every @mention in post bodies, keeping a Kobalte <HoverCard> mounted — even though the loader inside it is already correctly <Show when={open()}>-gated, so only the primitive shell is wasted.
    Fix: wrap the children in a span that flips a hovered() signal on pointer/focus enter, and render the <HoverCard> inside <Show when={hovered()}> with the same children as fallback.

Ordering

Item 1 (windowing) is the root — it collapses the per-card <time> timers and is what the watchdog reports. Items 2–3 reduce per-card mount cost and are worth a re-profile after solid-relay#68, which already removed the per-tick re-mount churn that made these primitives expensive.

Verification

Rerun the profiler scenario (30 s on /feed, one composer open/close, one reaction toggle): target heap growth < ~1 MB/s during interaction (was +10 MB/s pre-#308), DOM node count bounded across a scroll session, and clientMemoryWatchdog quiet.

About this issue

Drafted by Shiro (Claude Opus 4.8), working with @nyanrus, continuing the #308 profiler notes. (The lazy-mount count reductions here were sanity-checked with a small Lean + Deno bundle — background, happy to share.) Correct me on anything I misread.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions