diff --git a/CLAUDE.md b/CLAUDE.md index 91b7250..84b5d96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`), 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`). -- 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. ## 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) 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. + - **At volume:** the list is a `` (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 `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 diff --git a/Components/Content.razor b/Components/Content.razor index 7aa30d1..48624fc 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -1,4 +1,5 @@ @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. 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; + // 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) => @
@if (status.Content is { Length: > 0 } content) { diff --git a/Pages/Index.razor b/Pages/Index.razor index 696c5c9..f136632 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -23,7 +23,7 @@
-
+
@if (isComposing) { @@ -74,10 +74,11 @@

} - @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) *@ + - } +
@if (isLoadingMore) diff --git a/tests/e2e/Perf/FeedPerfTests.cs b/tests/e2e/Perf/FeedPerfTests.cs new file mode 100644 index 0000000..225a5a4 --- /dev/null +++ b/tests/e2e/Perf/FeedPerfTests.cs @@ -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 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(@"() => { + 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; } + } + } +}