diff --git a/bench/docs/architecture/performance.md b/bench/docs/architecture/performance.md index e5cd3a1..1502215 100644 --- a/bench/docs/architecture/performance.md +++ b/bench/docs/architecture/performance.md @@ -181,7 +181,9 @@ gated by an `IntersectionObserver` with `LAZY_HYDRATION_ROOT_MARGIN = '300px 0px'` (a card begins hydrating just before it scrolls into view) and scheduled by `priority = -index` so the top cards render first. Only the ~6 visible charts hydrate on open; the rest hydrate on scroll. The initial latest-100 fetches are -capped per tab (`HYDRATION_CONCURRENCY = 4`). +capped per tab (`HYDRATION_CONCURRENCY = 4`). A collapsed group has no islands +at all: its cards mount only when it opens (see "Collapsed groups on the landing +page" below). ### Full history is opt-in @@ -239,6 +241,46 @@ first. The cache is per-session by design: a close/reopen costs zero fetches, an the small staleness window for an already-open tab after a server-side revalidation is accepted (a refresh gets fresh data). +### Collapsed groups on the landing page + +Every group starts collapsed: its header row, with the Vortex-vs-Parquet +headline, and its summary table, both server-rendered. Its toolbar and cards +([`GroupBody`](../../web/components/GroupBody.tsx)) mount only while it is open, +so a collapsed group hydrates no island. The open groups live in +the URL fragment (`/#compression,clickbench`), which the CDN never sees. An +inline script after the last group +([`OpenGroupsScript`](../../web/components/OpenGroupsScript.tsx)) opens them +while they are still in React's hidden streamed segment, so a reload or Back +never shows them collapsed first. It also leaves a marker for the client code: a +group the reader opens or closes before hydration stays that way, and a reader +who scrolls, touches, clicks or presses a key before hydration is not moved, +neither by the restore nor by the scroll to a link's first group. Scroll is +restored by element, not by pixel +([`lib/scroll-restore.ts`](../../web/lib/scroll-restore.ts)): the open bodies +mount only after hydration, so a saved `scrollY` would land against a shorter +page. + +Measured against the previous build, the head of `claude/bench-cleanup` +(b4caf7c): a local restore of the production dump, desktop (1440×900), no +network throttling, CPU 4×, the median of 5 runs (Expand All: the median of 3), +with one-off scripts that are not in this repository. + +- Landing HTML 84 → 27 KB gzipped, islands 791 → 0, DOM elements + 21,237 → 1,585, script time 186 → 59 ms. +- The groups first paint at 413 → 182 ms. First contentful paint rises + 124 → 184 ms, because the previous build first paints its "Loading benchmark + summaries…" card and shows it until its 2.3 MB page has parsed. The new page + is small enough that its first paint already shows the groups; the card shows + only while a slow network holds them back. +- Back and Reload with two groups open put the same card within 1 px of its + saved offset. The reader sees the top of the page for 1–2 frames, then that + spot. The Layout Instability API scores these loads at 1.4–1.8. It counts + content that moves above the restored card: the cards that mount during that + jump, and the empty cards that hide once the bundles arrive. The restore's + two scrolls cancel both, so the reader sees no shift after the jump. +- Expand All mounts all 791 islands in the toggle tasks that follow the click: + 574 ms of blocking time against 500 ms. + ### Visual downsampling (LTTB) A chart card is only ~600–900 px wide and Chart.js draws ~2 px markers, so a diff --git a/bench/scripts/parity/screenshots.spec.ts b/bench/scripts/parity/screenshots.spec.ts index 48056fb..fc132bc 100644 --- a/bench/scripts/parity/screenshots.spec.ts +++ b/bench/scripts/parity/screenshots.spec.ts @@ -66,6 +66,7 @@ test('landing page with the first group open', async ({ page }) => { const section = page.locator('section.group-details').first(); await section.locator('summary.group-summary').click(); + await expect(section.locator('.chart-card').first()).toBeVisible(); // Charts draw when they scroll near the viewport, so visit every one before the screenshot. const cards = section.locator('.chart-card'); for (let index = 0, count = await cards.count(); index < count; index++) { diff --git a/bench/web/app/globals.css b/bench/web/app/globals.css index bd38c9f..2c57e78 100644 --- a/bench/web/app/globals.css +++ b/bench/web/app/globals.css @@ -474,6 +474,7 @@ main { } .group-summary-row { display: flex; + flex-wrap: wrap; align-items: center; gap: 0.75rem; padding: 1rem 2rem; @@ -520,6 +521,19 @@ main { line-height: 1; text-transform: uppercase; } +/* + * The one-line headline at the end of a group's summary row (GroupSection, + * lib/headline.ts): how Vortex compares with Parquet. Plain muted text; the + * words carry the verdict, so tone colors (data-tone on each part) are left to + * the visual redesign. It sits at the right on desktop and wraps to its own + * line on a phone. + */ +.group-headline { + margin-left: auto; + color: var(--muted); + font-size: 0.85rem; + font-variant-numeric: tabular-nums; +} /* * The copy-link button next to a group's chart count (GroupPermalink). A bare * icon button with the info icon's muted-to-foreground hover treatment; the @@ -664,7 +678,7 @@ main { opacity: 1; visibility: visible; } -.group-details > .chart-grid { +.group-body > .chart-grid { padding: 1.25rem; /* Page-level surface behind the chart cards. v2's `.benchmark-graphs` * uses `--bg-color` (`#101010` in dark mode); `--page-bg` is the v3 @@ -682,33 +696,13 @@ main { margin: 0; } -.group-disclosure:not([open]) ~ .chart-grid { - display: none; -} - -/* - * During the post-load Chart.js prebuild, render closed groups' chart - * grids in a hidden offscreen position so each canvas has real layout - * dimensions for Chart.js to measure. Without this override, charts - * construct against a 0x0 parent (the closed-group rule above), and the - * eventual on-toggle resize leaves a brief flash of a blank, full-zoomed - * canvas before the next frame paints. The width matches the visible - * content area via `--bench-prebuild-width` (set by `chart-init.js` from - * the live `
` width) so the build dimensions are close enough to - * the actual on-screen dimensions that Chart.js's ResizeObserver-driven - * adjust on toggle is a no-op or a single sub-frame tick. - */ -body.bench-prebuilding .group-disclosure:not([open]) ~ .chart-grid { - display: grid !important; - position: fixed !important; - visibility: hidden !important; - pointer-events: none !important; - top: -10000px !important; - left: 0 !important; - width: var(--bench-prebuild-width, 100vw); +/* A collapsed group ends with its summary card: the section's own border + * closes it. */ +.group-disclosure:not([open]) ~ .benchmark-scores-summary { + border-bottom: 0; } -.group-details > .chart-grid .chart-card:last-child { +.group-body > .chart-grid .chart-card:last-child { margin-bottom: 0; } @@ -1090,8 +1084,8 @@ body.bench-prebuilding .group-disclosure:not([open]) ~ .chart-grid { * chart grid. Y-axis buttons on the left, a centered "Filter series" * dropdown trigger, and a Reset button on the right. The dropdown panel * holds engine/format macro chips and a series row populated by JS as - * charts in the group hydrate. Hidden alongside `.chart-grid` when the - * disclosure is closed via the same `:not([open]) ~` rule. + * charts in the group hydrate. It lives in `.group-body` inside the + * group's `
`, which mounts it only while the group is open. * ---------------------------------------------------------------------- */ .group-toolbar { display: flex; @@ -1103,9 +1097,6 @@ body.bench-prebuilding .group-disclosure:not([open]) ~ .chart-grid { background: var(--bg); font-size: 0.78rem; } -.group-disclosure:not([open]) ~ .group-toolbar { - display: none; -} .group-toolbar-y { flex: 0 0 auto; gap: 0.25rem; @@ -1720,7 +1711,11 @@ main > .chart-card:not(.chart-grid .chart-card) .chart-wrap { flex: 0 0 auto; font-size: 0.65rem; } - .group-details > .chart-grid { + .group-headline { + flex-basis: 100%; + margin-left: 0; + } + .group-body > .chart-grid { padding: 0.75rem; } .benchmark-scores-summary { diff --git a/bench/web/app/page.tsx b/bench/web/app/page.tsx index 85f61df..6b5b998 100644 --- a/bench/web/app/page.tsx +++ b/bench/web/app/page.tsx @@ -8,6 +8,8 @@ import { GroupNav } from '@/components/GroupNav'; import { GroupSection } from '@/components/GroupSection'; import { Header } from '@/components/Header'; import { HomeLoading } from '@/components/HomeLoading'; +import { LandingState } from '@/components/LandingState'; +import { OpenGroupsScript } from '@/components/OpenGroupsScript'; import { groupAnchors } from '@/lib/anchor'; import { parseFilterCsv, singleSearchParam } from '@/lib/chart-format'; import { cachedFilterUniverse, cachedGroups } from '@/lib/data-cache'; @@ -28,14 +30,32 @@ export const dynamic = 'force-dynamic'; /** * The landing page: a server-rendered section per group in canonical - * `GROUP_ORDER`, each with its summary card, per-group toolbar, and a grid of - * chart-card client islands. `data-chart-index` is assigned page-wide so each - * chart's index is unique across every group (matching v3's `landing_body` - * counter). + * `GROUP_ORDER`, each collapsed to its header row (with the headline) and its + * summary card, and opening to its per-group toolbar and a grid of chart-card + * client islands (mounted only while the group is open). `data-chart-index` is assigned page-wide so + * each chart's index is unique across every group (matching v3's + * `landing_body` counter). * * `?engine=` / `?format=` are the global filter's URL allowlists (CSV). A - * A readable `?group=` anchor plus repeated `?hide=` / `?show=` values restores + * readable `?group=` anchor plus repeated `?hide=` / `?show=` values restores * the target group's local series overrides. + * + * The URL fragment lists the open groups' anchors (`#tpc-h-nvme-sf-1,clickbench`, + * see `lib/open-groups.ts`). The server never sees it, so `/` stays one CDN + * cache entry. [`OpenGroupsScript`], right after the last group, opens the + * listed groups before they paint; [`LandingState`] opens them after a client + * navigation (after a full load it keeps what the reader opened or closed + * before hydration), writes the fragment after opens and closes (coalescing a + * burst of toggles into two writes), and owns the scroll: a reload or Back + * restores the element that was at the top of the viewport + * (`lib/scroll-restore.ts`), and a fresh visit scrolls to the first listed + * group. Neither moves a reader who scrolled, touched, clicked or pressed a + * key before hydration. + * + * The content (`HomeContent`) renders inside a Suspense boundary. A browser + * gets it streamed into a hidden segment that React reveals with an inline + * script, so with JavaScript off it never shows: a reader without JavaScript + * sees only the [`HomeLoading`] fallback. This predates the collapsed groups. */ export default async function Home({ searchParams, @@ -75,13 +95,7 @@ async function HomeContent({ return ( <>
- ({ - name: group.name, - slug: group.slug, - anchor: anchors[i]!, - }))} - /> + ({ name: group.name, slug: group.slug }))} />
{groups.length === 0 ? (

No data ingested yet.

@@ -111,14 +125,21 @@ async function HomeContent({ /> ); })} - {/* v3's landing_body early-returns on an empty database, so the - no-JS hint only renders alongside actual chart shells. */} + {/* After the last group's markup, in the same streamed chunk, so + it opens the fragment's groups before they paint. */} + + {/* A browser never shows this hint. It streams inside the + Suspense boundary's hidden segment, which only React's inline + script reveals, so with JavaScript off the page shows the + loading fallback alone; with JavaScript on, `
+