perf(evalboard): cut page load time and show loading state while pages load - #152
Merged
Conversation
The front page took 5-8s cold and ~1.25s warm, and the split was exactly the 5-minute per-run cache TTL. Four causes, all redundant IO against the `/home` Azure Files mount: - The ad-hoc section loaded EVERY non-date-shaped candidate before sorting — 165 runs / ~294 MB of run.json — to render ten rows. It now orders candidates by the date in the id and loads only the newest page's worth. run.json `start_time` is still the authoritative sort. - Per-run projections were cached for 5 minutes regardless of age, so every visit after a short pause re-read the whole window. A run.json is written once at the end of a run, so runs older than 24h now cache for a day. /api/refresh evicts by tag, keeping the escape hatch. - readRunSummary and readRunTasks each read and parsed the same multi-MB run.json; the run page did both on every request. Memoized per request with react/cache. - findMatureSourceRuns walked up to 20 earlier run.jsons serially, and only short-circuits once every mature task resolves, which at ~50% maturity usually never happens. Now reads in concurrent batches, consumed in index order so "most recent execution wins" is unchanged. Filtering within a run also round-tripped the server for nothing: the tag/search params on that page are read entirely client-side, but committing them through router.replace re-ran the whole force-dynamic page. Switched to the native History API, which Next syncs into useSearchParams without a fetch. Whole-run download is removed — button, route branch, collectRunFiles and ensureRunDir. A nightly run is ~10k blobs / ~400 MB, and zipping it meant an uncapped blob fan-out, a stat-per-file walk over Azure Files, and the entire archive buffered in memory before the first byte was sent. Per-task download stays and is unchanged; its CRC-32 now comes from zlib instead of a per-byte JS loop (~47x, measured). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The harness segments were purely data-driven off listRecentHarnesses, which discovers from the last 12 usable pipeline runs. Delegate runs on a weekly cron, so between firings its last run slides past that window and the segment disappears, which reads as "delegate was removed" rather than "delegate hasn't run lately". The known set now always gets a segment. Selecting one with no runs in scope shows an empty result, which is honest and one click from recoverable; a control that silently loses an option is neither. Discovery still drives the chart series, so no empty lines appear alongside the restored segment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sheet The run page shipped 7.6 MB of HTML for a 1,296-task run and /trends shipped 5.6 MB. In both, 55% of the markup was `class` attribute text: 56k attributes carrying only 120 distinct values on the run page, 40k carrying 78 on trends. One 130-char chip string accounted for 1.4 MB on its own, repeated 10,918 times. The hot combinations now live in globals.css as component classes, so the browser parses them once and caches them across navigations instead of re-reading them on every row. Chips carry the largest share; the run grid's stat key, the trends numeric cell, and the trends sparkline bar are the other three with five-figure render counts. Chip hover moves from stripping `hover:` utilities out of the composed string at runtime to a `chip-act` marker the interactive branch emits, which the compound selectors key on. The non-interactive span still shows no hover affordance. Run page 7.64 MB -> 6.02 MB (-21%), trends 5.60 MB -> 4.49 MB (-20%). No visual change: every generated rule was checked against the utilities it replaced in the built stylesheet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run header claimed "+1 more" on runs that used a single model end to
end. tallyModels voted on the raw model_used string, and that string
varies by code path for one and the same model: a row that errors before
the model resolves keeps the qualified id it was configured with
("eu.anthropic.claude-sonnet-5") while every completed row records the
bare one ("claude-sonnet-5"). One errored task was enough to make the
header report a mixed-model run.
The vote now groups on the normalized id, the same key pricing already
resolves on, so normalizeModel is exported rather than duplicated. Only
the "how many models" question moves to normalized keys: display stays
the most common raw string inside the winning group, so the chip still
shows what the run recorded instead of a value the header derived.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment-only. Cuts the narration of what the code used to do and the restatements of what it now does, keeping the reasons that aren't recoverable from reading it: why the per-run cache is keyed per run rather than per source, why the ad-hoc load key and the sort key differ, why hover moved to a marker class, and why the model vote normalizes. Net 57 lines. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bai-uipath
marked this pull request as ready for review
September 3, 2026 20:23
bai-uipath
requested review from
CarlesUIPath,
akshaylive,
tmatup and
uipreliga
as code owners
September 3, 2026 20:23
|
Claude finished @bai-uipath's task in 1m 39s —— View job Code Review in Progress
|
Every heavy route is force-dynamic, so a navigation runs the server
component before anything can render. With no Suspense boundary the
browser sat on the PREVIOUS page for all of it: 1.4-1.6s on the front
page, 1.7-2.2s on a 1,296-task run, with no sign the click registered.
Adds a route-level loading.tsx for the front page (which by nesting also
covers trends, watchlist, path-to-ga, scribe and runs/latest), the run
page, and the task page, over shared primitives in _components/skeleton.
Also replaces the run page's `Suspense fallback={null}` with the same
body skeleton. That boundary left a blank hole under an already-rendered
header while the review index and mature-source scan finished, which
reads as a broken page rather than a loading one.
Blocks mirror the real breakpoints (two stat-tile columns on a phone and
five from md; the task table at md and up, cards below it) so the layout
does not move when content lands. Widths are fractional or capped, and
tests assert no fixed width can overflow a 320px viewport. The pulse
sits on the wrapper, not each block, and drops under
prefers-reduced-motion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Loading the front page, filtering to a tag inside a run, and the download button were all slow. Reviewing the result turned up more on the same pages, so this also cuts the HTML those pages ship, gives every slow route a loading state, restores a filter segment that had gone missing, and corrects a model count in the run header.
Rough before and after
Two honesty notes on that table. The warm figures are local; the "before" warm and cold figures are production App Insights, and the production cold path stays unverified until this deploys. And the HTML reduction is mostly a parse win rather than a transfer win, since gzip already collapses repeated class text.
What changed
Front page. A cold render read about 205 run.json files off the
/homeAzure Files mount. Four independent causes: the ad-hoc section loaded every candidate ever uploaded in order to show ten rows; per-run projections expired every 5 minutes however old the run was; the same run.json was read and parsed twice per request; and the mature-source scan walked earlier runs one at a time. All four are now bounded or shared.Filtering inside a run. Every tag click re-ran the whole server component to return markup the browser then recomputed anyway, since nothing on that page reads the filter params server-side. Filters now commit through the native History API: the URL stays shareable, the back button still works, and there is no round-trip.
Download. Whole-run download is removed. A nightly run is about 10k blobs and 400 MB, and zipping it fetched every blob with no concurrency cap and buffered the whole archive in memory before sending a byte. Per-task download stays, and its checksum now comes from
zlibrather than a per-byte loop.Loading states. Every heavy route is force-dynamic, so a navigation has to run the server component before it can render anything, and there was no Suspense boundary anywhere in the app. The browser held the previous page for the whole wait with no sign the click had registered. There is now a route-level skeleton for the front page (covering trends, watchlist, path-to-ga, scribe and runs/latest by nesting), the run page, and the task page. The run page's own
Suspense fallback={null}is replaced with the same body skeleton, since it had been leaving a blank hole under an already-rendered header. Blocks mirror the real breakpoints, so the layout does not move when content lands, and tests assert no fixed width can overflow a 320px viewport.Payload on the run page and /trends. 55% of the markup on both pages was
classattribute text, and a single 130-character chip string accounted for 1.4 MB on its own. Those combinations now live in the stylesheet, which the browser parses once and reuses across navigations. No visual change: every generated rule was diffed against the utilities it replaced in the built CSS. The same audit found every other page already under 610 KB, so those two were the only ones worth touching.Harness filter. Segments were discovered from the last 12 runs, so delegate vanished between its weekly firings and read as a harness that had been removed. The known set now always gets a segment, and discovery still drives the chart series.
Model count in the run header. The header claimed "+1 more" on runs that used one model end to end, because a task that errors before the model resolves records the qualified id while completed tasks record the bare one. The count now groups on the normalized id, the same key pricing already resolves on. Display still shows what the run actually recorded.
On caching
A run older than a day now holds its projection for a day instead of 5 minutes. That reads like a staleness risk and is not, because the on-disk blob cache underneath never re-fetches a file once it exists: a sidecar edited in storage was already stale until someone pressed Refresh, whatever TTL sat above it. Refresh is still the only thing that clears it, and it now evicts the projection alongside the disk copy, which is the part that actually needed fixing. Deleting or re-uploading a run behaves exactly as before. The one assumption worth recording is single-instance, since tag eviction is per process; the site runs one worker and has no autoscale rule.
Also done outside the repo
alwaysOnwasfalseon the App Service and is nowtrue(flow-evalboard/rg-coder-eval-tests). It is free on the current P2v3 plan and was the last cause of a cold front page that no code change could reach, since the worker idled out and the first visitor after a quiet stretch paid the whole boot. No repo change was needed and none is needed to keep it: nothing in the deploy path writessiteConfigand neither repo carries IaC. Verified healthy afterwards, 30 requests in the next five-minute bin, all 200.Two notes for anyone checking by hand. An anonymous
curlagainst the public hostname answers 401 from Easy Auth, which is expected and not a failed boot.az webapp auth showreports nulls because the site is on classic V1 while the CLI extension reads V2.Verification
tsc --noEmit, 637 vitest tests, andnext buildall pass. New coverage for the id-date extraction, the batched mature-source walk, the refresh tag eviction, the zip checksum, the harness segments, the model tally, and the skeletons (accessible name, reduced motion, no overflowing width, both grid breakpoints present).