diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcfff95..d618539 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,9 @@ jobs: - name: Type check run: bun run typecheck + - name: Unit tests + run: bun run test + - name: Start server run: | bun run start > /tmp/server.log 2>&1 & diff --git a/CLAUDE.md b/CLAUDE.md index 5eb4812..b93e8ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,39 +26,38 @@ They share one Bun workspace and the CI policy, and deploy separately: ## Architecture Overview -This is a Next.js 16 marketing website for Vortex, a columnar file format. The site features: +This is a Next.js 16 marketing website for Vortex, a columnar file format, drawn with the Vortex design system. The site features: ### Core Technologies -- **Next.js 16** with App Router and Turbopack for development +- **Next.js 16** with App Router and Turbopack - **React 19** with TypeScript -- **TailwindCSS 4** for styling -- **OGL** (WebGL library) for 3D graphics and ASCII art effect +- **The design system's CSS** (`src/styles/`), `globals.css` and CSS modules for styling, with no CSS framework +- **three.js** (WebGL) for the design system's ASCII mark, loaded once the page is idle - **Plausible Analytics** for privacy-focused tracking - **Vercel Analytics** for performance monitoring ### Visual System -- **3D Logo Animation**: WebGL-rendered GLTF model (`/public/logo.glb`) with ASCII art shader effect -- **Interactive Mouse/Touch Controls**: Model rotates based on user interaction -- **Responsive Design**: Different camera positions and sizing for mobile/desktop -- **Custom Fonts**: Geist Sans, Geist Mono, and Funnel Display +- **Design system**: `src/styles/vortex-tokens.css` and `src/styles/vortex.css` are copies of the Vortex design system's styles (change the system, not the copies), and the markup uses its `vx-` classes +- **ASCII mark**: `src/components/ascii-mark` mounts the system's renderer (`src/vendor/vortex-design/`) on the homepage and the 404, with the static symbol as its fallback +- **Themes**: Dark only, except the blog, which carries the ThemeSwitch (`src/lib/theme.ts`) +- **Custom Fonts**: Geist, Geist Mono and Funnel Display, self-hosted through `next/font` ### Component Structure -- `HeroASCII` - Main 3D logo component with WebGL rendering and ASCII conversion -- `Overlay` - Text overlay on top of the 3D scene - Layout components (`Header`, `Footer`) wrap all pages -- 404 page has its own hero variant (`hero-404`, `overlay-404`) +- The homepage composes the hero (`InstallTabs`, `AsciiMark`), `BenchmarkCard` and `LogoBanner` +- The 404 and error pages (`not-found.tsx`, `error.tsx`) share the hero's layout in `src/app/status.module.css` ### Key Files - `src/app/page.tsx` - Homepage with metadata for SEO -- `src/components/hero/index.tsx` - Complex WebGL rendering with custom shaders - `src/app/layout.tsx` - Root layout with analytics providers and font loading +- `src/app/globals.css` - The page frame, and the site's rules on top of the design system - `next.config.mjs` - Plausible proxy configuration, security headers (CSP, HSTS, Permissions-Policy, COOP, CORP) -The site is optimized for performance with font optimization, analytics integration, and responsive WebGL rendering. +The site is optimized for performance with font optimization, analytics integration, and an ASCII mark that loads only once the page is idle. ## Licensing @@ -124,10 +123,10 @@ Keep them in lockstep when bumping, and move to a new major only after Vercel of - **zod `<4.6.6`** (GHSA-mrhx-c7xc-m3w5, high memory exhaustion on oversized constrained collections). Path: bench's `@vercel/functions > @vercel/oidc > @vercel/cli-config` pins `zod@4.1.11` exactly. `overrides.zod = "^4.6.7"` puts it on 4.6.7. bench only imports `attachDatabasePool`, which does not load the OIDC code that uses zod. The snapshot scopes bench's zod `runtime`, because `@vercel/functions` is in bench's `dependencies`, so dependency review would block a PR that drops the override without `allow-ghsas: GHSA-mrhx-c7xc-m3w5` on the Dependency Review step in `ci.yml`. Drop the override once `@vercel/cli-config` accepts zod 4.6.6 or later. -- **postcss-selector-parser `<6.1.5`** (GHSA-rj75-hqrm-r3gf, moderate quadratic-time parsing). `@tailwindcss/typography@0.5.20`, the latest release, pins `postcss-selector-parser@6.0.10` exactly, so lockfile maintenance cannot reach the fix. `overrides["postcss-selector-parser"] = "^6.1.5"` stays on the same major. `renovate.json` keeps it on 6.x (`allowedVersions: "<7"`), so Renovate doesn't move the override to a major typography hasn't adopted. The built CSS does not change: with the font URLs normalized, the site's stylesheet is byte-identical with and without the override. Drop the override once `@tailwindcss/typography` ships a release that declares `postcss-selector-parser@^6.1.5` or later. - **Removed: `@lhci/cli`.** It brought eight advisories into `bun.lock`: extract-zip (two, with no fixed release), basic-ftp, body-parser, qs, tmp (two) and uuid. CI never used it, because the Lighthouse workflow runs `treosh/lighthouse-ci-action`, which ships its own copy. `bun run lighthouse` now fetches a pinned `@lhci/cli@0.15.1` with `bunx` when you run it. Do not add it back as a dependency. +**Cleared, for the record:** postcss-selector-parser `<6.1.5` (GHSA-rj75-hqrm-r3gf) went with `@tailwindcss/typography`, its only parent, when the blog moved to the design system's prose styles and dropped the plugin. + **Held back on purpose:** `@types/node` stays on `^26` in both apps to match the runtime (see "Node version"); `renovate.json` caps it with `allowedVersions: "<27"` so Renovate does not open 27.x / 28.x PRs ahead of the runtime. The `audit` job in `.github/workflows/ci.yml` runs a bare `bun audit` and never fails CI; any advisory it finds shows in its job summary, and is recorded here with how it is handled. Merge blocking on advisories is `dependency-review`'s job. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..d817195 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSES/OFL-1.1.txt b/LICENSES/OFL-1.1.txt new file mode 100644 index 0000000..84b8ae4 --- /dev/null +++ b/LICENSES/OFL-1.1.txt @@ -0,0 +1,85 @@ +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/REUSE.toml b/REUSE.toml index 830d2e4..5736bf1 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -13,6 +13,8 @@ path = [ "src/**.ts", "src/**.tsx", "src/**.css", + # The design system's modules, vendored unchanged (src/vendor/vortex-design/README.md). + "src/vendor/**.js", "scripts/**", "tests/**", ".github/**", @@ -45,10 +47,9 @@ SPDX-License-Identifier = "CC-BY-4.0" [[annotations]] path = [ "src/app/favicon.ico", - "src/assets/logo.svg", - "src/assets/logo-mobile.svg", - "public/logo.glb", - "public/404.glb", + "src/assets/vortex_symbol_dark_theme.svg", + "src/assets/vortex_wordmark.svg", + "src/assets/vortex_wordmark_dark_theme.svg", "bench/public/Vortex_Black_NoBG.svg", "bench/public/Vortex_White_NoBG.svg", "bench/web/public/Vortex_Black_NoBG.svg", @@ -71,7 +72,98 @@ path = "src/assets/lf_white.svg" SPDX-FileCopyrightText = "The Linux Foundation" SPDX-License-Identifier = "LicenseRef-Third-Party" +# Adopters' logos for the homepage's logo banner, used only to show who builds on Vortex. Each +# belongs to its owner and is not relicensed. Each file comes from its owner's own site, +# brand page or official repository, and each owner is named as it names itself. [[annotations]] -path = "src/assets/spiral.svg" -SPDX-FileCopyrightText = "Spiral" +path = "src/assets/logos/spiral.svg" +SPDX-FileCopyrightText = "Fulcrum Technologies, Inc. dba Spiral, Inc" SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/microsoft.png" +SPDX-FileCopyrightText = "Microsoft Corporation" +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/palantir.svg" +SPDX-FileCopyrightText = "Palantir Technologies Inc." +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/firebolt.svg" +SPDX-FileCopyrightText = "Firebolt Analytics Inc" +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/spiceai.svg" +SPDX-FileCopyrightText = "Spice AI, Inc." +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/datologyai.svg" +SPDX-FileCopyrightText = "DatologyAI, Inc." +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/arrow.svg" +SPDX-FileCopyrightText = "The Apache Software Foundation" +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/iceberg.svg" +SPDX-FileCopyrightText = "The Apache Software Foundation" +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/langchain.svg" +SPDX-FileCopyrightText = "LangChain Inc." +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/dash0.svg" +SPDX-FileCopyrightText = "Dash0 Inc." +SPDX-License-Identifier = "LicenseRef-Third-Party" + +# Milvus is an LF AI & Data Foundation project, which holds the project's trademark. +[[annotations]] +path = "src/assets/logos/milvus.svg" +SPDX-FileCopyrightText = "The Linux Foundation (LF AI & Data Foundation)" +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/duckdb.svg" +SPDX-FileCopyrightText = "DuckDB Foundation" +SPDX-License-Identifier = "LicenseRef-Third-Party" + +[[annotations]] +path = "src/assets/logos/clickhouse.svg" +SPDX-FileCopyrightText = "ClickHouse, Inc." +SPDX-License-Identifier = "LicenseRef-Third-Party" + +# Apache DataFusion, Apache Spark, Apache Hudi and Apache Paimon, and their logos, are trademarks +# of The Apache Software Foundation. +[[annotations]] +path = [ + "src/assets/logos/datafusion.svg", + "src/assets/logos/spark.svg", + "src/assets/logos/hudi.png", + "src/assets/logos/paimon.png", +] +SPDX-FileCopyrightText = "The Apache Software Foundation" +SPDX-License-Identifier = "LicenseRef-Third-Party" + +# The fonts the post Open Graph images set their text in (src/lib/og.tsx). They are the design +# system's Funnel Display and Geist Mono, as Google Fonts distributes them, under the SIL Open +# Font License. next/og cannot read the woff2 files next/font serves. The Geist Mono file has its +# GSUB table removed, because next/og's font parser cannot read its contextual alternates. The +# licence allows a modified version, and the font names no Reserved Font Name. +[[annotations]] +path = "src/lib/og-assets/FunnelDisplay-Medium.ttf" +SPDX-FileCopyrightText = "2024 The Funnel Project Authors (https://github.com/Dicotype/Funnel)" +SPDX-License-Identifier = "OFL-1.1" + +[[annotations]] +path = "src/lib/og-assets/GeistMono-Regular.ttf" +SPDX-FileCopyrightText = "2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)" +SPDX-License-Identifier = "OFL-1.1" diff --git a/bench/docs/architecture/performance.md b/bench/docs/architecture/performance.md index f5d0472..99f23c7 100644 --- a/bench/docs/architecture/performance.md +++ b/bench/docs/architecture/performance.md @@ -256,13 +256,16 @@ 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 +inline script before the first group +([`OpenGroupsScript`](../../web/components/OpenGroupsScript.tsx)) opens each +listed group as soon as the parser inserts it: its `MutationObserver` callback +runs before the browser can paint the new nodes. So a reload or Back never +shows them collapsed first, even on a connection slow enough that the first +groups paint before the last arrive. A second script after the last group ends +the work and 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 @@ -278,8 +281,7 @@ with one-off scripts that are not in this repository. - 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. + is small enough that its first paint already shows the groups. - 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 @@ -289,6 +291,48 @@ with one-off scripts that are not in this repository. - Expand All mounts all 791 islands in the toggle tasks that follow the click: 574 ms of blocking time against 500 ms. +Each request to `/` decides how to send the groups +([`app/page.tsx`](../../web/app/page.tsx)). It starts reading them and waits up +to 150 ms (`LOADING_VIEW_AFTER_MS`). A Data Cache hit answers well inside that, +so the page renders the groups directly, with no Suspense boundary: plain, +visible HTML that a reader with JavaScript off can read in full. A miss gets a +Suspense boundary instead: the "Loading benchmark summaries…" view streams at +once and the groups follow into a hidden segment that React's inline script +reveals. Without JavaScript that reveal never runs, so the loading view says to +reload in a few minutes (the CDN keeps a copy of `/` for five). A client +navigation to `/` on a miss shows the same loading view instead of holding the +previous page. A database failure inside the wait fails the render, so `/` +returns a real 500; one after it reaches the reader as an error inside a 200 +response that has already started. + +Measured as above (local dump, desktop, CPU 4×, median of 5; the cold runs on a +server restarted with an empty Data Cache), against the build that always waited +for the data (c4e0934, on #120) and, for the first column, the build that always +streamed the loading view (511c316): + +| `/` | Always streamed | Always waited | Decides per request | +| --- | --- | --- | --- | +| Miss: time to first byte | 40 ms | 590 ms | 180 ms | +| Miss: first contentful paint | 184 ms (loading view) | 720 ms | 328 ms (loading view) | +| Miss: groups laid out | 613 ms | 633 ms | 605 ms | +| Hit: time to first byte | 12 ms | 12 ms | 13 ms | +| Hit: first contentful paint | 96 ms (loading view) | 140 ms | 136 ms | +| Hit: groups laid out | 368 ms | 39 ms | 50 ms | +| Client navigation on a miss: loading view | (not measured) | never | 175 ms | +| Client navigation on a miss: groups | (not measured) | 583 ms | 593 ms | + +"Groups laid out" is the first animation frame in which a group section has +layout. In production a miss costs the cold database read, seconds after an idle +gap (see "The cost being hidden"); the CDN and the 24-hour Data Cache backstop +keep misses rare, and an ingest causes none: it marks the entries stale rather +than dropping them (see "Refresh on ingest"), so the next render of `/` takes +the plain-HTML path with the previous data. + +The 150 ms threshold was measured against the local Data Cache, where a hit +answers in a few milliseconds. Check it against Vercel's function logs on a +preview deployment before relying on it: a hit there reads the cache over the +network. + ### 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/web/app/globals.css b/bench/web/app/globals.css index 2c57e78..5bab710 100644 --- a/bench/web/app/globals.css +++ b/bench/web/app/globals.css @@ -137,6 +137,16 @@ a:hover { text-decoration: underline; } +/* Without JavaScript. The server renders `` and the + * root layout's pre-paint script swaps the class for `js`, so these rules + * hold only when no script runs. A control that does nothing without + * JavaScript carries `data-requires-js` and is hidden rather than left dead. + * `!important` beats every display rule those controls carry, the mobile + * ones included. */ +.no-js [data-requires-js] { + display: none !important; +} + .sticky-header { position: fixed; top: 0; @@ -410,6 +420,8 @@ main { font-style: italic; } +/* The landing page's loading view (`HomeLoading`), streamed while a slow + * read of the groups finishes. */ .summary-loading { display: grid; min-height: calc(100vh - var(--header-height) - 5rem); @@ -437,6 +449,10 @@ main { font-size: 0.875rem; } +.summary-loading-panel noscript p { + margin-top: 0.75rem; +} + .chart-spinner.summary-loading-spinner { width: 1.75rem; height: 1.75rem; @@ -535,8 +551,8 @@ main { 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 + * The copy-link control next to a group's chart count (GroupPermalink). A bare + * icon link with the info icon's muted-to-foreground hover treatment; the * `--copied` state swaps in an accent-colored checkmark as confirmation. */ .group-permalink { @@ -550,6 +566,7 @@ main { border-radius: 6px; background: transparent; color: var(--muted); + text-decoration: none; cursor: pointer; flex-shrink: 0; transition: @@ -560,6 +577,7 @@ main { .group-permalink:focus-visible { color: var(--fg); background: var(--border); + text-decoration: none; } .group-permalink--copied, .group-permalink--copied:hover, @@ -569,7 +587,7 @@ main { /* * The docs link next to a group title (GroupDocsLink), pointing at the * benchmark's explainer doc in the monorepo. Sized and coloured like the - * permalink button beside it so the summary row reads as one row of quiet + * copy-link control beside it so the summary row reads as one row of quiet * affordances. */ .group-docs-link { @@ -592,7 +610,7 @@ main { color: var(--fg); background: var(--border); } -/* Screen-reader-only text (the permalink button's copied announcement). */ +/* Screen-reader-only text (the permalink's copied announcement). */ .visually-hidden { position: absolute; width: 1px; @@ -702,6 +720,31 @@ main { border-bottom: 0; } +/* The body follows the `
`, not inside it, so the disclosure does not + * hide it. A collapsed group's body is empty today (`GroupBody` mounts its + * content only while the group is open); this keeps it hidden whatever it + * holds, with or without JavaScript. */ +.group-disclosure:not([open]) ~ .group-body { + display: none; +} + +/* Without JavaScript a group's summary still toggles its
, but + * opening it shows nothing more, so the row reads as a plain heading: no + * chevron, no pointer, no hover, and no open state. CSS cannot remove its + * Tab stop. */ +.no-js .group-summary { + cursor: default; +} +.no-js .group-summary-row::before { + display: none; +} +.no-js .group-summary:hover .group-summary-row { + background: none; +} +.no-js .group-disclosure[open] ~ .benchmark-scores-summary { + border-bottom: 0; +} + .group-body > .chart-grid .chart-card:last-child { margin-bottom: 0; } @@ -1404,6 +1447,10 @@ main > .chart-card:not(.chart-grid .chart-card) .chart-wrap { color: var(--muted); font-size: 0.875rem; } +/* The landing page's note opens `
`, above the first group. */ +.no-script--lead { + margin: 0 0 1.5rem; +} /* Per-card loading + error indicators (lazy-fetch on details toggle). */ .chart-loading, @@ -1654,6 +1701,18 @@ main > .chart-card:not(.chart-grid .chart-card) .chart-wrap { .nav-controls.nav-controls--open { display: flex; } + /* Without JavaScript the hamburger cannot open the panel, so the nav sits + * in the header row instead. Its only control that works without + * JavaScript, the GitHub link, shows; the rest are hidden. */ + .no-js .nav-controls { + display: flex; + position: static; + flex-direction: row; + padding: 0; + border-bottom: 0; + background: none; + box-shadow: none; + } .nav-controls .filter-dropdown { width: 100%; } diff --git a/bench/web/app/globals.no-js.test.ts b/bench/web/app/globals.no-js.test.ts new file mode 100644 index 0000000..6d8f4ec --- /dev/null +++ b/bench/web/app/globals.no-js.test.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +// @vitest-environment node + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const css = readFileSync(join(__dirname, 'globals.css'), 'utf8'); + +/** The declarations of the first rule whose selector is exactly `selector`. */ +function rule(source: string, selector: string): string | null { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return source.match(new RegExp(`(?:^|[}\\s])${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? null; +} + +describe('the stylesheet without JavaScript', () => { + it('hides every control that needs JavaScript under the no-js root, beating their own display rules', () => { + expect(rule(css, '.no-js [data-requires-js]')).toMatch(/display:\s*none\s*!important/); + }); + + it('hides a collapsed group body, which sits after its
, not inside it', () => { + expect(rule(css, '.group-disclosure:not([open]) ~ .group-body')).toMatch(/display:\s*none/); + }); + + it('makes a group summary read as a plain heading, with no chevron, pointer, hover or open state', () => { + expect(rule(css, '.no-js .group-summary')).toMatch(/cursor:\s*default/); + expect(rule(css, '.no-js .group-summary-row::before')).toMatch(/display:\s*none/); + expect(rule(css, '.no-js .group-summary:hover .group-summary-row')).toMatch( + /background:\s*none/, + ); + expect(rule(css, '.no-js .group-disclosure[open] ~ .benchmark-scores-summary')).toMatch( + /border-bottom:\s*0/, + ); + }); + + it('shows the mobile nav in the header row, since no script can open its panel', () => { + const mobile = css.match(/@media\s*\(max-width:\s*768px\)\s*\{(?:[^{}]|\{[^{}]*\})*\}/g); + expect(mobile).not.toBeNull(); + const declarations = rule(mobile!.join('\n'), '.no-js .nav-controls'); + expect(declarations).toMatch(/display:\s*flex/); + expect(declarations).toMatch(/position:\s*static/); + }); +}); diff --git a/bench/web/app/layout.test.ts b/bench/web/app/layout.test.ts new file mode 100644 index 0000000..f7f6833 --- /dev/null +++ b/bench/web/app/layout.test.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +// @vitest-environment node + +import { JSDOM } from 'jsdom'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import RootLayout from '@/app/layout'; + +// The stylesheets are Next.js's to build; loading them here would run bench's +// PostCSS config through Vite. +vi.mock('./globals.css', () => ({})); +vi.mock('./vortex-skin.css', () => ({})); + +const html = renderToStaticMarkup(createElement(RootLayout, null, createElement('main'))); + +/** Load the layout's markup in a browser-like document, scripts on or off. */ +function load(runScripts: boolean, theme?: string): Document { + return new JSDOM(`${html}`, { + url: 'https://bench.vortex.dev/', + ...(runScripts ? { runScripts: 'dangerously' as const } : {}), + beforeParse(window) { + if (theme !== undefined) { + window.localStorage.setItem('bench-theme', theme); + } + }, + }).window.document; +} + +describe('RootLayout', () => { + it('renders the root as no-js, which only a script swaps for js', () => { + expect(html).toMatch(/^/); + expect(load(false).documentElement.className).toBe('no-js'); + expect(load(true).documentElement.className).toBe('js'); + }); + + it('still applies a stored theme before first paint', () => { + const doc = load(true, 'dark'); + expect(doc.documentElement.className).toBe('js'); + expect(doc.documentElement.dataset.theme).toBe('dark'); + }); +}); diff --git a/bench/web/app/layout.tsx b/bench/web/app/layout.tsx index 9543d34..f424aba 100644 --- a/bench/web/app/layout.tsx +++ b/bench/web/app/layout.tsx @@ -4,7 +4,11 @@ import type { Metadata, Viewport } from 'next'; import type { ReactNode } from 'react'; +import { FONTS_CSS } from '@/lib/fonts'; + import './globals.css'; +// The Vortex design system's skin: colours, corners and Geist over globals.css. +import './vortex-skin.css'; export const metadata: Metadata = { title: 'Vortex Benchmarks', @@ -27,44 +31,48 @@ export const viewport: Viewport = { initialScale: 1, }; -// The pre-paint theme bootstrap, ported verbatim from -// `render.rs::theme_bootstrap_script`: apply any stored theme choice before -// first paint so a dark-mode visitor never flashes the light theme. Inline (not -// a module) and in `` so it runs before the body renders. The stored -// theme lands as a `data-theme` attribute the server never rendered, so the -// root `` carries `suppressHydrationWarning` (attribute-level, one -// element deep) to keep dev hydration checks quiet for themed visitors. The -// script's bare catch is deliberate and v3-byte-identical: localStorage +// The pre-paint bootstrap. Its first statement swaps the root's server-rendered +// `no-js` class for `js`, so `.no-js [data-requires-js]` (globals.css) hides +// the controls that do nothing without JavaScript only when no script runs. +// The rest is ported verbatim from `render.rs::theme_bootstrap_script`: apply +// any stored theme choice before first paint so a dark-mode visitor never +// flashes the light theme. Inline (not a module) and in `` so it runs +// before the body renders, and a constant that builds no code from a value. +// The class and the stored theme (a `data-theme` attribute) differ from what +// the server rendered, so the root `` carries `suppressHydrationWarning` +// (attribute-level, one element deep) to keep dev hydration checks quiet. The +// theme part's bare catch is deliberate and v3-byte-identical: localStorage // access throws in some private-browsing modes, and the correct fallback is // silently keeping the default prefers-color-scheme theme. -const THEME_BOOTSTRAP = `(function(){try{var t=localStorage.getItem("bench-theme");if(t==="light"||t==="dark"){document.documentElement.dataset.theme=t;}}catch(e){}})();`; - -const FUNNEL_DISPLAY_CSS = - 'https://fonts.googleapis.com/css2?family=Funnel+Display:wght@400;500;600;700&display=swap'; +const THEME_BOOTSTRAP = + '(function(){' + + 'document.documentElement.className=document.documentElement.className.replace("no-js","js");' + + 'try{var t=localStorage.getItem("bench-theme");if(t==="light"||t==="dark"){document.documentElement.dataset.theme=t;}}catch(e){}' + + '})();'; -// This is a hack to load Funnel font without blocking page paint. The URL is -// written out here rather than interpolated from FUNNEL_DISPLAY_CSS, so the -// inline script is a constant and never builds code from a value. Keep the two -// in step. +// This is a hack to load the web fonts without blocking page paint. The URL is +// written out here rather than interpolated from FONTS_CSS, so the inline +// script is a constant and never builds code from a value. lib/fonts.test.ts +// fails if the two drift apart. const FONT_BOOTSTRAP = '(function(){var l=document.createElement("link");l.rel="stylesheet";' + - 'l.href="https://fonts.googleapis.com/css2?family=Funnel+Display:wght@400;500;600;700&display=swap";' + + 'l.href="https://fonts.googleapis.com/css2?family=Funnel+Display:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap";' + 'document.head.appendChild(l);})();'; /** * Root layout: the ``/`` shell plus the global stylesheet, the - * pre-paint theme bootstrap, and the heading web font. + * pre-paint theme bootstrap, and the web fonts. */ export default function RootLayout({ children }: { children: ReactNode }) { return ( - + `, + it('renders the constant scripts as they are, before the first group and after the last', () => { + expect( + renderToStaticMarkup( + +
+ , + ), + ).toBe( + `` + + '
' + + ``, ); }); - it('is ES5 wrapped in a try, so no browser fails to parse it and no error escapes', () => { - expect(OPEN_GROUPS_BOOTSTRAP).toMatch(/^\(function\(\)\{try\{.*\}catch\(e\)\{\}\}\)\(\);$/); - expect(OPEN_GROUPS_BOOTSTRAP).not.toMatch(/=>|`|\blet\b|\bconst\b|\bclass\b/); - }); + it.each([ + ['bootstrap', OPEN_GROUPS_BOOTSTRAP], + ['finish step', OPEN_GROUPS_FINISH], + ])( + 'keeps the %s ES5 wrapped in a try, so no browser fails to parse it and no error escapes', + (_, script) => { + expect(script).toMatch(/^\(function\(\)\{try\{.*\}catch\(e\)\{\}\}\)\(\);$/); + expect(script).not.toMatch(/=>|`|\blet\b|\bconst\b|\bclass\b/); + }, + ); it('opens the listed groups while the page parses', () => { const { doc, errors } = load('#clickbench,tpc-h-nvme-sf-1'); @@ -205,6 +222,40 @@ describe('OpenGroupsScript', () => { expect(marker?.input).toBe(false); }); + it.each([ + ['', 0], + ['#', 0], + ['#,,', 0], + ['#clickbench', 1], + ])('creates an observer only when the fragment lists something ("%s")', (hash, observers) => { + let created = 0; + const { doc, errors } = load(hash, (window) => { + const Native = window.MutationObserver; + window.MutationObserver = class extends Native { + constructor(callback: MutationCallback) { + super(callback); + created += 1; + } + }; + }); + expect(created).toBe(observers); + expect(takeOpenGroupsBootstrap(doc)).toEqual({ input: false }); + expect(errors).toEqual([]); + }); + + it('still opens every listed group, at the finish step, when no observer can be made', () => { + const { doc, errors } = load('#clickbench,tpc-h-nvme-sf-1', (window) => { + window.MutationObserver = class { + constructor() { + throw new Error('no observers here'); + } + } as unknown as typeof MutationObserver; + }); + expect(openAnchors(doc)).toEqual(['tpc-h-nvme-sf-1', 'clickbench']); + expect(takeOpenGroupsBootstrap(doc)).toEqual({ input: false }); + expect(errors).toEqual([]); + }); + it('leaves no marker when it fails, so the groups open after hydration instead', () => { const { doc, errors } = load('#clickbench', (window) => { window.Document.prototype.querySelectorAll = () => { @@ -215,4 +266,115 @@ describe('OpenGroupsScript', () => { expect(errors).toEqual([]); }); }); + + // On a slow connection the browser paints the first groups before the rest + // have arrived, so each listed group must open as soon as it is parsed. The + // observer runs at the end of the parser task that inserted the group, + // before the browser can paint it; `settle` lets it run. + describe('while the page is still arriving', () => { + const SECTION_HTML = SECTIONS.map((section) => renderToStaticMarkup(section)); + + /** A page whose `
` holds only the bootstrap, as a browser has it + * before the first group arrives. `append` adds markup to the end of + * `
`, as the parser does, and `finish` runs the finish step. */ + function stream(hash: string) { + const virtualConsole = new VirtualConsole(); + const errors: unknown[] = []; + virtualConsole.on('jsdomError', (e) => errors.push(e)); + const dom = new JSDOM( + `
`, + { url: `https://bench.vortex.dev/${hash}`, runScripts: 'dangerously', virtualConsole }, + ); + const doc = dom.window.document; + const main = doc.querySelector('main'); + if (main === null) { + throw new Error('no main'); + } + return { + window: dom.window, + doc, + main, + errors, + append: (html: string) => main.insertAdjacentHTML('beforeend', html), + settle: () => new Promise((resolve) => setTimeout(resolve, 0)), + finish: () => { + const script = doc.createElement('script'); + script.textContent = OPEN_GROUPS_FINISH; + main.appendChild(script); + }, + }; + } + + it('opens a listed group as soon as it is parsed, before the later groups arrive', async () => { + const page = stream('#random-access'); + page.append(SECTION_HTML[0]!); + await page.settle(); + expect(openAnchors(page.doc)).toEqual([]); + + page.append(SECTION_HTML[1]!); + await page.settle(); + expect(openAnchors(page.doc)).toEqual(['random-access']); + // The marker waits for the finish step. + expect((page.doc as unknown as Record).__benchOpenGroups).toBeUndefined(); + + page.append(SECTION_HTML[2]!); + page.finish(); + expect(openAnchors(page.doc)).toEqual(['random-access']); + expect(takeOpenGroupsBootstrap(page.doc)).toEqual({ input: false }); + expect(page.errors).toEqual([]); + }); + + it('opens a listed group whose disclosure arrives after its section', async () => { + const page = stream('#random-access'); + const section = page.doc.createElement('section'); + section.id = 'random-access'; + section.setAttribute('data-group-slug', 'random_access.eyJ'); + page.main.appendChild(section); + await page.settle(); + + const details = page.doc.createElement('details'); + details.className = 'group-disclosure'; + section.appendChild(details); + await page.settle(); + expect(details.open).toBe(true); + }); + + it('keeps closed a listed group the reader closed before the last group arrived', async () => { + const page = stream('#tpc-h-nvme-sf-1,clickbench'); + page.append(SECTION_HTML[0]!); + await page.settle(); + expect(openAnchors(page.doc)).toEqual(['tpc-h-nvme-sf-1']); + + const first = page.doc.querySelector('details.group-disclosure'); + if (first === null) { + throw new Error('no disclosure'); + } + first.open = false; + page.append(SECTION_HTML[1]! + SECTION_HTML[2]!); + await page.settle(); + page.finish(); + expect(openAnchors(page.doc)).toEqual(['clickbench']); + }); + + it('opens a group the fragment lists by its slug only at the end, when no group can still claim it as an anchor', async () => { + const page = stream(`#${encodeURIComponent('random_access.eyJ')}`); + for (const html of SECTION_HTML) { + page.append(html); + await page.settle(); + } + expect(openAnchors(page.doc)).toEqual([]); + + page.finish(); + expect(openAnchors(page.doc)).toEqual(['random-access']); + }); + + it('records input from before the last group arrived', () => { + const page = stream('#clickbench'); + page.append(SECTION_HTML[0]!); + page.window.dispatchEvent(new page.window.Event('keydown')); + page.append(SECTION_HTML[1]! + SECTION_HTML[2]!); + page.finish(); + expect(takeOpenGroupsBootstrap(page.doc)).toEqual({ input: true }); + }); + }); }); diff --git a/bench/web/components/OpenGroupsScript.tsx b/bench/web/components/OpenGroupsScript.tsx index cf9ee0e..30643e0 100644 --- a/bench/web/components/OpenGroupsScript.tsx +++ b/bench/web/components/OpenGroupsScript.tsx @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +import type { ReactNode } from 'react'; + /** * The pre-paint bootstrap for the landing page's open groups: it opens the * groups the URL fragment lists before the browser first paints them, so a @@ -9,112 +11,193 @@ * the open groups' bodies mount, `LandingState` restores it by element * (`lib/scroll-restore.ts`). * + * The groups are visible HTML as they arrive, and on a slow connection the + * browser paints the first ones before the rest have arrived. So the script + * runs before the first group, and opens each listed group as soon as the + * parser has inserted it: a `MutationObserver` callback runs at the end of the + * parser task that inserted the nodes, before the browser can paint them. + * [`OPEN_GROUPS_FINISH`], after the last group, ends the work. + * * It is a constant ES5 string with nothing interpolated into it. The only * data it reads is `location.hash` (its input listeners, below, record only - * that input happened). It first builds two lookups from the group - * sections, each keeping the first section per key: the section `id` (the - * readable anchor) and its `data-group-slug` (the opaque slug the first - * permalinks carried). Keys carry a `:` prefix, so a token such as - * `__proto__` or `constructor` never reads an `Object.prototype` member. It - * then splits the fragment on `,`, decodes each token in its own `try` (a - * malformed token is skipped), skips empty and repeated tokens, and looks the - * token up as an anchor and then, failing that, as a slug. It never builds a - * selector or markup from the fragment. For a match it sets `open` on that - * section's `details.group-disclosure`. This is exactly what - * `openGroupsFromHash` in `lib/open-groups.ts` does after hydration; - * `OpenGroupsScript.test.tsx` pins the two together. The work is linear in - * the fragment's length, and the DOM work is bounded by the number of - * sections, so a crafted multi-megabyte fragment cannot stall the first - * paint. + * that input happened). It splits the fragment on `,`, decodes each token in + * its own `try` (a malformed token is skipped), and keeps each nonempty token + * once, with a `:` prefix, so a token such as `__proto__` or `constructor` + * never reads an `Object.prototype` member. It never builds a selector or + * markup from the fragment. + * + * Then it visits the group sections in page order, each once, as they + * arrive. A section whose `id` (the readable anchor) the fragment lists gets + * `open` on its `details.group-disclosure`, if it is the first section with + * that `id`; a visit that finds no disclosure yet waits for the next + * mutation. It also keeps the first section per `data-group-slug` (the + * opaque slug the first permalinks carried). At the end, the finish step + * visits any section not yet visited, then opens, for each listed token that + * names no section's anchor, the first section with that slug. A slug token + * waits for the end because a later section could still carry it as an + * anchor. This is exactly what `openGroupsFromHash` in `lib/open-groups.ts` + * does after hydration; `OpenGroupsScript.test.tsx` pins the two together. + * The work is linear in the fragment's length, and the DOM work is bounded by + * the number of sections, so a crafted multi-megabyte fragment cannot stall + * the first paint. * - * Last, it leaves a marker on its document for [`LandingState`] + * Last, the finish step leaves a marker on its document for [`LandingState`] * (`takeOpenGroupsBootstrap` in `lib/open-groups.ts`): * `document.__benchOpenGroups`, an object whose `input` turns true at the * reader's first `wheel`, `touchstart`, `keydown` or `pointerdown` (one-shot - * passive capture listeners on the window; `stop` removes them). After hydration, - * `LandingState` takes the marker on its first mount. It then keeps the open - * state as the reader left it (a group opened or closed before hydration - * stays so, and the fragment is written from it), and when `input` is true - * it does not scroll the page at all: no restore, no scroll to the fragment's - * first group. A scroll is not input: the browser's own jump - * to a single-anchor fragment fires one. The marker is written only after - * the groups are open, and the outer `try` keeps any failure from reaching the - * page: without the marker, the groups simply open after hydration instead. + * passive capture listeners on the window, added before the first group; + * `stop` removes them). After hydration, `LandingState` takes the marker on + * its first mount. It then keeps the open state as the reader left it (a + * group opened or closed before hydration stays so, even while the page was + * still arriving, and the fragment is written from it), and when `input` is + * true it does not scroll the page at all: no restore, no scroll to the + * fragment's first group. A scroll is not input: the browser's own jump to a + * single-anchor fragment fires one. The marker is written only after the + * groups are open, and each script's outer `try` keeps any failure from + * reaching the page: without the marker, the groups simply open after + * hydration instead. * * Kept as one line so the shipped bytes are what this file shows. Readable: * * var t = location.hash.slice(1).split(','), - * s = document.querySelectorAll('section[data-group-slug][id]'), - * a = {}, b = {}, u = {}, i, k, m, d, g, h, + * w = {}, p = [], a = {}, b = {}, j = 0, i, k, o, g, h, v, * n = ['wheel', 'touchstart', 'keydown', 'pointerdown']; - * for (i = 0; i < s.length; i++) { - * k = ':' + s[i].id; - * if (!a[k]) a[k] = s[i]; - * k = ':' + s[i].getAttribute('data-group-slug'); - * if (!b[k]) b[k] = s[i]; - * } * for (i = 0; i < t.length; i++) { * try { k = decodeURIComponent(t[i]); } catch (e) { continue; } * if (!k) continue; * k = ':' + k; - * if (u[k]) continue; - * u[k] = 1; - * m = a[k] || b[k]; - * d = m && m.querySelector('details.group-disclosure'); - * if (d) d.open = true; + * if (w[k]) continue; + * w[k] = 1; + * p.push(k); * } - * g = document.__benchOpenGroups = { + * // Visit the sections not yet visited; `z` is the finish step. + * v = function (z) { + * var l = document.querySelectorAll('section[data-group-slug][id]'), s, d, c; + * for (; j < l.length; j++) { + * s = l[j]; + * c = ':' + s.id; + * if (w[c] && !a[c]) { + * d = s.querySelector('details.group-disclosure'); + * if (!d && !z) return; + * if (d) d.open = true; + * } + * a[c] = 1; + * c = ':' + s.getAttribute('data-group-slug'); + * if (!b[c]) b[c] = s; + * } + * }; + * g = { * input: false, * stop: function () { - * for (var j = 0; j < n.length; j++) removeEventListener(n[j], h, true); + * for (var m = 0; m < n.length; m++) removeEventListener(n[m], h, true); * } * }; * h = function () { g.input = true; g.stop(); }; * for (i = 0; i < n.length; i++) { * addEventListener(n[i], h, { capture: true, passive: true }); * } + * // The finish step exists before the observer does: if creating the + * // observer fails, the finish step still opens every listed group. + * document.__benchOpenGroupsEnd = function () { + * var m, d; + * document.__benchOpenGroupsEnd = null; + * if (o) o.disconnect(); + * if (p.length) { + * v(1); + * for (m = 0; m < p.length; m++) { + * k = p[m]; + * if (!a[k] && b[k]) { + * d = b[k].querySelector('details.group-disclosure'); + * if (d) d.open = true; + * } + * } + * } + * document.__benchOpenGroups = g; + * }; + * // A fragment that lists nothing has nothing to open: no observer. + * if (p.length) { + * o = new MutationObserver(function () { try { v(0); } catch (e) {} }); + * o.observe(document, { childList: true, subtree: true }); + * } */ export const OPEN_GROUPS_BOOTSTRAP = '(function(){try{' + 'var t=location.hash.slice(1).split(","),' + - 's=document.querySelectorAll("section[data-group-slug][id]"),' + - 'a={},b={},u={},i,k,m,d,g,h,' + + 'w={},p=[],a={},b={},j=0,i,k,o,g,h,v,' + 'n=["wheel","touchstart","keydown","pointerdown"];' + - 'for(i=0;i`, so it runs - * as soon as the groups' markup has parsed, in whatever streamed chunk - * carries them, and before they paint. The script adds `open` to a - * server-rendered `
`, which is why `GroupSection`'s disclosure - * carries `suppressHydrationWarning`. + * The finish step of [`OPEN_GROUPS_BOOTSTRAP`], after the last group: it + * visits the sections the observer has not reached, opens the groups listed by + * slug, stops the observer and leaves the marker. When the bootstrap failed it + * finds no finish step, and its `try` swallows that: no marker is left. + */ +export const OPEN_GROUPS_FINISH = '(function(){try{document.__benchOpenGroupsEnd()}catch(e){}})();'; + +/** + * Wraps the landing page's group sections in [`OPEN_GROUPS_BOOTSTRAP`], as an + * inline script before the first section, and [`OPEN_GROUPS_FINISH`], as one + * after the last, inside `
`. The bootstrap runs before any group has + * arrived and opens each listed group before it paints, in whatever streamed + * chunk carries it; the finish step runs as soon as the last group's markup + * has parsed. The scripts add `open` to a server-rendered `
`, which + * is why `GroupSection`'s disclosure carries `suppressHydrationWarning`. * * React never runs a script it renders on the client, so a client navigation - * to `/` skips this and leaves no marker; `LandingState` opens the groups + * to `/` skips both and leaves no marker; `LandingState` opens the groups * from the fragment then. */ -export function OpenGroupsScript() { +export function OpenGroupsScript({ children }: { children?: ReactNode }) { return ( -