Skip to content

perf: analysis, Bot or Not and puzzles show their layout from the first paint - #327

Merged
ashtonanderson merged 11 commits into
mainfrom
claude/perf-round-5
Nov 3, 2026
Merged

ashtonanderson merged 11 commits into
mainfrom
claude/perf-round-5

Conversation

@ashtonanderson

@ashtonanderson ashtonanderson commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Fifth round: the analysis pages, Bot or Not and the puzzles page show their layout from the first paint, instead of a blank page, an animated loading board and a fade-in. Stacked on #326 (claude/perf-round-4); this PR's base is that branch. Details and all numbers are in LOADING.md ("Round 5").

The problem

These pages sent only the header and footer, and drew nothing until:

  • the page's JavaScript had run;
  • on analysis and puzzles, the visitor's session had loaded (AuthenticatedWrapper);
  • the game or puzzle had arrived.

Then Loading showed nothing for 1 s, an animated loading board, and faded the page in. They pick their phone or desktop layout in JavaScript from the window's width, which the server doesn't know.

Changes

  • An empty board drawn with CSS alone (e4f9e79). EmptyBoard uses chessground's own elements, styles and coordinates. _document applies the visitor's saved board theme before the first paint, so the empty board is in their colours.

  • Shells for three pages. Each renders its panels, an empty board and its static parts where the loaded page draws them, on the server too, in their empty starting states:

    • Bot or Not (c5b0da6): the panels, board controls and guess form.
    • Puzzles (0bcfe0a): the panels and the board.
    • Analysis (4e46a23): /analysis/<id>/... and /analysis while it finds a game. It draws the panels, the win-rate and eval bars, the arrow legend, the compact blunder meter and the board controls.
  • How the shells match the page. CSS picks the phone or desktop layout at the width the page uses (1025 px; 1121 px on analysis). Board sizes the pages compute in JavaScript are the same formulas in CSS. The game then fills the shell in, without fading in.

  • The shell costs nothing at start-up (e92f4a5).

    • My first analysis shell was built twice by React (while the session loaded, then inside the page), with animated parts in both layouts. On a phone's first visit the game came ~0.5 s later.
    • Now PageShell keeps the server's HTML untouched while the app starts (an empty dangerouslySetInnerHTML, so React neither renders nor hydrates it).
    • After a link within the site, only the layout for the window renders.
    • Analysis and puzzles keep one shell until the game is on screen, and the pages are memoized so that dropping the shell doesn't re-render them.
  • /play, after choosing a side (3c00156). The setup dialog now prefetches the game page's code. Before, the code waited behind the prefetches of the header's links. /play/maia then shows the game as soon as it's ready, instead of fading it in over 0.4 s.

Results (round-4 branch → this branch)

Emulated phone (4G, every thread at a quarter speed), medians of 5 loads, builds alternating load by load. ada was heavily loaded by other users during these runs, so times vary.

page, visit page on screen game fully visible main-thread work
analysis game, first 6.2 → 0.94 s 7.3 → 6.0 s 7.9 → 8.3 s
analysis game, returning 2.2 → 0.72 s 3.7 → 3.1 s 5.7 → 5.8 s
/analysis, first 7.6 → 0.68 s 8.4 → 9.0 s 7.1 → 7.7 s
/analysis, returning 8.7 → 0.82 s 10.0 → 8.7 s 9.9 → 10.0 s
Bot or Not, first 3.6 → 0.71 s 4.0 → 4.0 s 3.2 → 3.4 s
Bot or Not, returning 2.5 → 0.60 s 3.0 → 2.7 s 2.3 → 2.4 s
/puzzles, first 4.9 → 0.77 s 6.1 → 6.3 s 6.2 → 6.9 s
/puzzles, returning 3.6 → 0.54 s 5.1 → 4.5 s 6.0 → 6.0 s

Desktop, game fully visible:

page before after
Analysis game 1.02 s 0.65 s
/analysis 2.04 s 1.59 s
Bot or Not 1.79 s 1.45 s
/puzzles 2.45 s 2.14 s

The game was fully visible sooner in 19 of 20 paired loads. Main-thread work was the same or lower.

  • "Game fully visible" means the board has pieces and is fully opaque. The data arrives no sooner, but the old fade-in took up to 1.2 s to finish on the slow phone.
  • Exceptions: first visits to /puzzles and /analysis are mixed (sooner in 2 of 5 pairs).
  • Costs on the phone:
    • The first paint is up to 0.15 s later, because it draws the shell.
    • First visits to analysis and /puzzles do 0.4–0.7 s more main-thread work, about 0.1–0.2 s on a fast CPU.
  • Layout: the shell matches the loaded page at a Pixel 7 and at seven desktop sizes (1200 × 800 to 2560 × 1440). Layout shift is 0–0.003.

Lighthouse (phone settings, local builds, medians of 3):

