The feed stays light at thousands of posts

A Perf test (tests/e2e/Perf/FeedPerfTests, Category=Perf) scrolls load0's
home, thousands of posts from SocialPub's load runs, to 2000 posts. Before:
100 cards took 12 s to reach, 1000 took 437 s, 2000 took 29 minutes; the
main thread was busy almost all of that time, and the page held 224 000 DOM
nodes and 920 MB of .NET heap. Every card re-rendered whenever the list did.

- A card re-renders only when its own events run or its render key changes
  (Content.RenderKey: the post, its status, notification, conversation,
  place and grouping, where it is shown, the persona and the connection).
  2000 cards then took 122 s.
- The list is a <Virtualize>: only the cards near the view are rendered.
  The page now holds about 3 000 DOM nodes and 145 MB however long the feed
  is, and each page of older posts costs the same (about 1.9 s in the test's
  scroll loop) at the 100th post as at the 1700th. The feed tells how many
  posts it holds in data-feed-count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 20:30:45 +02:00
1 parent f9de55ffd5
commit a5178a67b1
4 files changed
+138 -5

No files matched your search

+10 -1
View File
@@ -67,7 +67,11 @@ tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentNam
- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`), - **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`),
and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the
town's report (`out/client.jsonl`). town's report (`out/client.jsonl`).
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. - Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it. - The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
## CSS: Tailwind 4 + daisyUI 5, vendored, offline ## CSS: Tailwind 4 + daisyUI 5, vendored, offline
@@ -284,6 +288,11 @@ supplies:
streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL) streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL)
for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit
it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible. it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible.
- **At volume:** the list is a `<Virtualize>` (only the cards near the view are rendered), and a card re-renders only
when its own events run or its render key changes (`Content.RenderKey`: the post, its status, notification,
conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a
card shows from outside it must replace one of those objects or be added to the key; mutating one in place leaves the
card as it was. Without these a feed took seven minutes to reach a thousand cards and held 900 MB at two thousand.
- **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in - **The globe** (`wwwroot/js/globe.js`, an ES module imported by `GlobePanel`) runs on globe.gl, vendored in
`wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the `wwwroot/vendor/globe/` with its textures and `countries.json` (`SOURCES.md` has versions, licences and hashes; the
service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an service worker never precaches it). It looks at the spherical mean of the located posts in view, pulses and draws an
+40
View File
@@ -1,4 +1,5 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
@implements IHandleEvent
@* One feed item: a post (a boost shows the original and its booster), a notification, or a conversation's last post. @* One feed item: a post (a boost shows the original and its booster), a notification, or a conversation's last post.
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
@@ -404,6 +405,45 @@
protected override void OnInitialized() => IsLoading = false; protected override void OnInitialized() => IsLoading = false;
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll to
// (tests/e2e/Perf/FeedPerfTests).
object renderedWith;
int handlingEvents;
object RenderKey() => (Post, Post?.Status, Post?.Notification, Post?.Conversation, Post?.Location, Post?.GroupCount, Post?.GroupAccounts,
(FilterIn, Highlighted, Compact, IncludeExpand, CssContainer), (CascadingState?.PersonaId, CascadingState?.IsOnline));
protected override bool ShouldRender()
{
var key = RenderKey();
if (handlingEvents == 0 && Equals(key, renderedWith))
return false;
renderedWith = key;
return true;
}
// as ComponentBase handles an event, rendering after it and again when its task ends, with the card's own renders allowed
async Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object arg)
{
handlingEvents++;
try
{
var task = callback.InvokeAsync(arg);
var pending = task.Status is not (TaskStatus.RanToCompletion or TaskStatus.Canceled);
StateHasChanged();
if (!pending)
return;
await task;
StateHasChanged();
}
finally
{
handlingEvents--;
}
}
RenderFragment Body(Status status) => @<div class="flex flex-col gap-3"> RenderFragment Body(Status status) => @<div class="flex flex-col gap-3">
@if (status.Content is { Length: > 0 } content) @if (status.Content is { Length: > 0 } content)
{ {
+5 -4
View File
@@ -23,7 +23,7 @@
<div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl"> <div class="relative flex-1 min-h-0 neo-inset neo-xs rounded-xl">
<div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4"> <div id="@ScrollId" class="absolute inset-0 overflow-y-auto p-3 md:p-4">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4" data-feed-count="@posts.Count">
@if (isComposing) @if (isComposing)
{ {
@@ -74,10 +74,11 @@
</p> </p>
} }
@foreach (var post in posts) @* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
{ (tests/e2e/Perf/FeedPerfTests) *@
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" /> <Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
} </Virtualize>
<div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60"> <div id="@SentinelId" class="flex justify-center min-h-8 text-xs opacity-60">
@if (isLoadingMore) @if (isLoadingMore)
+83
View File
@@ -0,0 +1,83 @@
using System.Diagnostics;
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Perf
{
// The home feed with thousands of posts behind it: load0's home, filled by SocialPub's load runs
// (tools/pasture/load.sh, the flood peer). Scrolls it until 100, 500, 1000 and 2000 posts are loaded (the feed renders
// only the cards near the view, so it counts what it holds, data-feed-count) and measures, at each, how long the
// pages took, the page's size (DOM nodes, JS and .NET heaps) and the long tasks; and the time to the first card. Writes perf.json next to the test output; nothing is asserted beyond reaching the cards, the numbers are
// for comparing runs.
[Trait("Category", "Perf")]
public sealed class FeedPerfTests(Browser browser)
{
static readonly int[] Steps = [100, 500, 1000, 2000];
[Fact]
public async Task A_long_home_feed_scrolls_to_two_thousand_cards()
{
var persona = new Pasture.Persona("load0", "load0", "pastureroot", "Pasture-Pass-1!", null, null, null);
await using var session = await SignIn.As(browser, persona, fresh: true);
var page = session.Page;
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
await page.AddInitScriptAsync(@"window.__longTasks = []; new PerformanceObserver(list => list.getEntries().forEach(e => window.__longTasks.push(e.duration))).observe({ type: 'longtask', buffered: true });");
var clock = Stopwatch.StartNew();
await page.GotoAsync($"{Pasture.Site}/");
var cards = page.Locator("article[data-uri]");
await cards.First.WaitForAsync(new() { Timeout = 60000 });
var firstCard = clock.Elapsed.TotalMilliseconds;
var loaded = page.Locator("[data-feed-count]");
async Task<int> Loaded() => int.Parse(await loaded.GetAttributeAsync("data-feed-count") ?? "0");
Assert.SkipWhen(await Loaded() < 20, "load0's home is short: run SocialPub's tools/pasture/load.sh first");
var report = new JsonObject { ["firstCardMs"] = Math.Round(firstCard), ["steps"] = new JsonArray() };
foreach (var target in Steps)
{
var step = Stopwatch.StartNew();
var count = await Loaded();
var stalled = Stopwatch.StartNew();
while (count < target && stalled.Elapsed < TimeSpan.FromSeconds(20))
{
await page.EvaluateAsync("() => { const s = document.getElementById('feed-scroll'); s.scrollTop = s.scrollHeight; }");
await page.WaitForTimeoutAsync(150);
var now = await Loaded();
if (now > count)
stalled.Restart();
count = now;
}
var metrics = await page.EvaluateAsync<JsonElementHolder>(@"() => {
const heap = performance.memory ? performance.memory.usedJSHeapSize : null;
const runtime = globalThis.getDotnetRuntime ? globalThis.getDotnetRuntime(0) : null;
const wasm = runtime && runtime.Module && runtime.Module.HEAPU8 ? runtime.Module.HEAPU8.length : null;
const tasks = window.__longTasks.splice(0);
return { json: JSON.stringify({ dom: document.getElementsByTagName('*').length, jsHeap: heap, wasmHeap: wasm,
longTasks: tasks.length, longTaskMs: Math.round(tasks.reduce((a, b) => a + b, 0)) }) };
}");
var row = JsonNode.Parse(metrics.Json)!.AsObject();
row["target"] = target;
row["posts"] = count;
row["cards"] = await cards.CountAsync();
row["seconds"] = Math.Round(step.Elapsed.TotalSeconds, 1);
report["steps"]!.AsArray().Add(row);
if (count < target)
break;
}
var path = Path.Combine(AppContext.BaseDirectory, "perf.json");
await File.WriteAllTextAsync(path, report.ToJsonString(new() { WriteIndented = true }));
TestContext.Current.SendDiagnosticMessage(report.ToJsonString());
Assert.True(report["steps"]!.AsArray().Count > 0);
await session.AssertGuards();
}
public sealed class JsonElementHolder
{
public string Json { get; set; }
}
}
}