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:
1 parent
f9de55ffd5
commit
a5178a67b1
4 files changed
+138
-5
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user