page score main content
Analysis 64 → 63 2.9 → 3.5 s
Bot or Not 54 → 76 5.9 → 2.8 s
/puzzles 42 → 43 24 s on both (Stockfish's download)

Analysis's speed index is unchanged (3.16 → 3.14 s). Bot or Not's blocking time fell from 0.95 to 0.67 s.

/play, from choosing a side until the game is fully visible (4 runs each, against the rest of this branch):

before after
Phone 1.98–2.35 s 1.42–1.57 s
Desktop 1.32–1.70 s 0.99–1.14 s

The fade is ~0.4 s of the saving; on the phone the prefetch adds ~0.2 s. play/start_game itself takes ~0.57 s on the backend.

Stockfish

Checked with scripts/perf/chart-probe.mjs on the analysis page, final build, 8 steps per run (2 runs per game on desktop, 3 on the phone):

round 4 → this branch
Desktop: depth 12 after a step 0.01 ± 0.03 s later (sooner on 18 of 32 steps)
Desktop: depth reached before the next step 16.91 → 16.84
Phone: depth 12 after a step 0.19 ± 0.28 s later (sooner on 21 of 46 steps)
Phone: steps reaching depth 12 48 → 46 of 48
Phone: depth reached before the next step 14.62 → 14.38

Not settled on the phone.

  • All four phone comparisons this round put depth 12 slightly later, by 0.04–0.34 s, each within its spread. That includes one where the analysis page differed only by _document's theme line.
  • All four ran the base build first in each pair.
  • A run with the order swapped was swamped by other users' load on ada (depth 12 reached on only 17 of 24 steps on both builds).
  • Nothing this branch changes runs while the page steps through a game.
  • I'd re-run the phone check, both orders, when ada is quiet, before merging.

Testing

  • npm test (84, with main's), npx tsc --noEmit and npm run lint pass, rebased on main after Capture PostHog web vitals again #330 and Track what visitors do, and stop losing events #332.
  • Checked in local production builds:
    • Placement: in the server's HTML with JavaScript off, while the game request is held back, and once loaded, the board and its surroundings are at the same position and size. No React hydration errors.
    • Links within the site: reaching each page by a link from another page shows the shell, then the game.
    • Board colours: a saved blue board theme shows before any of the app's JavaScript runs.
  • Not tried on real phones or in Safari.

Not done

  • /drills: it opens on the openings picker or on Maia's download window, depending on what the browser has cached, which the server can't know.
  • The site header at tablet widths (671–1024 px): the server's HTML has the desktop menu, which wraps to two lines. When the app loads and swaps in the phone menu, every page moves 28 px (also on main).

🤖 Generated with Claude Code

@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
maia-platform-frontend Ready Ready Preview Nov 3, 2026 6:32am UTC

Request Review

@ashtonanderson ashtonanderson changed the title perf: Bot or Not and the puzzles page show their layout from the first paint perf: analysis, Bot or Not and puzzles show their layout from the first paint Nov 2, 2026
ashtonanderson and others added 9 commits November 2, 2026 18:47
…the first paint

EmptyBoard draws chessground's board elements and coordinates without a
chessground instance, so page shells can show it in the server's HTML.
_document applies the visitor's saved board theme to <body> before the
first paint (SettingsContext did it only after hydration), so such a
board doesn't change colour when the app loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Until its game arrived the page showed nothing for 1 s, then an animated
loading board, then faded in. It now renders a shell, on the server too:
the panels, an empty board and the guess form where the page draws them,
with the phone or desktop layout chosen by CSS at the width isMobile uses.
The game then fills it in without fading in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd puzzle load

AuthenticatedWrapper takes a fallback to render until the visitor's
session has loaded; the puzzles page renders its shell there and while
the puzzle loads (between puzzles too) instead of a blank page and the
animated loading board. The shell sizes the desktop board as the page
first does, in CSS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and game load

The analysis page rendered nothing until the visitor's session and the game
had loaded, then the animated loading board, then faded in; /analysis
showed the loading board while it found a game to open. Both now render a
shell, on the server too: the panels, the board with its win-rate and eval
bars, the arrow legend, the compact blunder meter and the board controls in
their empty starting states, where the page draws them. CSS picks the
phone-style or desktop layout at the width the page uses (1121 px), and the
desktop board's size, which the page computes in JavaScript, is the same
formula in CSS. The game then fills it in without fading in.

The left panel's tabs move to src/constants/analysis.ts, shared with the
shell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…app starts

The first analysis shell cost the emulated phone time: on first visits the
game went in ~0.5 s later and the main thread did ~0.7 s more. React built
the shell as the session loaded (AuthenticatedWrapper's fallback), built a
second one inside the page while the game loaded, and both carried
framer-motion bars, tabs and board controls in the phone and desktop
layouts.

PageShell now holds each shell. One rendered while the app hydrates keeps
the server's HTML as it is (an empty dangerouslySetInnerHTML, whose children
React neither renders nor hydrates); one rendered later, as after a link
within the app, renders only the layout for the window. The analysis and
puzzles pages keep one shell, beside AuthenticatedWrapper, until the game or
puzzle is on screen: the page reports it in a layout effect, so the two are
never painted together, and is memoized so that removing the shell doesn't
render it again. AuthenticatedWrapper's fallback is gone.

With this, the game is fully visible sooner than with round 4's fade-in, on
the phone and on desktops (LOADING.md, Round 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al build

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and the game appears without fading in

After choosing a side, the router first fetched /play/maia's code (on the
emulated phone it waited behind the prefetches of the header's links), and
the game then faded in over 0.4 s. The dialog now prefetches the game page
as it opens, and /play/maia shows the game as soon as it is ready.

From choosing a side until the game is fully visible: emulated phone
1.98-2.35 -> 1.42-1.57 s, desktop 1.32-1.70 -> 0.99-1.14 s (4 runs each);
~0.4 s of it is the fade, ~0.2 s on the phone the prefetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…visit's time goes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ashtonanderson and others added 2 commits November 3, 2026 06:27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	LOADING.md
The rebase on #330 and #332 gave round 5's commits new IDs, and the
old ones are no longer on any branch. The round 4 build it was measured
against keeps its old ID, marked as from before the rebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ashtonanderson
ashtonanderson changed the base branch from claude/perf-round-4 to main November 3, 2026 06:34
@ashtonanderson
ashtonanderson marked this pull request as ready for review November 3, 2026 06:34
@ashtonanderson
ashtonanderson merged commit 1487a10 into main Nov 3, 2026
8 checks passed

This branch was successfully deployed

1 active deployment
Preview — c3482fc9 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant