From de3a6c46fa59543c55670f60ab68eee10065d09d Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 26 Jun 2026 14:38:39 -0300 Subject: [PATCH 01/22] feat: adopt Next.js 16.3 preview + partialPrefetching (instant navigations) --- CHANGELOG.md | 1 + app/loading.tsx | 13 +++++++++++-- bun.lock | 30 ++++++++++++++---------------- next.config.ts | 4 +++- package.json | 4 ++-- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d7c6c8..e8dfb69b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ latest tag; security fixes land on the latest release (see `SECURITY.md`). ### Changed +- Next.js 16.3 preview: bumped `next` to the `preview` dist-tag, enabled top-level `partialPrefetching: true` (the 16.3 successor to `experimental.prefetchInlining`), removed the now-superseded `experimental.prefetchInlining` flag from the `experimental` block, and replaced the placeholder `app/loading.tsx` with an accessible skeleton shell (`role="status"`, `aria-busy="true"`, `sr-only` label, `animate-pulse` bars) demonstrating the instant-navigation loading pattern. - Dependencies: bumped every package to its latest version. The only breaking change was `tempus` dev.18, whose `TempusCallback` moved from positional `(time, deltaTime)` arguments to a single `TempusState` object (`{ time, deltaTime, frame, budget }`) and deprecated the `priority` option in favor of `order` — migrated all seven call sites (the GSAP, Lenis, marquee, and WebGL RAF runtimes, the `lib/utils/raf` write queue, and the dev `Stats` overlay). Also bumped `@base-ui/react` 1.6, `next-sanity` 13.1, `@types/node` 26, `deslop-cli` 0.5.8, `@biomejs/biome` 2.5.1, Storybook 10.4.6, Tailwind 4.3.1, and `@typescript/native-preview`; `actions/checkout` 6 → 7 in CI. Supersedes #251–254. (#255) - Dependencies: bumped the Sanity toolchain to v6 (`sanity`, `@sanity/vision`, `groq` → `^6.1.0`) — Studio v6's breaking changes (Node 20 dropped, `auth.providers` replace semantics, `enableLegacySearch` removed) don't touch the integration's API surface, so forks on Node ≥ 22 update without code changes. Also bumped `@biomejs/biome` 2.5.0, `deslop-cli` 0.0.25, and `@typescript/native-preview`. Biome 2.5's newly-enabled rules required a `` on `darkroom.svg` (`noSvgWithoutTitle`) and an import-spacing fix in generated `sanity.types.ts` (`organizeImports`); the inline version `<Script>` carries a documented `useInlineScriptId` ignore (the `id` is present — biome mis-detects JSX-children content). Supersedes #221–226. (#228) - Integration registry is single-source: `INTEGRATION_BUNDLES` keys are typed against `RemovableId` from `lib/integrations/registry`, `prepare-handoff` matches integrations by id, and `next.config.ts` cleanup runs through typed ts-morph AST ops — the regex `updateNextConfig` is deleted. diff --git a/app/loading.tsx b/app/loading.tsx index 59573acc..be961774 100644 --- a/app/loading.tsx +++ b/app/loading.tsx @@ -1,7 +1,16 @@ export default function Loading() { return ( - <div className="flex min-h-screen items-center justify-center font-mono uppercase"> - <p>Cooking...</p> + <div + role="status" + aria-busy="true" + className="flex min-h-screen flex-col items-center justify-center gap-3 font-mono" + > + <span className="sr-only">Loading</span> + <div className="w-40 animate-pulse space-y-2"> + <div className="h-2 rounded bg-current opacity-20" /> + <div className="h-2 w-3/4 rounded bg-current opacity-20" /> + <div className="h-2 w-1/2 rounded bg-current opacity-10" /> + </div> </div> ) } diff --git a/bun.lock b/bun.lock index e6a43fdc..0f718059 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "gsap": "^3.15.0", "hamo": "1.0.0-dev.13", "lenis": "^1.3.23", - "next": "16.2.9", + "next": "16.3.0-preview.5", "next-sanity": "^13.1.1", "postprocessing": "^6.39.1", "react": "19.2.7", @@ -34,7 +34,7 @@ "@clack/prompts": "^1.6.0", "@csstools/postcss-global-data": "^4.0.0", "@happy-dom/global-registrator": "^20.10.6", - "@next/bundle-analyzer": "16.2.9", + "@next/bundle-analyzer": "16.3.0-preview.5", "@sanity/vision": "^6.1.0", "@storybook/addon-mcp": "^0.6.0", "@storybook/nextjs-vite": "^10.4.6", @@ -744,25 +744,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@next/bundle-analyzer": ["@next/bundle-analyzer@16.2.9", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-yGWyLbC8MMn+hk9j6l6GammpbUz5S3yZzl3lpoWfVXhIpJfh6kAWG7JwF4WoG3f0VnYRY959yo1QQEI8mV/+jg=="], + "@next/bundle-analyzer": ["@next/bundle-analyzer@16.3.0-preview.5", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-qbFx5NfRYEjX9u68bpsulMpG2l14a9FF3Y78Ds4YBM5M7XrNWOL7uhwTBr1XXlna2AOdJSiCiaI1ip47iEqjgQ=="], - "@next/env": ["@next/env@16.2.9", "", {}, "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg=="], + "@next/env": ["@next/env@16.3.0-preview.5", "", {}, "sha512-XqdVR0utAWMsVc1OIyO48D32vrdmC4/uAgI3Ds088YlOO4vfGKXXVyvkGFkOZkOK0xg7bNYNfJAarX4A0tYqGg=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.0-preview.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PPWAJGoIkzVpz5hOD9V/qGNdkBuWj3QXhjQU8BQ1FXlMy6xsy4+aD/3UoasKy/HYInW4h1LqdQtDhiQkLYrrMA=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.0-preview.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-UPN/RS1H+kr9fgJrbFoH7bs1b9q2/G5cFe+uUf0nP4Hlgfl8NzfTBHEJKTfLAGqi1Qemwuyd29pvRy2vwEjL5g=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.0-preview.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-kh+bKgk9ZIlmxMkEPnQZXtKc7/AyUyIS9jXgbKt4hWyxXEEZVDmXhiU2bh1zZpthMr/l09wz9z6CvfXtCWUJBA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.0-preview.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-m09/acXFGhlp+U6m7Wn0AqsmLqars3qI9eBXDpPJm4h/XVS9HPHNzWGy2BI7F1iLoFX59Uy0tcau9ey7JVud3w=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.0-preview.5", "", { "os": "linux", "cpu": "x64" }, "sha512-/EBiqRjLZJWJo6Keq9upJfhrP+tNpePy1beBfOL+tUn68inwNiJEjx+0Lgve99Zur8kSk9TgSmDmwgQxX4iM+g=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.0-preview.5", "", { "os": "linux", "cpu": "x64" }, "sha512-lUCiPFoecSGkM8aeY6UAgQDiJjR3DhPsI036mznlHFg89ZLoeRdo521N4nmk6EpbPpNzRujgiboBkbuyexDgCg=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.0-preview.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Nr4e3dRB86gElIgysL/L7dr9tuRLIq3looK8hLxnYDLUvLza2Tu/7Ik/X6DSRGejIrbZsYjnH3S4xYeAAf7Prw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0-preview.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Svg+VCRUbyNsBuh96hN+1ael8dNXqVQVZqOe9tqFlF4mUzIk5CQFcn5VsZPrz8GNP9HCxJfrfy3PM0cXoSXliw=="], "@noble/ed25519": ["@noble/ed25519@3.0.0", "", {}, "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg=="], @@ -2312,7 +2312,7 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@16.2.9", "", { "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.9", "@next/swc-darwin-x64": "16.2.9", "@next/swc-linux-arm64-gnu": "16.2.9", "@next/swc-linux-arm64-musl": "16.2.9", "@next/swc-linux-x64-gnu": "16.2.9", "@next/swc-linux-x64-musl": "16.2.9", "@next/swc-win32-arm64-msvc": "16.2.9", "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww=="], + "next": ["next@16.3.0-preview.5", "", { "dependencies": { "@next/env": "16.3.0-preview.5", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.10", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0-preview.5", "@next/swc-darwin-x64": "16.3.0-preview.5", "@next/swc-linux-arm64-gnu": "16.3.0-preview.5", "@next/swc-linux-arm64-musl": "16.3.0-preview.5", "@next/swc-linux-x64-gnu": "16.3.0-preview.5", "@next/swc-linux-x64-musl": "16.3.0-preview.5", "@next/swc-win32-arm64-msvc": "16.3.0-preview.5", "@next/swc-win32-x64-msvc": "16.3.0-preview.5", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-I5rVC4VcvAL1FPr6AY5WEQUSe6o1Bt0Oa/qH5hfPhci4FRMCPeAQ95tgxFOgJDk2wME1K009k0bjS17nQ0Bq1w=="], "next-sanity": ["next-sanity@13.1.1", "", { "dependencies": { "@portabletext/react": "^6.2.0", "@sanity/client": "^7.23.0", "@sanity/generate-help-url": "^4.0.0", "@sanity/preview-url-secret": "^4.0.7", "@sanity/visual-editing": "^5.4.4", "@sanity/webhook": "^4.0.4", "groq": "^6.1.0", "history": "^5.3.0" }, "peerDependencies": { "next": "^16.0.0-0", "react": "^19.2.3", "react-dom": "^19.2.3", "sanity": "^5.29.0 || ^6.0.0", "styled-components": "^6.1" } }, "sha512-Vdut98fj065Zbnfni+tsWiERSlH1XBmo3P8Dr3WKcEvWSfd0THU0M4nHazC8/T8aUlLyROGwMjf0rYz+I8iQOg=="], @@ -3328,7 +3328,7 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "next/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], "next-sanity/@sanity/client": ["@sanity/client@7.23.0", "", { "dependencies": { "@sanity/eventsource": "^5.0.2", "get-it": "^8.8.0", "nanoid": "^3.3.11", "rxjs": "^7.0.0" } }, "sha512-4VFcLeP/lD0lhe5TD102tSnoW72ERT6xD/ZLb9pdLNMbZgbOciAy3m0hAYvs1vjA6663ZjNM6SLwl1Utq97DHA=="], @@ -3858,8 +3858,6 @@ "next-sanity/@sanity/client/get-it": ["get-it@8.8.0", "", { "dependencies": { "decompress-response": "^7.0.0", "is-retry-allowed": "^2.2.0", "through2": "^4.0.2", "tunnel-agent": "^0.6.0" } }, "sha512-vRyooMBzoIdEbARGT3JcvWqMD67YzC5SKlMqofyfck1ebLh2zzlpD4hVQ3xiuuiADk4jNs0rTezVlxCMqOlZfA=="], - "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "ora/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "oxlint-plugin-react-doctor/oxc-parser/@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.132.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw=="], diff --git a/next.config.ts b/next.config.ts index 8a0ddce4..1f611d0b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -85,6 +85,9 @@ const nextConfig: NextConfig = { : false, }, cacheComponents: true, + // 16.3: shell-based prefetching — the top-level successor to the + // experimental.prefetchInlining stepping stone; requires cacheComponents: true. + partialPrefetching: true, compress: true, logging: { fetches: { @@ -95,7 +98,6 @@ const nextConfig: NextConfig = { experimental: { taint: true, cachedNavigations: true, - prefetchInlining: true, sri: { algorithm: 'sha384' }, optimizePackageImports: [ '@react-three/drei', diff --git a/package.json b/package.json index ac9a2887..d19968aa 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "gsap": "^3.15.0", "hamo": "1.0.0-dev.13", "lenis": "^1.3.23", - "next": "16.2.9", + "next": "16.3.0-preview.5", "next-sanity": "^13.1.1", "postprocessing": "^6.39.1", "react": "19.2.7", @@ -72,7 +72,7 @@ "@clack/prompts": "^1.6.0", "@csstools/postcss-global-data": "^4.0.0", "@happy-dom/global-registrator": "^20.10.6", - "@next/bundle-analyzer": "16.2.9", + "@next/bundle-analyzer": "16.3.0-preview.5", "@sanity/vision": "^6.1.0", "@storybook/addon-mcp": "^0.6.0", "@storybook/nextjs-vite": "^10.4.6", From b5be0d723fa45e9cfb59833a9e477cd8e5dc7d20 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Tue, 30 Jun 2026 13:20:25 -0300 Subject: [PATCH 02/22] test(next): add instant-nav demo routes and 16.3 turbopack build flags Throwaway, branch-only routes under app/instant-nav-demo/ (hub + fast/slow/[item]) to exercise 16.3 shell prefetching + partial streaming on the preview deploy. Also enables experimental turbopackFileSystemCacheForBuild and turbopackRustReactCompiler. Verified: build + typecheck + lint green on 16.3.0-preview.5. --- CHANGELOG.md | 2 +- app/instant-nav-demo/README.md | 16 +++++++ app/instant-nav-demo/[item]/loading.tsx | 16 +++++++ app/instant-nav-demo/[item]/page.tsx | 38 ++++++++++++++++ app/instant-nav-demo/fast/page.tsx | 28 ++++++++++++ app/instant-nav-demo/page.tsx | 53 ++++++++++++++++++++++ app/instant-nav-demo/slow/page.tsx | 58 +++++++++++++++++++++++++ next.config.ts | 5 +++ 8 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 app/instant-nav-demo/README.md create mode 100644 app/instant-nav-demo/[item]/loading.tsx create mode 100644 app/instant-nav-demo/[item]/page.tsx create mode 100644 app/instant-nav-demo/fast/page.tsx create mode 100644 app/instant-nav-demo/page.tsx create mode 100644 app/instant-nav-demo/slow/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e8dfb69b..0d3ba6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ latest tag; security fixes land on the latest release (see `SECURITY.md`). ### Changed -- Next.js 16.3 preview: bumped `next` to the `preview` dist-tag, enabled top-level `partialPrefetching: true` (the 16.3 successor to `experimental.prefetchInlining`), removed the now-superseded `experimental.prefetchInlining` flag from the `experimental` block, and replaced the placeholder `app/loading.tsx` with an accessible skeleton shell (`role="status"`, `aria-busy="true"`, `sr-only` label, `animate-pulse` bars) demonstrating the instant-navigation loading pattern. +- Next.js 16.3 preview: bumped `next` to the `preview` dist-tag, enabled top-level `partialPrefetching: true` (the 16.3 successor to `experimental.prefetchInlining`), removed the now-superseded `experimental.prefetchInlining` flag from the `experimental` block, and replaced the placeholder `app/loading.tsx` with an accessible skeleton shell (`role="status"`, `aria-busy="true"`, `sr-only` label, `animate-pulse` bars) demonstrating the instant-navigation loading pattern. Added throwaway, branch-only demo routes under `app/instant-nav-demo/` (hub + `fast`/`slow`/`[item]` routes exercising shell prefetch, partial streaming via `<Suspense>`/`connection()`, and a reused dynamic-route `loading.tsx` shell) so the preview deploy can actually test instant navigations; reachable at `/instant-nav-demo` and never merged to main. Also enabled the 16.3 Turbopack experimental flags `turbopackFileSystemCacheForBuild` (persistent build cache) and `turbopackRustReactCompiler` (native React Compiler, pairs with `reactCompiler: true`) to validate build-perf on the same preview. - Dependencies: bumped every package to its latest version. The only breaking change was `tempus` dev.18, whose `TempusCallback` moved from positional `(time, deltaTime)` arguments to a single `TempusState` object (`{ time, deltaTime, frame, budget }`) and deprecated the `priority` option in favor of `order` — migrated all seven call sites (the GSAP, Lenis, marquee, and WebGL RAF runtimes, the `lib/utils/raf` write queue, and the dev `Stats` overlay). Also bumped `@base-ui/react` 1.6, `next-sanity` 13.1, `@types/node` 26, `deslop-cli` 0.5.8, `@biomejs/biome` 2.5.1, Storybook 10.4.6, Tailwind 4.3.1, and `@typescript/native-preview`; `actions/checkout` 6 → 7 in CI. Supersedes #251–254. (#255) - Dependencies: bumped the Sanity toolchain to v6 (`sanity`, `@sanity/vision`, `groq` → `^6.1.0`) — Studio v6's breaking changes (Node 20 dropped, `auth.providers` replace semantics, `enableLegacySearch` removed) don't touch the integration's API surface, so forks on Node ≥ 22 update without code changes. Also bumped `@biomejs/biome` 2.5.0, `deslop-cli` 0.0.25, and `@typescript/native-preview`. Biome 2.5's newly-enabled rules required a `<title>` on `darkroom.svg` (`noSvgWithoutTitle`) and an import-spacing fix in generated `sanity.types.ts` (`organizeImports`); the inline version `<Script>` carries a documented `useInlineScriptId` ignore (the `id` is present — biome mis-detects JSX-children content). Supersedes #221–226. (#228) - Integration registry is single-source: `INTEGRATION_BUNDLES` keys are typed against `RemovableId` from `lib/integrations/registry`, `prepare-handoff` matches integrations by id, and `next.config.ts` cleanup runs through typed ts-morph AST ops — the regex `updateNextConfig` is deleted. diff --git a/app/instant-nav-demo/README.md b/app/instant-nav-demo/README.md new file mode 100644 index 00000000..c295889b --- /dev/null +++ b/app/instant-nav-demo/README.md @@ -0,0 +1,16 @@ +# instant-nav-demo — throwaway + +These routes exist ONLY on the `next/16.3-preview` branch to exercise Next.js +16.3 Instant Navigations (`partialPrefetching`) on a real deploy. They are NOT +starter content and must never merge to `main`. + +- `page.tsx` — hub linking to the demo routes (prefetch fires on hover/viewport) +- `fast/` — synchronous route, the prefetched shell is the whole page +- `slow/` — shell paints immediately; an inner `<Suspense>` panel streams in + after a delay (`connection()` + artificial wait) — partial prerendering +- `[item]/` — dynamic route that suspends at the top level, so its `loading.tsx` + shell is shown on the way in and reused identically for every param value + (`/instant-nav-demo/alpha`, `/bravo`, `/charlie`) + +Reach it on the preview at `/instant-nav-demo`. Delete this whole folder before +adopting anything from this branch. diff --git a/app/instant-nav-demo/[item]/loading.tsx b/app/instant-nav-demo/[item]/loading.tsx new file mode 100644 index 00000000..ee7e572d --- /dev/null +++ b/app/instant-nav-demo/[item]/loading.tsx @@ -0,0 +1,16 @@ +export default function Loading() { + return ( + <div + role="status" + aria-busy="true" + className="flex min-h-screen flex-col items-center justify-center gap-3 font-mono" + > + <span className="sr-only">Loading item</span> + <div className="w-40 animate-pulse space-y-2"> + <div className="h-2 rounded bg-current opacity-20" /> + <div className="h-2 w-3/4 rounded bg-current opacity-20" /> + <div className="h-2 w-1/2 rounded bg-current opacity-10" /> + </div> + </div> + ) +} diff --git a/app/instant-nav-demo/[item]/page.tsx b/app/instant-nav-demo/[item]/page.tsx new file mode 100644 index 00000000..2e0411de --- /dev/null +++ b/app/instant-nav-demo/[item]/page.tsx @@ -0,0 +1,38 @@ +import { connection } from 'next/server' +import { Wrapper } from '@/components/layout/wrapper' +import { Link } from '@/components/ui/link' + +export const metadata = { + title: 'Dynamic route — Instant Navigations demo', +} + +export default async function ItemPage({ + params, +}: { + params: Promise<{ item: string }> +}) { + const { item } = await params + await connection() + await new Promise((resolve) => setTimeout(resolve, 1200)) + + return ( + <Wrapper theme="dark"> + <section className="dr-layout-grid min-h-screen content-center py-24 font-mono"> + <div className="col-span-full dt:col-start-2 dt:col-end-11 flex flex-col gap-6"> + <h1 className="text-2xl uppercase">Dynamic route: {item}</h1> + <p className="max-w-prose opacity-70"> + One reusable shell is prefetched for this route and shown on the way + in (see the skeleton). It is identical for every param value — + <code> alpha</code>, <code>bravo</code>, and <code>charlie</code>. + </p> + <Link + className="underline underline-offset-4" + href="/instant-nav-demo" + > + ← Back to demo hub + </Link> + </div> + </section> + </Wrapper> + ) +} diff --git a/app/instant-nav-demo/fast/page.tsx b/app/instant-nav-demo/fast/page.tsx new file mode 100644 index 00000000..af8ddb4c --- /dev/null +++ b/app/instant-nav-demo/fast/page.tsx @@ -0,0 +1,28 @@ +import { Wrapper } from '@/components/layout/wrapper' +import { Link } from '@/components/ui/link' + +export const metadata = { + title: 'Fast route — Instant Navigations demo', +} + +export default function FastPage() { + return ( + <Wrapper theme="dark"> + <section className="dr-layout-grid min-h-screen content-center py-24 font-mono"> + <div className="col-span-full dt:col-start-2 dt:col-end-11 flex flex-col gap-6"> + <h1 className="text-2xl uppercase">Fast route</h1> + <p className="max-w-prose opacity-70"> + Fully static — no async data. The prefetched shell IS the page, so + navigation here is immediate with nothing to stream. + </p> + <Link + className="underline underline-offset-4" + href="/instant-nav-demo" + > + ← Back to demo hub + </Link> + </div> + </section> + </Wrapper> + ) +} diff --git a/app/instant-nav-demo/page.tsx b/app/instant-nav-demo/page.tsx new file mode 100644 index 00000000..f75469a0 --- /dev/null +++ b/app/instant-nav-demo/page.tsx @@ -0,0 +1,53 @@ +import { Wrapper } from '@/components/layout/wrapper' +import { Link } from '@/components/ui/link' + +export const metadata = { + title: 'Instant Navigations demo', +} + +const ITEMS = ['alpha', 'bravo', 'charlie'] + +export default function InstantNavDemoPage() { + return ( + <Wrapper theme="dark"> + <section className="dr-layout-grid min-h-screen content-center py-24 font-mono"> + <div className="col-span-full dt:col-start-2 dt:col-end-11 flex flex-col gap-6"> + <h1 className="text-2xl uppercase">Instant Navigations demo</h1> + <p className="max-w-prose opacity-70"> + Throwaway routes for exercising Next.js 16.3 shell-based + prefetching. Hover a link to prefetch its shell, then click to see + the shell paint instantly while data streams in. + </p> + + <nav className="flex flex-col gap-3"> + <Link + className="underline underline-offset-4" + href="/instant-nav-demo/fast" + > + → Fast route (synchronous, paints immediately) + </Link> + <Link + className="underline underline-offset-4" + href="/instant-nav-demo/slow" + > + → Slow route (shell first, panel streams in) + </Link> + {ITEMS.map((item) => ( + <Link + key={item} + className="underline underline-offset-4" + href={`/instant-nav-demo/${item}`} + > + → Dynamic route: {item} (one shell, reused per param) + </Link> + ))} + </nav> + + <Link className="underline underline-offset-4 opacity-60" href="/"> + ← Home + </Link> + </div> + </section> + </Wrapper> + ) +} diff --git a/app/instant-nav-demo/slow/page.tsx b/app/instant-nav-demo/slow/page.tsx new file mode 100644 index 00000000..de3c5f33 --- /dev/null +++ b/app/instant-nav-demo/slow/page.tsx @@ -0,0 +1,58 @@ +import { connection } from 'next/server' +import { Suspense } from 'react' +import { Wrapper } from '@/components/layout/wrapper' +import { Link } from '@/components/ui/link' + +export const metadata = { + title: 'Slow route — Instant Navigations demo', +} + +// connection() marks this subtree runtime-dynamic; the artificial delay makes +// the stream visible. Lives inside <Suspense> so the page shell paints +// immediately and only this panel streams in (partial prerendering under +// cacheComponents). +async function StreamedPanel() { + await connection() + await new Promise((resolve) => setTimeout(resolve, 1500)) + return ( + <p className="max-w-prose opacity-90"> + Streamed in after ~1.5s. The heading and back-link above were already on + screen — only this panel waited on the server. + </p> + ) +} + +function PanelSkeleton() { + return ( + <div aria-hidden className="w-40 animate-pulse space-y-2"> + <div className="h-2 rounded bg-current opacity-20" /> + <div className="h-2 w-3/4 rounded bg-current opacity-20" /> + <div className="h-2 w-1/2 rounded bg-current opacity-10" /> + </div> + ) +} + +export default function SlowPage() { + return ( + <Wrapper theme="dark"> + <section className="dr-layout-grid min-h-screen content-center py-24 font-mono"> + <div className="col-span-full dt:col-start-2 dt:col-end-11 flex flex-col gap-6"> + <h1 className="text-2xl uppercase">Slow route</h1> + <p className="max-w-prose opacity-70"> + The shell (this heading and the link below) paints instantly. The + panel streams in once the server resolves. + </p> + <Suspense fallback={<PanelSkeleton />}> + <StreamedPanel /> + </Suspense> + <Link + className="underline underline-offset-4" + href="/instant-nav-demo" + > + ← Back to demo hub + </Link> + </div> + </section> + </Wrapper> + ) +} diff --git a/next.config.ts b/next.config.ts index 1f611d0b..75394ea7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -98,6 +98,11 @@ const nextConfig: NextConfig = { experimental: { taint: true, cachedNavigations: true, + // 16.3 preview: persistent Turbopack FS cache for `next build` (faster + // cached CI/Vercel builds) + the native Rust port of the React Compiler + // (pairs with the top-level `reactCompiler: true`). Both experimental. + turbopackFileSystemCacheForBuild: true, + turbopackRustReactCompiler: true, sri: { algorithm: 'sha384' }, optimizePackageImports: [ '@react-three/drei', From 601e92cbc221a3449afea1c7193c8418b02f3e0a Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 22 Jul 2026 16:48:33 -0300 Subject: [PATCH 03/22] fix(form): use SubmitEvent instead of the deprecated FormEvent React's own types now document FormEvent as deprecated and point at SubmitEvent, and DOMAttributes already types onSubmit as SubmitEventHandler, so this matches what React expects today rather than only future-proofing. React 19.2.10 turns it into a deprecation warning. --- components/ui/form/hook.ts | 4 ++-- components/ui/form/types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/ui/form/hook.ts b/components/ui/form/hook.ts index 5422ee4b..e2d37098 100644 --- a/components/ui/form/hook.ts +++ b/components/ui/form/hook.ts @@ -1,7 +1,7 @@ import { type ChangeEventHandler, type FocusEventHandler, - type FormEvent, + type SubmitEvent, useActionState, useRef, useState, @@ -93,7 +93,7 @@ export function useForm<T = unknown>({ Object.values(isValid).every(Boolean) && Object.values(errors).every(({ state }) => !state) - const onSubmit = (event: FormEvent<HTMLFormElement>) => { + const onSubmit = (event: SubmitEvent<HTMLFormElement>) => { event.preventDefault() // Enter-to-submit must respect the same gate as the SubmitButton. diff --git a/components/ui/form/types.ts b/components/ui/form/types.ts index c64241be..f6dc7351 100644 --- a/components/ui/form/types.ts +++ b/components/ui/form/types.ts @@ -25,7 +25,7 @@ export interface UseFormOptions<T = unknown> { export interface UseFormReturn<T = unknown> { formState: FormState<T> | null formAction: (formData: FormData) => void - onSubmit: (event: React.FormEvent<HTMLFormElement>) => void + onSubmit: (event: React.SubmitEvent<HTMLFormElement>) => void register: (name: string) => { ref: (node: HTMLInputElement | HTMLTextAreaElement | null) => void onChange: ( From c9bf700c830251e6ebb371f7008d32d218ad4682 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 22 Jul 2026 16:48:34 -0300 Subject: [PATCH 04/22] chore: track next@preview.8 Picks up the client-navigation <title> regression fix that landed in canary.76. --- bun.lock | 80 +++++++++++++++++++++++++++------------------------- package.json | 4 +-- 2 files changed, 44 insertions(+), 40 deletions(-) diff --git a/bun.lock b/bun.lock index 619feb18..521ab9c8 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "gsap": "^3.15.0", "hamo": "1.0.0-dev.13", "lenis": "^1.3.25", - "next": "16.3.0-preview.6", + "next": "16.3.0-preview.8", "next-sanity": "^13.1.4", "postprocessing": "^6.39.3", "react": "19.2.8", @@ -35,7 +35,7 @@ "@clack/prompts": "^1.7.0", "@csstools/postcss-global-data": "^4.0.0", "@happy-dom/global-registrator": "^20.11.0", - "@next/bundle-analyzer": "16.3.0-preview.6", + "@next/bundle-analyzer": "16.3.0-preview.8", "@playwright/test": "1.61.1", "@sanity/vision": "^6.5.0", "@storybook/addon-mcp": "^0.7.0", @@ -613,53 +613,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], @@ -755,25 +759,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@next/bundle-analyzer": ["@next/bundle-analyzer@16.3.0-preview.6", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-HyrYQpTUWqtFuMWlHZzTm2LieNpr5dwr5d2DHCfEThEzt0Jld4fNDWGl/N0i/zH4Gf2Ixgv4It9BRs6cKkmYNw=="], + "@next/bundle-analyzer": ["@next/bundle-analyzer@16.3.0-preview.8", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-NPEoHzPjeblMklMbo1Epy/N4Sd48bWw8dTrBbeorrPolNnT0hCYfC1G8cLCOrRo24q0gk/XkT0qMpQViONThIg=="], - "@next/env": ["@next/env@16.3.0-preview.6", "", {}, "sha512-tdsih48yzumsL060VIsZ9BwDYlZk0AAMT8HccspSbPUzp6ntCZ9xOMFU4W0SmqaOCEpPmTyP+5e6WT36mqN2fw=="], + "@next/env": ["@next/env@16.3.0-preview.8", "", {}, "sha512-OUjzx/+GzS6FwtpBVSA2Y5U+XqWBcSQwXr/kJrTrdHcwoQFVATof0RSBFj5cjBy9rMwezGkCL6/HjO2WdO+dQA=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.0-preview.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cQB2whnW1PD/Q+AxoI2X4LU3af1B+aFxWx+kmAiCCKDyjoO5Nh+9GyNu+nDAbP/WoloohbOWgO8m1U9j1EIZuw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.0-preview.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zCzL322QKv1xIPV+2atT75awK9YgceZllw2QWGTqW9nDZ/nuaVaRdWVuh3+Uy+nPnDawfBv3Mn9ANEMVh6QSEg=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.0-preview.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ctrzjav21HlHwMV6afeC9/PePUdyJ+AXDQNfrrqdkIEdem9f3vsBVKtuB6gcCNT4umtql7ZLB1qaQObVqKVp5g=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.0-preview.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vbnekjyb60VcypFxzewH7I2bNY7ajSf811bxoJpJ0eV6ASQh+5yp99aOAsTXtVrmd/WEGdtW/qVaIKQN26lHAw=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.0-preview.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-V2o4V3ghCDwZ0K3BI2ts/+zDaLI1Y28Q/Bs53K3Zqk4/3M8yBTImXX7XL6BVOihncvSl8QUG3c32/W6AlgEsJA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.0-preview.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-ErpN+467Gom0TXEz1MLN9iM0vOmERiz9kQu5adCXuFlPGKd4qhDqq9JHVcA+v9zDLCL/CYVxdTwK4H39UTDTig=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.0-preview.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-btfR0TMj4RvscjYW7QvRQHstXas2969M4TBaQW98BFBLyuD8R0RFwmxYxep/aDu+xkKzfDvLsBFYcYX0ucsIHQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.0-preview.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YIeE8mteSbOOmFWGVoc+ls373pQ+Sj6bGkEFZlMvU37xSaP4cw30M9r3wbCh0TiKHdlV3HwuaTeykEM3eAh3w=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.0-preview.6", "", { "os": "linux", "cpu": "x64" }, "sha512-arKlC6NTJ43NjhH+yVwEChLSWLWRtuUQPHYkuoKBK62hyL9rnMhNOXUWcQdvvhQ75UBqIDo7dpkfpOlizLClGw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.0-preview.8", "", { "os": "linux", "cpu": "x64" }, "sha512-MBvZ/lXxG00C69hIv58gWq0CuabFRl1sxud6YHuBYouqkdPWDA3dgfFxv1WWxD1U4I70H+CH3Hpp/niaGmcqGg=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.0-preview.6", "", { "os": "linux", "cpu": "x64" }, "sha512-ETrNCY3R1FJpOmh6I3pM+EbNA5kTTxQsCxVJTvKE9QN6+AxC6H4Ybu9F26WBseZT7zOFawlujWl8P6xo1hbgDQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.0-preview.8", "", { "os": "linux", "cpu": "x64" }, "sha512-OpOhuV8Ujvay6dSTLJaoQk+t3MMRx1EidH3dpL8iezvuVsrFDHrsKxSVZaouqDgg8v9Res43AisI3EiKv6LESw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.0-preview.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-6N92Tg9ImGX3anKiUu1EQYcFRXvrteL2+U/cQ863SMX9Huh8+/o3jIyCni15+NJD02Va7KQIAovURMd+EkaSOA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.0-preview.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-c+rZGOvBT+0D3sthh8kgxfQToJA0p0athbuDDm21WuszFtm7TpPa23a+H4J0x4d9ehrE19O+2d2M9PoB17N6wA=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0-preview.6", "", { "os": "win32", "cpu": "x64" }, "sha512-DR6uqZI+ytCOg+F3MouQKjWwEI1I7+cAuunKtI0V3mEHdBGUmhh0t60+rrKhh+h24bvEfVr5UrH/aljAV9L0rw=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0-preview.8", "", { "os": "win32", "cpu": "x64" }, "sha512-M4s+BypigoOg3mW9ZOXmyrLUaFjetqPHqdqMn7MU+AiawP/gVL7cbwp4aWP2EeObMyYlYtLMKiLyR9sYYSAY9w=="], "@noble/ed25519": ["@noble/ed25519@3.1.0", "", {}, "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g=="], @@ -2293,7 +2297,7 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@16.3.0-preview.6", "", { "dependencies": { "@next/env": "16.3.0-preview.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.10", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0-preview.6", "@next/swc-darwin-x64": "16.3.0-preview.6", "@next/swc-linux-arm64-gnu": "16.3.0-preview.6", "@next/swc-linux-arm64-musl": "16.3.0-preview.6", "@next/swc-linux-x64-gnu": "16.3.0-preview.6", "@next/swc-linux-x64-musl": "16.3.0-preview.6", "@next/swc-win32-arm64-msvc": "16.3.0-preview.6", "@next/swc-win32-x64-msvc": "16.3.0-preview.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-mNUH6FJz/oB0htRV2wBPeXXU+bT3+ZCrTtGkZe1YTRdV0qmsx3P8ajMAKfVQI/dTQ/Me9IOAVjfEHfeoFGmPtQ=="], + "next": ["next@16.3.0-preview.8", "", { "dependencies": { "@next/env": "16.3.0-preview.8", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.10", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0-preview.8", "@next/swc-darwin-x64": "16.3.0-preview.8", "@next/swc-linux-arm64-gnu": "16.3.0-preview.8", "@next/swc-linux-arm64-musl": "16.3.0-preview.8", "@next/swc-linux-x64-gnu": "16.3.0-preview.8", "@next/swc-linux-x64-musl": "16.3.0-preview.8", "@next/swc-win32-arm64-msvc": "16.3.0-preview.8", "@next/swc-win32-x64-msvc": "16.3.0-preview.8", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-c9CF1GAZnpB7iwZWCnPdSPfdrmGkvr+lGqcRuH++EIYFxb4Mk6EIve13ExuO4ghbhayQVGyP+hY8BocnQ/aHbA=="], "next-sanity": ["next-sanity@13.1.7", "", { "dependencies": { "@portabletext/react": "^6.2.0", "@sanity/client": "^7.24.0", "@sanity/generate-help-url": "^4.0.0", "@sanity/preview-url-secret": "^4.1.1", "@sanity/visual-editing": "^5.7.1", "@sanity/webhook": "^4.0.4", "groq": "^6.6.0", "history": "^5.3.0" }, "peerDependencies": { "next": "^16.0.0-0", "react": "^19.2.3", "react-dom": "^19.2.3", "sanity": "^5.29.0 || ^6.0.0", "styled-components": "^6.1" } }, "sha512-i/QdYS9EnECYyUv1YjKFvLafrYa9OM6sDxm9GfgBO58HGyh9a7IdY2jdoIk9RFQRMZwJUi4goqDhBb+sg/MGKQ=="], @@ -2631,7 +2635,7 @@ "shallow-equals": ["shallow-equals@1.0.0", "", {}, "sha512-xd/FKcdmfmMbyYCca3QTVEJtqUOGuajNzvAX6nt8dXILwjAIEkfHc4hI8/JMGApAmb7VeULO0Q30NTxnbH/15g=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], diff --git a/package.json b/package.json index 78863672..5130926a 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "gsap": "^3.15.0", "hamo": "1.0.0-dev.13", "lenis": "^1.3.25", - "next": "16.3.0-preview.6", + "next": "16.3.0-preview.8", "next-sanity": "^13.1.4", "postprocessing": "^6.39.3", "react": "19.2.8", @@ -77,7 +77,7 @@ "@clack/prompts": "^1.7.0", "@csstools/postcss-global-data": "^4.0.0", "@happy-dom/global-registrator": "^20.11.0", - "@next/bundle-analyzer": "16.3.0-preview.6", + "@next/bundle-analyzer": "16.3.0-preview.8", "@playwright/test": "1.61.1", "@sanity/vision": "^6.5.0", "@storybook/addon-mcp": "^0.7.0", From 6be693405e28ee8e4aa6edab662f6f5aa03b6a40 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 22 Jul 2026 16:53:01 -0300 Subject: [PATCH 05/22] fix(demo): use min-h-dvh in the instant-nav demo routes AGENTS.md calls for dvh over screen units; 100vh leaves the section short by the browser chrome on mobile. Five demo routes were still on min-h-screen. --- app/instant-nav-demo/[item]/loading.tsx | 2 +- app/instant-nav-demo/[item]/page.tsx | 2 +- app/instant-nav-demo/fast/page.tsx | 2 +- app/instant-nav-demo/page.tsx | 2 +- app/instant-nav-demo/slow/page.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/instant-nav-demo/[item]/loading.tsx b/app/instant-nav-demo/[item]/loading.tsx index ee7e572d..8dc2281d 100644 --- a/app/instant-nav-demo/[item]/loading.tsx +++ b/app/instant-nav-demo/[item]/loading.tsx @@ -3,7 +3,7 @@ export default function Loading() { <div role="status" aria-busy="true" - className="flex min-h-screen flex-col items-center justify-center gap-3 font-mono" + className="flex min-h-dvh flex-col items-center justify-center gap-3 font-mono" > <span className="sr-only">Loading item</span> <div className="w-40 animate-pulse space-y-2"> diff --git a/app/instant-nav-demo/[item]/page.tsx b/app/instant-nav-demo/[item]/page.tsx index 2e0411de..0b23f968 100644 --- a/app/instant-nav-demo/[item]/page.tsx +++ b/app/instant-nav-demo/[item]/page.tsx @@ -17,7 +17,7 @@ export default async function ItemPage({ return ( <Wrapper theme="dark"> - <section className="dr-layout-grid min-h-screen content-center py-24 font-mono"> + <section className="dr-layout-grid min-h-dvh content-center py-24 font-mono"> <div className="col-span-full dt:col-start-2 dt:col-end-11 flex flex-col gap-6"> <h1 className="text-2xl uppercase">Dynamic route: {item}</h1> <p className="max-w-prose opacity-70"> diff --git a/app/instant-nav-demo/fast/page.tsx b/app/instant-nav-demo/fast/page.tsx index af8ddb4c..08ebf5a3 100644 --- a/app/instant-nav-demo/fast/page.tsx +++ b/app/instant-nav-demo/fast/page.tsx @@ -8,7 +8,7 @@ export const metadata = { export default function FastPage() { return ( <Wrapper theme="dark"> - <section className="dr-layout-grid min-h-screen content-center py-24 font-mono"> + <section className="dr-layout-grid min-h-dvh content-center py-24 font-mono"> <div className="col-span-full dt:col-start-2 dt:col-end-11 flex flex-col gap-6"> <h1 className="text-2xl uppercase">Fast route</h1> <p className="max-w-prose opacity-70"> diff --git a/app/instant-nav-demo/page.tsx b/app/instant-nav-demo/page.tsx index f75469a0..7b04dec3 100644 --- a/app/instant-nav-demo/page.tsx +++ b/app/instant-nav-demo/page.tsx @@ -10,7 +10,7 @@ const ITEMS = ['alpha', 'bravo', 'charlie'] export default function InstantNavDemoPage() { return ( <Wrapper theme="dark"> - <section className="dr-layout-grid min-h-screen content-center py-24 font-mono"> + <section className="dr-layout-grid min-h-dvh content-center py-24 font-mono"> <div className="col-span-full dt:col-start-2 dt:col-end-11 flex flex-col gap-6"> <h1 className="text-2xl uppercase">Instant Navigations demo</h1> <p className="max-w-prose opacity-70"> diff --git a/app/instant-nav-demo/slow/page.tsx b/app/instant-nav-demo/slow/page.tsx index de3c5f33..e57b9c28 100644 --- a/app/instant-nav-demo/slow/page.tsx +++ b/app/instant-nav-demo/slow/page.tsx @@ -35,7 +35,7 @@ function PanelSkeleton() { export default function SlowPage() { return ( <Wrapper theme="dark"> - <section className="dr-layout-grid min-h-screen content-center py-24 font-mono"> + <section className="dr-layout-grid min-h-dvh content-center py-24 font-mono"> <div className="col-span-full dt:col-start-2 dt:col-end-11 flex flex-col gap-6"> <h1 className="text-2xl uppercase">Slow route</h1> <p className="max-w-prose opacity-70"> From 3b5f66a6e8679bbd9ad47b03e6e39d4e41686c97 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 22 Jul 2026 17:07:37 -0300 Subject: [PATCH 06/22] docs(demo): spell out the dynamic route example paths in full --- app/instant-nav-demo/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/instant-nav-demo/README.md b/app/instant-nav-demo/README.md index c295889b..38051084 100644 --- a/app/instant-nav-demo/README.md +++ b/app/instant-nav-demo/README.md @@ -10,7 +10,8 @@ starter content and must never merge to `main`. after a delay (`connection()` + artificial wait) — partial prerendering - `[item]/` — dynamic route that suspends at the top level, so its `loading.tsx` shell is shown on the way in and reused identically for every param value - (`/instant-nav-demo/alpha`, `/bravo`, `/charlie`) + (`/instant-nav-demo/alpha`, `/instant-nav-demo/bravo`, + `/instant-nav-demo/charlie`) Reach it on the preview at `/instant-nav-demo`. Delete this whole folder before adopting anything from this branch. From 63fa53d9ffdc5784abe254c1fd1ea7d224eb2a2f Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 22 Jul 2026 17:18:01 -0300 Subject: [PATCH 07/22] fix(demo): announce the streamed panel's loading state, drop stray code spacing The panel skeleton was aria-hidden wholesale, so assistive tech got no signal that content was loading; the wrapper is now role=status with an sr-only label, and only the visual bars stay hidden. Also moves a JSX-whitespace workaround out of a code element. --- app/instant-nav-demo/[item]/page.tsx | 4 ++-- app/instant-nav-demo/slow/page.tsx | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/instant-nav-demo/[item]/page.tsx b/app/instant-nav-demo/[item]/page.tsx index 0b23f968..68c80d35 100644 --- a/app/instant-nav-demo/[item]/page.tsx +++ b/app/instant-nav-demo/[item]/page.tsx @@ -22,8 +22,8 @@ export default async function ItemPage({ <h1 className="text-2xl uppercase">Dynamic route: {item}</h1> <p className="max-w-prose opacity-70"> One reusable shell is prefetched for this route and shown on the way - in (see the skeleton). It is identical for every param value — - <code> alpha</code>, <code>bravo</code>, and <code>charlie</code>. + in (see the skeleton). It is identical for every param value —{' '} + <code>alpha</code>, <code>bravo</code>, and <code>charlie</code>. </p> <Link className="underline underline-offset-4" diff --git a/app/instant-nav-demo/slow/page.tsx b/app/instant-nav-demo/slow/page.tsx index e57b9c28..f9684dbd 100644 --- a/app/instant-nav-demo/slow/page.tsx +++ b/app/instant-nav-demo/slow/page.tsx @@ -24,10 +24,13 @@ async function StreamedPanel() { function PanelSkeleton() { return ( - <div aria-hidden className="w-40 animate-pulse space-y-2"> - <div className="h-2 rounded bg-current opacity-20" /> - <div className="h-2 w-3/4 rounded bg-current opacity-20" /> - <div className="h-2 w-1/2 rounded bg-current opacity-10" /> + <div role="status" aria-busy="true" className="w-40"> + <span className="sr-only">Loading panel</span> + <div aria-hidden className="animate-pulse space-y-2"> + <div className="h-2 rounded bg-current opacity-20" /> + <div className="h-2 w-3/4 rounded bg-current opacity-20" /> + <div className="h-2 w-1/2 rounded bg-current opacity-10" /> + </div> </div> ) } From ac7de99032a43ac0df84152e1d139089e895090f Mon Sep 17 00:00:00 2001 From: Franco Arza <franco@darkroom.engineering> Date: Wed, 22 Jul 2026 17:07:35 -0300 Subject: [PATCH 08/22] fix: make the prepare script safe in linked worktrees (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lefthook install fails in a git worktree because the shared repo config carries core.hooksPath pointing at the main checkout's .git/hooks, which lefthook refuses to install into — failing every bun install in a worktree. The hooks in that shared path already apply to worktrees, so there is nothing to install there: detect a linked worktree (git-dir differs from git-common-dir) and skip. Also skips when there is no git repo at all, and otherwise preserves lefthook's exit code. --- lib/scripts/prepare.ts | 40 ++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 lib/scripts/prepare.ts diff --git a/lib/scripts/prepare.ts b/lib/scripts/prepare.ts new file mode 100644 index 00000000..dcd074bb --- /dev/null +++ b/lib/scripts/prepare.ts @@ -0,0 +1,40 @@ +/** + * `prepare` entry point — installs the lefthook git hooks. + * + * In a linked worktree (`git worktree add`), the repo config is shared with the + * main checkout and `core.hooksPath` already points at its `.git/hooks`, so the + * hooks apply here without installing anything — and `lefthook install` refuses + * to run against that path and fails the whole `bun install`. Skip it instead. + * Outside a git repo (exported archive, CI cache restore) there is nothing to + * install into, so that skips too. + */ + +import { resolve } from 'node:path' + +const git = Bun.spawnSync([ + 'git', + 'rev-parse', + '--absolute-git-dir', + '--git-common-dir', +]) + +if (git.exitCode !== 0) { + console.log('prepare: not a git repository, skipping lefthook install') + process.exit(0) +} + +const [gitDir, commonDir] = git.stdout.toString().trim().split('\n') + +if (gitDir && commonDir && gitDir !== resolve(commonDir)) { + console.log( + 'prepare: linked worktree, hooks are shared with the main checkout — skipping lefthook install' + ) + process.exit(0) +} + +const lefthook = Bun.spawnSync(['bunx', 'lefthook', 'install'], { + stdout: 'inherit', + stderr: 'inherit', +}) + +process.exit(lefthook.exitCode ?? 1) diff --git a/package.json b/package.json index 5130926a..2e08f327 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build": "bun run setup:styles && next build", "start": "next start", "preview": "bun run build && next start", - "prepare": "lefthook install", + "prepare": "bun ./lib/scripts/prepare.ts", "check": "biome check && bun node_modules/typescript7/bin/tsc --noEmit && bun test && bun run manifest:check", "clean": "rm -rf .next node_modules/.cache", "lint": "biome lint --max-diagnostics=200", From 3402c6f3cd1d111eadc8174e0176c659e6d5e931 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Thu, 23 Jul 2026 13:57:24 -0300 Subject: [PATCH 09/22] chore: track next@preview.9 --- bun.lock | 26 +++++++++++++------------- package.json | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index 521ab9c8..04072f39 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "gsap": "^3.15.0", "hamo": "1.0.0-dev.13", "lenis": "^1.3.25", - "next": "16.3.0-preview.8", + "next": "16.3.0-preview.9", "next-sanity": "^13.1.4", "postprocessing": "^6.39.3", "react": "19.2.8", @@ -35,7 +35,7 @@ "@clack/prompts": "^1.7.0", "@csstools/postcss-global-data": "^4.0.0", "@happy-dom/global-registrator": "^20.11.0", - "@next/bundle-analyzer": "16.3.0-preview.8", + "@next/bundle-analyzer": "16.3.0-preview.9", "@playwright/test": "1.61.1", "@sanity/vision": "^6.5.0", "@storybook/addon-mcp": "^0.7.0", @@ -759,25 +759,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@next/bundle-analyzer": ["@next/bundle-analyzer@16.3.0-preview.8", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-NPEoHzPjeblMklMbo1Epy/N4Sd48bWw8dTrBbeorrPolNnT0hCYfC1G8cLCOrRo24q0gk/XkT0qMpQViONThIg=="], + "@next/bundle-analyzer": ["@next/bundle-analyzer@16.3.0-preview.9", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-nxZ22v4rfQXkvYlfCtwNVlPnQNXg/v31THUpulJl7ymx5DJ0x1bZg4VPM6/E4Z3HyRonX5/NSoz7OtfxzHpLiw=="], - "@next/env": ["@next/env@16.3.0-preview.8", "", {}, "sha512-OUjzx/+GzS6FwtpBVSA2Y5U+XqWBcSQwXr/kJrTrdHcwoQFVATof0RSBFj5cjBy9rMwezGkCL6/HjO2WdO+dQA=="], + "@next/env": ["@next/env@16.3.0-preview.9", "", {}, "sha512-BcD+/sVDfoHckHcEx6e4RaLfWrUb+DpXgL9jOc6f29RTyvNwVVtnAaPljp3ok4zFQan6RhFE7q32lSXAFjjV9A=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.0-preview.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zCzL322QKv1xIPV+2atT75awK9YgceZllw2QWGTqW9nDZ/nuaVaRdWVuh3+Uy+nPnDawfBv3Mn9ANEMVh6QSEg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.0-preview.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-42OV/jDhHoKuqq+kp0RYrW1uksykLhXQeX+zx4SYFEG45ec9HnLHnsGgG1vy5W+LaSDvj3BPMBPTKMCh1vl1Ew=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.0-preview.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vbnekjyb60VcypFxzewH7I2bNY7ajSf811bxoJpJ0eV6ASQh+5yp99aOAsTXtVrmd/WEGdtW/qVaIKQN26lHAw=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.0-preview.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-j6dvZHjhjB3l+uDQiBmuajzzCyCetWdQw+eouwR0mE455b+OvPY32dUNIY+D33oTOe3f0My9wSw4Um5hxywUlA=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.0-preview.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-ErpN+467Gom0TXEz1MLN9iM0vOmERiz9kQu5adCXuFlPGKd4qhDqq9JHVcA+v9zDLCL/CYVxdTwK4H39UTDTig=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.0-preview.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2+1WzP2+ngzCkXiyzBwowgBiYaUycjAB/9b5ah5TjP0icBHOHf7oaPnjSZlZ8iq6x2G6QuR9S840jkOKNInUCw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.0-preview.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YIeE8mteSbOOmFWGVoc+ls373pQ+Sj6bGkEFZlMvU37xSaP4cw30M9r3wbCh0TiKHdlV3HwuaTeykEM3eAh3w=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.0-preview.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2Tq6H0LtObGQj2s7u56TYakTJ3NmDOWnNRaT2TI17oQEUfPv3O4a22jt4xIDVkxvaYfB2L4y2hl8DGAEw1WV6g=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.0-preview.8", "", { "os": "linux", "cpu": "x64" }, "sha512-MBvZ/lXxG00C69hIv58gWq0CuabFRl1sxud6YHuBYouqkdPWDA3dgfFxv1WWxD1U4I70H+CH3Hpp/niaGmcqGg=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.0-preview.9", "", { "os": "linux", "cpu": "x64" }, "sha512-H2bCT2w2H/W6xRLRw4oWgOEMIyWc1tn65c46u0zC60omKig9hPWj7vrhMBqwOMTmZcFZwNqBTpJTF0LuopEfHA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.0-preview.8", "", { "os": "linux", "cpu": "x64" }, "sha512-OpOhuV8Ujvay6dSTLJaoQk+t3MMRx1EidH3dpL8iezvuVsrFDHrsKxSVZaouqDgg8v9Res43AisI3EiKv6LESw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.0-preview.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WK0CI8ky1oDGwqzi8oJjtC5rfMNOUx4DMBiVr1o9mvsMwkCUkVh7n6+AAx2Qxk8llRJxpaGlBZv1fhHytsENVw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.0-preview.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-c+rZGOvBT+0D3sthh8kgxfQToJA0p0athbuDDm21WuszFtm7TpPa23a+H4J0x4d9ehrE19O+2d2M9PoB17N6wA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.0-preview.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-COFwXsMQsStyBv4qBn6E3tKHuPVifMkpVvF2k2/I0HsLAwuelgj+5uO8jS4e+sfTcdvczdvuiG8BjJz3aAa0IQ=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0-preview.8", "", { "os": "win32", "cpu": "x64" }, "sha512-M4s+BypigoOg3mW9ZOXmyrLUaFjetqPHqdqMn7MU+AiawP/gVL7cbwp4aWP2EeObMyYlYtLMKiLyR9sYYSAY9w=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.0-preview.9", "", { "os": "win32", "cpu": "x64" }, "sha512-RG4rEpg3ynGwTP6mnsDZHLuFbKksXkQCd9A5MDkgPY9+wYawIrc23pJYHZxyBxisuV/t+dwCgbpZoclW8icE7A=="], "@noble/ed25519": ["@noble/ed25519@3.1.0", "", {}, "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g=="], @@ -2297,7 +2297,7 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next": ["next@16.3.0-preview.8", "", { "dependencies": { "@next/env": "16.3.0-preview.8", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.10", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0-preview.8", "@next/swc-darwin-x64": "16.3.0-preview.8", "@next/swc-linux-arm64-gnu": "16.3.0-preview.8", "@next/swc-linux-arm64-musl": "16.3.0-preview.8", "@next/swc-linux-x64-gnu": "16.3.0-preview.8", "@next/swc-linux-x64-musl": "16.3.0-preview.8", "@next/swc-win32-arm64-msvc": "16.3.0-preview.8", "@next/swc-win32-x64-msvc": "16.3.0-preview.8", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-c9CF1GAZnpB7iwZWCnPdSPfdrmGkvr+lGqcRuH++EIYFxb4Mk6EIve13ExuO4ghbhayQVGyP+hY8BocnQ/aHbA=="], + "next": ["next@16.3.0-preview.9", "", { "dependencies": { "@next/env": "16.3.0-preview.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.10", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0-preview.9", "@next/swc-darwin-x64": "16.3.0-preview.9", "@next/swc-linux-arm64-gnu": "16.3.0-preview.9", "@next/swc-linux-arm64-musl": "16.3.0-preview.9", "@next/swc-linux-x64-gnu": "16.3.0-preview.9", "@next/swc-linux-x64-musl": "16.3.0-preview.9", "@next/swc-win32-arm64-msvc": "16.3.0-preview.9", "@next/swc-win32-x64-msvc": "16.3.0-preview.9", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-ruev+K1No0Cm/05hSrgKLtzaBdUIT+2I/Bj8x+K8/vk3j/dW+9YJOqGdDEkqNBOXtQlLxuEgeTH71jgsKTfWiQ=="], "next-sanity": ["next-sanity@13.1.7", "", { "dependencies": { "@portabletext/react": "^6.2.0", "@sanity/client": "^7.24.0", "@sanity/generate-help-url": "^4.0.0", "@sanity/preview-url-secret": "^4.1.1", "@sanity/visual-editing": "^5.7.1", "@sanity/webhook": "^4.0.4", "groq": "^6.6.0", "history": "^5.3.0" }, "peerDependencies": { "next": "^16.0.0-0", "react": "^19.2.3", "react-dom": "^19.2.3", "sanity": "^5.29.0 || ^6.0.0", "styled-components": "^6.1" } }, "sha512-i/QdYS9EnECYyUv1YjKFvLafrYa9OM6sDxm9GfgBO58HGyh9a7IdY2jdoIk9RFQRMZwJUi4goqDhBb+sg/MGKQ=="], diff --git a/package.json b/package.json index 2e08f327..4e35ab86 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "gsap": "^3.15.0", "hamo": "1.0.0-dev.13", "lenis": "^1.3.25", - "next": "16.3.0-preview.8", + "next": "16.3.0-preview.9", "next-sanity": "^13.1.4", "postprocessing": "^6.39.3", "react": "19.2.8", @@ -77,7 +77,7 @@ "@clack/prompts": "^1.7.0", "@csstools/postcss-global-data": "^4.0.0", "@happy-dom/global-registrator": "^20.11.0", - "@next/bundle-analyzer": "16.3.0-preview.8", + "@next/bundle-analyzer": "16.3.0-preview.9", "@playwright/test": "1.61.1", "@sanity/vision": "^6.5.0", "@storybook/addon-mcp": "^0.7.0", From 59e24f1cb75f4c67715f9881cb0f635daa30eecf Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 16:21:04 -0300 Subject: [PATCH 10/22] perf(hooks): let the React Compiler optimize useDeviceDetection again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-doctor flagged this as a compiler bailout: calling setState synchronously in an effect body. The cost is not the cascading render on its own — a bailout means the compiler gives up auto-memoizing the whole hook, and this one is consumed across the app. Safari and WebGL detection are synchronous and session-constant, already backed by the module cache above, so they do not need an effect at all. Both now read through useSyncExternalStore with a no-op subscribe and a server snapshot of undefined, which is the same pattern lib/hooks/use-sync-external.ts already uses for the other browser-API hooks. Autoplay detection stays on useState plus an effect. It is genuinely async, and a .then(setState) callback is not what the compiler objects to. The returned shape is unchanged: same keys, isWebGL still derives from supportsWebGL && isDesktop, and the values are still undefined until the client has them. getServerSnapshot means navigator and document are never touched during SSR. react-doctor now reports zero findings for this file. check passes and a build from a cleared .next succeeds. --- lib/hooks/use-device-detection.ts | 35 ++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/hooks/use-device-detection.ts b/lib/hooks/use-device-detection.ts index 8176e1f9..75b36465 100644 --- a/lib/hooks/use-device-detection.ts +++ b/lib/hooks/use-device-detection.ts @@ -1,5 +1,5 @@ import { useMediaQuery, useWindowSize } from 'hamo' -import { useEffect, useState } from 'react' +import { useEffect, useState, useSyncExternalStore } from 'react' import { breakpoints } from '@/styles/config' @@ -55,6 +55,18 @@ function detectIsAutoplaySupported() { return cache.isAutoplaySupported } +// Safari / WebGL support never change after load, so there is nothing to +// subscribe to — a stable no-op keeps useSyncExternalStore happy without +// ever notifying. +function subscribeToStaticDetection() { + // oxlint-disable-next-line eslint/no-empty-function -- required unsubscribe signature; nothing to tear down since the value is never notified + return () => {} +} + +function getStaticDetectionServerSnapshot() { + return undefined +} + /** * Detect device capabilities: screen size, input method, motion preference, * WebGL support, Safari, and inline-video autoplay support. @@ -74,15 +86,24 @@ export function useDeviceDetection() { const isTouchOnly = useMediaQuery('(any-pointer: coarse) and (hover: none)') const { dpr } = useWindowSize() - // Static detections — resolved from the module cache after mount (client-only - // so SSR stays consistent; undefined until then). - const [isSafari, setIsSafari] = useState<boolean>() - const [supportsWebGL, setSupportsWebGL] = useState<boolean>() + // Static detections — resolved from the module cache. Safari / WebGL are + // synchronous and read via useSyncExternalStore (undefined on the server so + // SSR stays consistent; the real value on the client, no effect needed). + const isSafari = useSyncExternalStore( + subscribeToStaticDetection, + detectIsSafari, + getStaticDetectionServerSnapshot + ) + const supportsWebGL = useSyncExternalStore( + subscribeToStaticDetection, + detectSupportsWebGL, + getStaticDetectionServerSnapshot + ) + + // Autoplay support is genuinely async, so it keeps useState + an effect. const [isAutoplaySupported, setIsAutoplaySupported] = useState<boolean>() useEffect(() => { - setIsSafari(detectIsSafari()) - setSupportsWebGL(detectSupportsWebGL()) void detectIsAutoplaySupported().then(setIsAutoplaySupported) }, []) From d68fa40ba0500375e407d2a08aaa18030be7398c Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 16:22:22 -0300 Subject: [PATCH 11/22] perf: cache Intl formatters, skip the barrels, stop walking lists twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four react-doctor performance findings, all real. formatMoney built a new Intl.NumberFormat on every call, and constructing one is expensive. It could not simply be hoisted: both locale and currencyCode vary per call, which matters for multi-market storefronts. Cached per (locale, currency) pair instead. react-doctor still flags this one — its matcher looks for a top-level `new Intl.NumberFormat` and cannot see through a cache guard. The memoised version is the correct fix, so the finding stays. Two Shopify server actions imported through the package barrel, pulling the whole index in to reach one function. Now import the defining module directly: shopifyFetch from ../client, the cart operations from ../cart-operations. The barrel is pure re-exports with no side effects, so bypassing it is safe. Two list walks became one. fetch-form flattened the field groups and then mapped the result in a second pass; the mapping now happens inside the same flatMap. The allowed-form-id parse did split/map/filter, where trim only ever produces '' as a falsy value, so a single flatMap is exactly equivalent. check passes, 385 tests. --- lib/integrations/hubspot/action.ts | 8 +++--- lib/integrations/hubspot/fetch-form.ts | 30 +++++++++----------- lib/integrations/shopify/cart/actions.ts | 4 +-- lib/integrations/shopify/customer/actions.ts | 2 +- lib/integrations/shopify/money.ts | 19 ++++++++++--- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/lib/integrations/hubspot/action.ts b/lib/integrations/hubspot/action.ts index b69768f0..7345cc95 100644 --- a/lib/integrations/hubspot/action.ts +++ b/lib/integrations/hubspot/action.ts @@ -61,10 +61,10 @@ export async function HubspotNewsletterAction( run: async ({ email, formId }) => { const allowedFormIds = env.HUBSPOT_ALLOWED_FORM_IDS if (allowedFormIds) { - const allowList = allowedFormIds - .split(',') - .map((id) => id.trim()) - .filter(Boolean) + const allowList = allowedFormIds.split(',').flatMap((id) => { + const trimmed = id.trim() + return trimmed ? [trimmed] : [] + }) if (!allowList.includes(formId)) { return { status: 400, diff --git a/lib/integrations/hubspot/fetch-form.ts b/lib/integrations/hubspot/fetch-form.ts index 2113608f..604565a3 100644 --- a/lib/integrations/hubspot/fetch-form.ts +++ b/lib/integrations/hubspot/fetch-form.ts @@ -128,22 +128,20 @@ function apiParser(id: string, data: HubspotFormResponse) { return { portalId: env.NEXT_PUBLIC_HUBSPOT_PORTAL_ID, id: id, - inputs: data.fieldGroups - .flatMap((item) => item.fields ?? []) - .map((flatData) => { - return { - name: flatData.name || '', - label: flatData.label || '', - placeholder: flatData.placeholder || '', - required: flatData.required, - type: flatData.fieldType || '', - hidden: flatData.hidden, - helpText: flatData.helpText || '', - options: flatData.options - ? flatData.options.map((option) => option.label) - : [], - } - }), + inputs: data.fieldGroups.flatMap((item) => + (item.fields ?? []).map((flatData) => ({ + name: flatData.name || '', + label: flatData.label || '', + placeholder: flatData.placeholder || '', + required: flatData.required, + type: flatData.fieldType || '', + hidden: flatData.hidden, + helpText: flatData.helpText || '', + options: flatData.options + ? flatData.options.map((option) => option.label) + : [], + })) + ), submitButton: { text: data.displayOptions.submitButtonText || 'Submit', }, diff --git a/lib/integrations/shopify/cart/actions.ts b/lib/integrations/shopify/cart/actions.ts index cc079aa6..2483735f 100644 --- a/lib/integrations/shopify/cart/actions.ts +++ b/lib/integrations/shopify/cart/actions.ts @@ -10,14 +10,14 @@ import { rateLimiters, } from '@/lib/utils/rate-limit' -import { TAGS } from '../constants' import { addToCart, createCart, getCart, removeFromCart, updateCart, -} from '../index' +} from '../cart-operations' +import { TAGS } from '../constants' import type { AddItemPayload, Cart } from '../types' /** Unified result shape for all cart mutations. */ diff --git a/lib/integrations/shopify/customer/actions.ts b/lib/integrations/shopify/customer/actions.ts index 17b6c345..f39ce82c 100644 --- a/lib/integrations/shopify/customer/actions.ts +++ b/lib/integrations/shopify/customer/actions.ts @@ -8,7 +8,7 @@ import { runFormAction } from '@/lib/utils/form-action' import { rateLimiters } from '@/lib/utils/rate-limit' import { emailSchema } from '@/utils/validation' -import { shopifyFetch } from '../index' +import { shopifyFetch } from '../client' import { customerAccessTokenCreateMutation, customerAccessTokenDeleteMutation, diff --git a/lib/integrations/shopify/money.ts b/lib/integrations/shopify/money.ts index 7f8d3aeb..ea0749f9 100644 --- a/lib/integrations/shopify/money.ts +++ b/lib/integrations/shopify/money.ts @@ -1,12 +1,23 @@ import type { Money } from './types' +// Formatters are keyed on the (locale, currency) pair since both vary at +// call time; `new Intl.NumberFormat` is expensive to construct, so each +// unique pair is built once and reused. +const formatters = new Map<string, Intl.NumberFormat>() + /** * Format a Shopify `Money` object using its own `currencyCode`, instead of * assuming `$` and two decimals. Locale defaults to the runtime's locale. */ export function formatMoney(money: Money, locale?: string): string { - return new Intl.NumberFormat(locale, { - style: 'currency', - currency: money.currencyCode, - }).format(Number(money.amount)) + const key = JSON.stringify([locale, money.currencyCode]) + let formatter = formatters.get(key) + if (!formatter) { + formatter = new Intl.NumberFormat(locale, { + style: 'currency', + currency: money.currencyCode, + }) + formatters.set(key, formatter) + } + return formatter.format(Number(money.amount)) } From 5b259fd71f2f4a29a01a8f455d4f7fca8e96dc4f Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 16:26:05 -0300 Subject: [PATCH 12/22] perf(next): turn on the rest of the 16.3 instant-navigation cluster partialPrefetching and cachedNavigations were already on, but Next passes them to renderOpts alongside three more flags in the same struct (next/dist/esm/server/base-server.js), and this branch exists to exercise exactly that machinery. Adds two of them: - varyParams keys a Cache Components segment on the params it actually reads, instead of invalidating the whole segment when any param changes. The most to gain of the set, since cacheComponents is already on. - optimisticRouting is the current route-tree match for the client segment cache. Next still ships the path it replaces behind a `deprecated_` prefix, so this is moving onto the supported one. Skipped dynamicOnHover: the flag alone changes nothing, since the upgrade is opt-in per <Link> via `unstable_dynamicOnHover`, and it costs extra requests on hover. Worth revisiting with the nav bar specifically. Also records a measurement rather than a guess: cssChunking: 'graph' was tried and reverted. It produced 11 stylesheets against the default's 9, for byte identical output. More requests, no saving. Build is clean with no config validation warnings, check passes, and the CSS output is back to the default 9 files. --- next.config.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/next.config.ts b/next.config.ts index 87786883..d1e5879d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -106,12 +106,28 @@ const nextConfig: NextConfig = { experimental: { taint: true, cachedNavigations: true, + // The rest of the instant-navigation cluster that `partialPrefetching` and + // `cachedNavigations` belong to (they are passed to renderOpts together in + // next/dist/esm/server/base-server.js). + // + // `varyParams` lets Cache Components key a segment on the params it + // actually reads, instead of invalidating the whole segment whenever any + // param changes. It is the one with the most to gain here, since + // `cacheComponents` is already on. + varyParams: true, + // `optimisticRouting` is the current route-tree match for the client + // segment cache. It replaces a deprecated search-params-based path that + // Next still ships behind `deprecated_requestOptimisticRouteCacheEntry`. + optimisticRouting: true, // 16.3 preview: persistent Turbopack FS cache for `next build` (faster // cached CI/Vercel builds) + the native Rust port of the React Compiler // (pairs with the top-level `reactCompiler: true`). Both experimental. turbopackFileSystemCacheForBuild: true, turbopackRustReactCompiler: true, sri: { algorithm: 'sha384' }, + // Not setting `cssChunking: 'graph'`. Measured on this branch it produced + // 11 stylesheets instead of the default's 9, for identical total bytes — + // more requests, no saving. The default 'loose' heuristic wins here. optimizePackageImports: [ '@react-three/drei', '@react-three/fiber', From a23dcd15ade4096be799ff7fa5dadd94f7bd45c4 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 18:22:32 -0300 Subject: [PATCH 13/22] perf(webgl): hold sim instances in refs and drop the effect-event handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears nine of the eleven React Compiler errors react-doctor reported. The fluid and flowmap simulations lived in useState: constructed in a mount effect, setState'd, then mutated imperatively every frame. That is two bailouts at once — setState-in-effect, and modifying a value returned from useState — so the compiler gave up on both hooks. They are now refs, which is what the repo already prescribes for object instantiation. The context type and FlowmapProvider carry RefObject through to consumers. Two regressions the ref conversion introduced, caught on review and fixed: - The aspect-ratio effects had `[size, fluid]` deps. Dropping the instance from the list meant a rebuilt sim never got its aspect set, since `size` had not changed. They now depend on `gl` and `resolution`, which is what rebuilds it. - useTheatre was given `deps: [fluidRef]`. A ref's identity never changes, so Theatre would never re-subscribe and its initial values would never reach a newly built sim. Also now keyed on `gl` and `resolution`. use-webgl-rect stops passing a useEffectEvent function into useTransform and useLenis, which React forbids because the identity is only valid inside the owning effect. The body moved into a ref reassigned in an effect, with a stable wrapper delegating to it, so both call sites get one identity that never changes. That removes the two rules-of-hooks suppressions the file was carrying as a documented follow-up. The tunnel's ContextBridge keeps its finding suppressed, with evidence: drei wraps the returned component in useMemo(..., []), so it is created once per hook instance rather than per render, and the state-reset the rule warns about cannot happen. react-doctor: 149 issues -> 135, errors 11 -> 2. check passes, clean build. --- .../components/flowmap-provider/index.tsx | 28 +++--- lib/webgl/components/tunnel/index.tsx | 9 ++ lib/webgl/hooks/use-webgl-rect.ts | 89 ++++++++++--------- lib/webgl/utils/flowmaps/index.tsx | 35 +++++--- lib/webgl/utils/fluid/index.tsx | 44 +++++---- 5 files changed, 124 insertions(+), 81 deletions(-) diff --git a/lib/webgl/components/flowmap-provider/index.tsx b/lib/webgl/components/flowmap-provider/index.tsx index b525f8f8..314c8fc4 100644 --- a/lib/webgl/components/flowmap-provider/index.tsx +++ b/lib/webgl/components/flowmap-provider/index.tsx @@ -13,7 +13,7 @@ * updated each frame inside the R3F render loop. */ -import { createContext, use } from 'react' +import { createContext, type RefObject, use } from 'react' import { useFlowmapSim } from '@/webgl/utils/flowmaps' import type { Flowmap } from '@/webgl/utils/flowmaps/flowmap-sim' @@ -23,12 +23,16 @@ import type { Fluid } from '@/webgl/utils/fluid/fluid-sim' /** * Shape of the flowmap context value. * - * @property fluid - The GPU fluid simulation instance (Navier-Stokes). - * @property flowmap - The GPU flowmap simulation instance (velocity-field displacement). + * The simulations are held in refs (not state) because they're mutated + * imperatively every frame — reading `.current` gives the latest instance + * without ever triggering a re-render. + * + * @property fluid - Ref to the GPU fluid simulation instance (Navier-Stokes). + * @property flowmap - Ref to the GPU flowmap simulation instance (velocity-field displacement). */ type FlowmapContextType = { - fluid: Fluid | null - flowmap: Flowmap | null + fluid: RefObject<Fluid | null> | null + flowmap: RefObject<Flowmap | null> | null } export const FlowmapContext = createContext<FlowmapContextType>({ @@ -37,7 +41,7 @@ export const FlowmapContext = createContext<FlowmapContextType>({ }) /** - * Retrieves the active GPU simulation instance from context. + * Retrieves the active GPU simulation instance ref from context. * * Must be called inside a {@link FlowmapProvider} (which itself must be * inside the R3F `<Canvas>` tree, since the simulations depend on the @@ -46,18 +50,20 @@ export const FlowmapContext = createContext<FlowmapContextType>({ * @param type - Which simulation to return. * - `'flowmap'` (default) -- lightweight velocity-field displacement. * - `'fluid'` -- full Navier-Stokes fluid simulation. - * @returns The requested simulation instance (`Fluid` or `Flowmap`). + * @returns A ref to the requested simulation instance (`Fluid` or `Flowmap`). + * Read `.current` inside an effect, event handler, or `useFrame` callback — + * never during render. * * @example * ```tsx * function DistortedImage() { - * const flowmap = useFlowmap('flowmap') - * // Use flowmap.texture as a uniform in a custom shader + * const flowmapRef = useFlowmap('flowmap') + * // Use flowmapRef.current?.texture as a uniform in a custom shader * } * * function FluidBackground() { - * const fluid = useFlowmap('fluid') - * // Use fluid.density / fluid.velocity textures + * const fluidRef = useFlowmap('fluid') + * // Use fluidRef.current?.density / fluidRef.current?.velocity textures * } * ``` */ diff --git a/lib/webgl/components/tunnel/index.tsx b/lib/webgl/components/tunnel/index.tsx index e2a46bfe..14c6531f 100644 --- a/lib/webgl/components/tunnel/index.tsx +++ b/lib/webgl/components/tunnel/index.tsx @@ -63,6 +63,15 @@ export function WebGLTunnel({ children }: PropsWithChildren) { return ( <WebGLTunnel.In> + {/* + `static-components` fires because ContextBridge comes out of a hook, and + a component built during render would normally reset its state. It does + not here: drei wraps the returned component in `useMemo(..., [])` + (node_modules/@react-three/drei/core/useContextBridge.js), so it is + created once per hook instance and kept, with fresh context values read + through a ref. The `key` keeps identity stable across re-parents. + */} + {/* react-doctor-disable-next-line react-hooks-js/static-components */} <ContextBridge key={uuid}>{children}</ContextBridge> </WebGLTunnel.In> ) diff --git a/lib/webgl/hooks/use-webgl-rect.ts b/lib/webgl/hooks/use-webgl-rect.ts index 64f75a53..b925e5df 100644 --- a/lib/webgl/hooks/use-webgl-rect.ts +++ b/lib/webgl/hooks/use-webgl-rect.ts @@ -2,7 +2,7 @@ import { useThree } from '@react-three/fiber' import type { Rect } from 'hamo' import { useTransform } from 'hamo' import { useLenis } from 'lenis/react' -import { useEffect, useEffectEvent, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { Euler, Vector3 } from 'three' /** @@ -29,8 +29,8 @@ interface UseWebGLRectOptions { /** * Hook for positioning WebGL meshes based on DOM element rects. * - * Uses useEffectEvent for stable callback references that always - * access latest values without causing effect re-runs. + * Uses a ref-held callback for a stable function reference that always + * accesses latest values without causing effect re-runs. * * Pass `visible: false` to skip position computations when the element * is off-screen, improving performance for many WebGL elements. @@ -83,53 +83,56 @@ export function useWebGLRect( isVisible: true, }) - // useEffectEvent: callback always has access to latest values - // without being a dependency that triggers re-subscriptions - const handleUpdate = useEffectEvent(() => { - // Skip computations when not visible - if (!visible) return - - const { translate, scale } = getTransform() - const scroll = lenis ? Math.floor(lenis.scroll) : window.scrollY - const transform = transformRef.current - - if ( - rect.top === undefined || - rect.height === undefined || - rect.left === undefined || - rect.width === undefined - ) { - // Expected during initial render before DOM measurement completes - return - } - - transform.isVisible = - scroll > rect.top - size.height + translate.y && - scroll < rect.top + translate.y + rect.height - - transform.position.x = -size.width / 2 + (rect.left + rect.width / 2) - transform.position.y = - size.height / 2 - (rect.top + rect.height / 2) + scroll - translate.y - transform.scale.x = rect.width * scale.x - transform.scale.y = rect.height * scale.y + // Holds the latest render's update logic. Reassigned in an effect (never + // during render, which would be unsafe under concurrent rendering) so the + // stable wrapper below always runs against fresh closures after commit. + const callbackRef = useRef<(() => void) | null>(null) - onUpdate?.(transformRef.current) + useEffect(() => { + callbackRef.current = () => { + // Skip computations when not visible + if (!visible) return + + const { translate, scale } = getTransform() + const scroll = lenis ? Math.floor(lenis.scroll) : window.scrollY + const transform = transformRef.current + + if ( + rect.top === undefined || + rect.height === undefined || + rect.left === undefined || + rect.width === undefined + ) { + // Expected during initial render before DOM measurement completes + return + } + + transform.isVisible = + scroll > rect.top - size.height + translate.y && + scroll < rect.top + translate.y + rect.height + + transform.position.x = -size.width / 2 + (rect.left + rect.width / 2) + transform.position.y = + size.height / 2 - (rect.top + rect.height / 2) + scroll - translate.y + transform.scale.x = rect.width * scale.x + transform.scale.y = rect.height * scale.y + + onUpdate?.(transformRef.current) + } }) - // NOTE: these two suppressions are not about hook ordering. Both calls are - // unconditional and at the top level of this hook. What the rule objects to - // is passing `handleUpdate` (a useEffectEvent function) into another hook at - // all, which React forbids because the identity is only valid inside the - // effect that owns it. It works here because useTransform/useLenis invoke it - // from within their own effects, but the proper fix is to drop useEffectEvent - // for a ref-held callback. Tracked as a follow-up, not silenced and forgotten. + // Stable wrapper, created once via lazy useState initialization, that + // always delegates to the latest callback held in callbackRef. Its + // identity never changes, so passing it to useTransform/useLenis never + // causes them to re-subscribe. + const [handleUpdate] = useState<() => void>( + () => () => callbackRef.current?.() + ) // Subscribe to transform changes - // oxlint-disable-next-line react/rules-of-hooks -- see NOTE above useTransform(handleUpdate, []) // Subscribe to lenis scroll - // oxlint-disable-next-line react/rules-of-hooks -- see NOTE above useLenis(handleUpdate, []) // Fallback for non-lenis scroll @@ -142,7 +145,7 @@ export function useWebGLRect( return () => { window.removeEventListener('scroll', handleUpdate, false) } - }, [lenis]) // handleUpdate is stable from useEffectEvent + }, [lenis, handleUpdate]) function get() { return transformRef.current diff --git a/lib/webgl/utils/flowmaps/index.tsx b/lib/webgl/utils/flowmaps/index.tsx index 4d33f89a..067ed7f7 100644 --- a/lib/webgl/utils/flowmaps/index.tsx +++ b/lib/webgl/utils/flowmaps/index.tsx @@ -1,6 +1,6 @@ import { useFrame, useThree } from '@react-three/fiber' import { types } from '@theatre/core' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { useCurrentSheet } from '@/dev/theatre' import { useTheatre } from '@/dev/theatre/hooks/use-theatre' @@ -12,15 +12,18 @@ export function useFlowmapSim(resolution = 128) { const gl = useThree((state) => state.gl) const size = useThree((state) => state.size) - // Created/destroyed by the effect, keyed on gl + resolution. - const [flowmap, setFlowmap] = useState<Flowmap | null>(null) + // Created/destroyed by the effect, keyed on gl + resolution. Held in a ref + // (not state) because the instance is mutated imperatively below — the + // React Compiler cannot optimize a component that constructs-then-setStates + // an instance in an effect and later mutates that same state value. + const flowmapRef = useRef<Flowmap | null>(null) useEffect(() => { const flowmap = new Flowmap(gl, { size: resolution }) - setFlowmap(flowmap) + flowmapRef.current = flowmap return () => { flowmap.destroy() - setFlowmap(null) + flowmapRef.current = null } }, [gl, resolution]) @@ -30,9 +33,10 @@ export function useFlowmapSim(resolution = 128) { const lastTimeRef = useRef<number | null>(null) // Mouse/touch input — drives the flowmap stamp position and velocity. - // The callback always reads the latest `size` and `flowmap`, because - // usePointerInput routes it through useEffectEvent. + // The callback always reads the latest `size` and `flowmapRef.current`, + // because usePointerInput routes it through useEffectEvent. usePointerInput((clientX, clientY, dx, dy) => { + const flowmap = flowmapRef.current if (!flowmap) return const now = performance.now() @@ -52,10 +56,15 @@ export function useFlowmapSim(resolution = 128) { }) // Aspect ratio so the cursor falloff stays round + // `gl` and `resolution` are dependencies even though they are not read here: + // they are what recreates the instance above, and a fresh Flowmap needs its + // aspect set. Before the instance moved into a ref, the old `flowmap` state + // value in this list did that job. useEffect(() => { + const flowmap = flowmapRef.current if (!flowmap) return flowmap.material.uniforms.uAspect.value = size.width / size.height - }, [flowmap, size]) + }, [size, gl, resolution]) useTheatre( sheet, @@ -72,15 +81,21 @@ export function useFlowmapSim(resolution = 128) { falloff: number dissipation: number }) => { + const flowmap = flowmapRef.current if (!flowmap) return flowmap.falloff = falloff flowmap.dissipation = dissipation }, - deps: [flowmap], + // Re-subscribe whenever the instance is rebuilt, so Theatre re-applies + // its current values to the new Flowmap. A ref's identity never changes, + // so listing `flowmapRef` here would mean never re-subscribing, and the + // initial values would be dropped on the floor. + deps: [gl, resolution], } ) useFrame(() => { + const flowmap = flowmapRef.current if (flowmap && !movedRef.current) { // Pointer idle this frame: park off-screen + zero velocity so the // existing trail dissipates instead of stamping a fixed smear. @@ -91,5 +106,5 @@ export function useFlowmapSim(resolution = 128) { flowmap?.update() }, -10) - return flowmap + return flowmapRef } diff --git a/lib/webgl/utils/fluid/index.tsx b/lib/webgl/utils/fluid/index.tsx index 3cf082e6..055244d0 100644 --- a/lib/webgl/utils/fluid/index.tsx +++ b/lib/webgl/utils/fluid/index.tsx @@ -1,6 +1,6 @@ import { useFrame, useThree } from '@react-three/fiber' import { types } from '@theatre/core' -import { useEffect, useState } from 'react' +import { useEffect, useRef } from 'react' import { useCurrentSheet } from '@/dev/theatre' import { useTheatre } from '@/dev/theatre/hooks/use-theatre' @@ -12,37 +12,42 @@ export function useFluidSim(resolution = 128) { const gl = useThree((state) => state.gl) const size = useThree((state) => state.size) - // Created/destroyed by the effect, keyed on gl + resolution. - const [fluid, setFluid] = useState<null | Fluid>(null) + // Created/destroyed by the effect, keyed on gl + resolution. Held in a ref + // (not state) because the instance is mutated imperatively below — the + // React Compiler cannot optimize a component that constructs-then-setStates + // an instance in an effect and later mutates that same state value. + const fluidRef = useRef<null | Fluid>(null) useEffect(() => { const fluid = new Fluid(gl, { simRes: resolution }) - setFluid(fluid) + fluidRef.current = fluid return () => { fluid.destroy() - setFluid(null) + fluidRef.current = null } }, [resolution, gl]) // Normalize pointer input and queue splats. The callback always reads the - // latest `size` and `fluid`, because usePointerInput routes it through - // useEffectEvent. + // latest `size` and `fluidRef.current`, because usePointerInput routes it + // through useEffectEvent. usePointerInput((clientX, clientY, dx, dy) => { if (!(Math.abs(dx) || Math.abs(dy))) return const normalizedX = clientX / size.width const normalizedY = 1 - clientY / size.height - fluid?.addSplat(normalizedX, normalizedY, dx * 5, dy * -5) + fluidRef.current?.addSplat(normalizedX, normalizedY, dx * 5, dy * -5) }) - // Update aspect ratio when viewport size changes + // Update aspect ratio when viewport size changes. + // + // `gl` and `resolution` are dependencies even though they are not read here: + // they are what recreates the instance above, and a fresh Fluid needs its + // aspect set. Before the instance moved into a ref, the old `fluid` state + // value in this list did that job. useEffect(() => { + const fluid = fluidRef.current if (!fluid) return - // Writing a Three.js shader uniform on the state-held Fluid instance. The - // `fluid` reference itself never changes, so this imperative WebGL update is - // invisible to React — react-hooks-js/immutability is a false positive here. - // react-doctor-disable-next-line react-hooks-js/immutability fluid.splatMaterial.uniforms.uAspect.value = size.width / size.height - }, [size, fluid]) + }, [size, gl, resolution]) // Theatre.js controls for fluid parameters useTheatre( @@ -69,6 +74,7 @@ export function useFluidSim(resolution = 128) { curl: number radius: number }) => { + const fluid = fluidRef.current if (!fluid) return fluid.curlStrength = curl fluid.densityDissipation = density @@ -76,15 +82,19 @@ export function useFluidSim(resolution = 128) { fluid.pressureDissipation = pressure fluid.radius = radius }, - deps: [fluid], + // Re-subscribe whenever the instance is rebuilt, so Theatre re-applies + // its current values to the new Fluid. A ref's identity never changes, + // so listing `fluidRef` here would mean never re-subscribing, and the + // initial values would be dropped on the floor. + deps: [gl, resolution], } ) // Drive the simulation with the real frame delta so it runs at the same // apparent speed regardless of display refresh rate. useFrame((_, delta) => { - fluid?.update(delta) + fluidRef.current?.update(delta) }, -10) - return fluid + return fluidRef } From db66af75a7883f2e7c66ee0cdaa48da7adcf8b67 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 18:28:03 -0300 Subject: [PATCH 14/22] perf(webgl,theatre): clear the last two React Compiler errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-doctor is now at zero errors, down from eleven. WebGLImageMesh keeps its material in useState and mutates it, which trips `immutability`. It stays that way, with the finding suppressed and the reason written down. The material is handed to <primitive object={material} /> during render, so it cannot be built in an effect; and lazily instantiating it into a ref during render only trades this finding for a `refs` one, which I tried and reverted — errors went 2 to 3. Assigning a Three.js material's map is imperative GPU work on an object React only stores. useTheatreObject's setState in an effect also stays. The state is what makes the Theatre handle observable: useTheatre's subscription effect keys on it and has to re-run when the object appears or is rebuilt, so a ref would leave subscribers with no signal. The bailout costs auto-memoization on dev-only tooling that is lazily loaded, gated behind a dev toggle, and removed by setup:project when Theatre is dropped. Both are suppressions rather than rewrites, which is the honest outcome here: the alternatives were measured and made things worse or made a public hook's contract worse to satisfy a linter on dev-only code. react-doctor: 149 issues -> 133, errors 11 -> 0. check passes, clean build. --- lib/dev/theatre/hooks/use-theatre.ts | 9 +++++++++ lib/webgl/components/image/webgl.tsx | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/dev/theatre/hooks/use-theatre.ts b/lib/dev/theatre/hooks/use-theatre.ts index d6a7ff0c..b489c775 100644 --- a/lib/dev/theatre/hooks/use-theatre.ts +++ b/lib/dev/theatre/hooks/use-theatre.ts @@ -26,6 +26,15 @@ export function useTheatreObject( useEffect(() => { if (!sheet) return + // `set-state-in-effect` fires here, and it stays. The state is what makes + // the object observable: useTheatre's subscription effect keys on it, so it + // has to re-run when the object appears or is rebuilt. Holding it in a ref + // instead would leave subscribers with no signal, and the object is a + // Theatre handle that only exists once the sheet does, so it cannot be + // derived during render. The bailout costs auto-memoization on Theatre's + // dev tooling only — lazily loaded, gated behind a dev toggle, and removed + // outright by setup:project for projects that drop Theatre. + // react-doctor-disable-next-line react-hooks-js/set-state-in-effect setObject(sheet?.object(theatreKey, config, { reconfigure: true })) return () => { diff --git a/lib/webgl/components/image/webgl.tsx b/lib/webgl/components/image/webgl.tsx index 9234e840..18500938 100644 --- a/lib/webgl/components/image/webgl.tsx +++ b/lib/webgl/components/image/webgl.tsx @@ -75,6 +75,15 @@ function WebGLImageMesh({ src, rect, visible = true }: WebGLImageMeshProps) { texture.magFilter = texture.minFilter = LinearFilter texture.generateMipmaps = false + // `immutability` fires on these two writes because `material` came out of + // useState. It stays that way on purpose. The material has to exist during + // render (it is handed to <primitive object={material} /> below), so it + // cannot be built in an effect; and lazily instantiating it into a ref + // during render just trades this finding for a `refs` one, since that is a + // ref write during render. Assigning a Three.js material's map is + // imperative GPU work on an object React only stores — there is no React + // state to keep in sync, and the identity never changes. + // react-doctor-disable-next-line react-hooks-js/immutability material.map = texture material.needsUpdate = true }) From fe70b46537281d5318b300d0b3e2f4309be23670 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 18:38:14 -0300 Subject: [PATCH 15/22] chore(react-doctor): stop the dead-code pass reporting a template as dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-doctor's dead-code analysis assumes an application. satus is a template: it ships UI primitives, hooks and whole opt-in integrations for consumers to reach for, the demo app deliberately imports almost none of them, and setup:project strips whatever a project does not keep. "Nothing imports this" is the intended state. That pass was 85 of 130 findings — the entire Shopify, HubSpot and Mailchimp integrations, components/ui/form, fold, scrollbar, lib/hooks/use-reveal. It also called happy-dom an unused devDependency, which it is not: bunfig.toml preloads lib/scripts/test-setup.ts, which registers happy-dom onto Bun's globals through @happy-dom/global-registrator, so dropping it breaks bun test. Real dead code is still covered by a tool tuned for this repo. `bun run deslop` is a cross-file dead-code, unused-export and circular-import scanner and it reports zero unused files and zero dead exports here — which is what made it safe to conclude these were false positives rather than a backlog. Also turns off nextjs-missing-metadata. It fires on any file named `page.*`, and two of the three hits are not routes at all: a Sanity document schema and a GROQ query. The third is the Studio route, which is 'use client' and therefore cannot export metadata, and should not be indexed regardless. The config moves from JSON to TypeScript so each entry carries its reasoning, the same reason oxlint.config.ts and oxfmt.config.ts are TypeScript. react-doctor: 133 issues -> 45, still zero errors. --- doctor.config.json | 9 ------- doctor.config.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) delete mode 100644 doctor.config.json create mode 100644 doctor.config.ts diff --git a/doctor.config.json b/doctor.config.json deleted file mode 100644 index 35fda649..00000000 --- a/doctor.config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://react.doctor/schema/config.json", - "rules": { - "react-doctor/server-auth-actions": "off", - "react-doctor/no-unknown-property": "off", - "react-doctor/no-pure-black-background": "off", - "react-doctor/jsx-no-constructed-context-values": "off" - } -} diff --git a/doctor.config.ts b/doctor.config.ts new file mode 100644 index 00000000..95d58c78 --- /dev/null +++ b/doctor.config.ts @@ -0,0 +1,59 @@ +/** + * React Doctor configuration. + * + * A `.ts` file rather than `doctor.config.json` so every disabled check can say + * why it is disabled — the same reason `oxlint.config.ts` and `oxfmt.config.ts` + * are TypeScript. + * + * Nothing here is switched off to make a number go down. Each entry is either + * wrong about this codebase or wrong about this *kind* of codebase. + */ +export default { + $schema: 'https://react.doctor/schema/config.json', + + /** + * Dead-code analysis: unused files, unused exports, unused devDependencies. + * + * Off because it assumes an application and satus is a template. It ships UI + * primitives, hooks and whole opt-in integrations for consumers to reach for; + * the demo app deliberately does not import most of them, and + * `setup:project` strips the ones a project chooses not to keep. "Nothing + * imports this" is the intended state here, not dead code. + * + * This pass accounted for 85 of 130 findings, including the entire Shopify, + * HubSpot and Mailchimp integrations, `components/ui/form`, `fold`, + * `scrollbar`, and `lib/hooks/use-reveal` — none of which are dead. It also + * called `happy-dom` an unused devDependency, when it is loaded without an + * import: `bunfig.toml` preloads `lib/scripts/test-setup.ts`, which registers + * happy-dom onto Bun's globals via `@happy-dom/global-registrator`. + * + * Real dead code is still covered, by a tool tuned for this repo: `bun run + * deslop` is a cross-file dead-code, unused-export and circular-import + * scanner, and it reports zero unused files and zero dead exports here. + */ + deadCode: false, + + rules: { + /** + * Fires on any file named `page.*`, including files that are not Next + * routes: `lib/integrations/sanity/schemas/page.ts` is a Sanity document + * schema and `lib/integrations/shopify/queries/page.ts` is a GROQ query. + * The one real route it flagged, `app/studio/[[...tool]]`, is + * `'use client'` — a client component cannot export `metadata` — and it is + * a CMS admin surface that should not be indexed anyway. + */ + 'react-doctor/nextjs-missing-metadata': 'off', + + 'react-doctor/server-auth-actions': 'off', + 'react-doctor/no-unknown-property': 'off', + 'react-doctor/no-pure-black-background': 'off', + + /** + * React Compiler memoizes context values automatically, so hoisting them by + * hand is pre-Compiler folklore. oxlint's equivalent + * (`react/jsx-no-constructed-context-values`) is left out of the enabled + * rule set for the same reason. + */ + 'react-doctor/jsx-no-constructed-context-values': 'off', + }, +} From d557a2f847f14dbb8b1de713e1f85e77a8a97470 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 18:43:24 -0300 Subject: [PATCH 16/22] a11y: give every form control a name, and use <output> for loading regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real accessibility bugs and three semantic upgrades. Accessibility findings are now at zero. InputField and TextareaField only rendered <Field.Label> when a `label` prop was passed, which is optional. A field given just a placeholder reached screen readers with no accessible name at all. There is now a fallback chain: the visible label if there is one, otherwise the placeholder, otherwise the field name. The aria-label is deliberately NOT set when a visible label exists — aria-label overrides it, and the announced name could then drift from the text on screen. The three role="status" loading regions become <output>, which carries that role implicitly and gives screen readers more reliable semantics. Two of them already had `flex` in their class list, which overrides <output>'s inline default, so the layout is untouched. The third (the demo panel skeleton) had only `w-40`, and width does nothing on an inline element, so it gains an explicit `block`. Two suppressions remain on the form controls, on the inner elements passed to Base UI's `render` prop. Base UI merges id, name and the aria-label onto those at runtime, so a static read of that line sees an unlabelled input that never reaches the DOM. The name is always present. react-doctor: 45 issues -> 40. Accessibility 5 -> 0. check passes, clean build. --- app/instant-nav-demo/[item]/loading.tsx | 7 ++++--- app/instant-nav-demo/slow/page.tsx | 7 +++++-- app/loading.tsx | 8 +++++--- components/ui/form/fields/index.tsx | 19 +++++++++++++++++++ 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/app/instant-nav-demo/[item]/loading.tsx b/app/instant-nav-demo/[item]/loading.tsx index d5e720fc..e258a3b3 100644 --- a/app/instant-nav-demo/[item]/loading.tsx +++ b/app/instant-nav-demo/[item]/loading.tsx @@ -1,7 +1,8 @@ export default function Loading() { return ( - <div - role="status" + // <output> has an implicit role="status"; `flex` overrides its default + // inline display, so the layout is unchanged. + <output aria-busy="true" className="gap-3 flex min-h-dvh flex-col items-center justify-center font-mono" > @@ -11,6 +12,6 @@ export default function Loading() { <div className="h-2 rounded w-3/4 bg-current opacity-20" /> <div className="h-2 rounded w-1/2 bg-current opacity-10" /> </div> - </div> + </output> ) } diff --git a/app/instant-nav-demo/slow/page.tsx b/app/instant-nav-demo/slow/page.tsx index 3ddf8545..da7067e5 100644 --- a/app/instant-nav-demo/slow/page.tsx +++ b/app/instant-nav-demo/slow/page.tsx @@ -25,14 +25,17 @@ async function StreamedPanel() { function PanelSkeleton() { return ( - <div role="status" aria-busy="true" className="w-40"> + // `block` is required: <output> is inline by default, and `w-40` would do + // nothing on an inline element. The implicit role="status" replaces the + // explicit one. + <output aria-busy="true" className="w-40 block"> <span className="sr-only">Loading panel</span> <div aria-hidden className="animate-pulse space-y-2"> <div className="h-2 rounded bg-current opacity-20" /> <div className="h-2 rounded w-3/4 bg-current opacity-20" /> <div className="h-2 rounded w-1/2 bg-current opacity-10" /> </div> - </div> + </output> ) } diff --git a/app/loading.tsx b/app/loading.tsx index 67ffcb1f..f3053682 100644 --- a/app/loading.tsx +++ b/app/loading.tsx @@ -3,8 +3,10 @@ // and fails prerendering (e.g. /studio). Keep this fallback dependency-free. export default function Loading() { return ( - <div - role="status" + // <output> carries an implicit role="status", so the role is redundant. + // `flex` overrides the element's default inline display, so the layout is + // unchanged. + <output aria-busy="true" className="gap-3 flex min-h-dvh flex-col items-center justify-center font-mono" > @@ -14,6 +16,6 @@ export default function Loading() { <div className="h-2 rounded w-3/4 bg-current opacity-20" /> <div className="h-2 rounded w-1/2 bg-current opacity-10" /> </div> - </div> + </output> ) } diff --git a/components/ui/form/fields/index.tsx b/components/ui/form/fields/index.tsx index fddd2eec..be307d0d 100644 --- a/components/ui/form/fields/index.tsx +++ b/components/ui/form/fields/index.tsx @@ -84,7 +84,20 @@ export function InputField({ required={required} placeholder={placeholder} className={cn(s.input)} + // `label` is optional, and without it no <Field.Label> is rendered, so + // the control would otherwise reach screen readers with no accessible + // name. Fall back to the placeholder, then to the field name, so there + // is always one. Deliberately NOT set when a visible label exists: + // aria-label would override it, and the announced name could then + // drift from the text on screen. Spread rather than passing + // `undefined`, which `exactOptionalPropertyTypes` rejects. + // + {...(label ? {} : { 'aria-label': placeholder ?? fieldName })} {...register(fieldName)} + // The finding lands on this inner element. Base UI merges id, name and + // the aria-label above onto it at runtime, so a static read of this + // line sees an unlabelled input that never reaches the DOM. + // react-doctor-disable-next-line react-doctor/control-has-associated-label render={<input />} /> {error?.state && error.message && ( @@ -146,7 +159,13 @@ export function TextareaField({ required={required} placeholder={placeholder} className={s.textarea} + // Same as InputField: no `label` means no <Field.Label>, so fall back + // to the placeholder and then the field name. + {...(label ? {} : { 'aria-label': placeholder ?? fieldName })} {...reg} + // Same as InputField: Base UI merges the real attributes onto this + // element at runtime. + // react-doctor-disable-next-line react-doctor/control-has-associated-label render={<textarea rows={rows} />} /> {error?.state && error.message && ( From 381957cf962bcb20dd9d366400d34e287b7e35bd Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 18:45:50 -0300 Subject: [PATCH 17/22] perf(demo): resolve params and connection() together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-doctor is right that these two awaits are independent, so they run concurrently now. The 1200ms delay below stays sequential — a slow route is what the demo exists to show. --- app/instant-nav-demo/[item]/page.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/instant-nav-demo/[item]/page.tsx b/app/instant-nav-demo/[item]/page.tsx index e7f6638d..d43670f1 100644 --- a/app/instant-nav-demo/[item]/page.tsx +++ b/app/instant-nav-demo/[item]/page.tsx @@ -12,8 +12,10 @@ export default async function ItemPage({ }: { params: Promise<{ item: string }> }) { - const { item } = await params - await connection() + // `params` and `connection()` are independent, so they resolve together. + const [{ item }] = await Promise.all([params, connection()]) + // The delay stays sequential on purpose — a slow route is what this demo is + // demonstrating. await new Promise((resolve) => setTimeout(resolve, 1200)) return ( From d43181f6a71279fb2d0d6db3513f835f6bf0a98c Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 18:52:36 -0300 Subject: [PATCH 18/22] chore(react-doctor): scope the Theatre and theme findings by rule, not by folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses react-doctor's ignore.overrides so these stop polluting every local run and every CI log, without going blind in those files: the suppression lists specific rules, so a genuine bug of some other kind is still reported. No blanket "skip this folder" anywhere. lib/dev/theatre is an integration with an external, imperative animation editor. React does not own that data flow — values change because someone dragged a keyframe in the studio UI, and the hooks exist to subscribe and mirror it back. All eight rules presuppose React owning the flow, so each asks for a rewrite that cannot exist. no-event-handler is the clearest: it says to move the side effect into the event handler that triggers it, and there is no React event handler, only Theatre's own notifications. It is also dev-only, lazily imported, behind a dev toggle, and deleted by setup:project when Theatre is dropped. components/layout/theme already implements React's documented pattern for adjusting state when a prop changes, render-phase re-sync with a prevTheme sentinel, docs link in the file. The three rules there each ask for something impossible: computing currentTheme inline (it is runtime-overridable via setTheme), holding it in a ref (it is rendered, through context), or moving the data-theme write off an effect (it synchronises an external system, which is what effects are for). Verified the scoping does not leak: exhaustive-deps still fires in lib/integrations/hubspot/embed, so it is genuinely path-limited. react-doctor: 39 issues -> 12, still zero errors. --- doctor.config.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/doctor.config.ts b/doctor.config.ts index 95d58c78..f8ddb8c8 100644 --- a/doctor.config.ts +++ b/doctor.config.ts @@ -33,6 +33,66 @@ export default { */ deadCode: false, + /** + * Per-path suppressions. Scoped by rule rather than by whole directory on + * purpose: a genuine bug of some *other* kind in these files still gets + * reported. Nothing here is a blanket "skip this folder". + */ + ignore: { + overrides: [ + { + /** + * Theatre.js is an external, imperative animation editor. React does + * not own this data flow: values change because someone dragged a + * keyframe in the studio UI, and the hooks exist to subscribe to that + * and mirror it back. Every rule below presupposes React owning the + * flow, so each one asks for a rewrite that cannot exist here — + * `no-event-handler` in particular says "run the side effect in the + * event handler that triggers it", and there is no React event + * handler, only Theatre's own change notifications. + * + * Scope note: this is dev-only tooling. It is lazily imported, gated + * behind a dev toggle, and `setup:project` deletes it outright for + * projects that do not keep Theatre — so none of it ships to users. + */ + files: ['lib/dev/theatre/**'], + rules: [ + 'react-doctor/no-event-handler', + 'react-doctor/no-effect-event-handler', + 'react-doctor/no-chain-state-updates', + 'react-doctor/no-derived-state', + 'react-doctor/no-pass-data-to-parent', + 'react-doctor/no-fetch-in-effect', + 'react-doctor/rendering-hydration-mismatch-time', + 'react-doctor/exhaustive-deps', + ], + }, + { + /** + * `components/layout/theme` already implements React's documented + * pattern for adjusting state when a prop changes — the render-phase + * re-sync with a `prevTheme` sentinel, with the docs link in the file. + * The three rules below each ask for something that cannot work here: + * + * - `no-derived-useState` wants the value computed inline, but + * `currentTheme` is overridable at runtime through `setTheme`, so it + * is seeded from the prop rather than derived from it. + * - `rerender-state-only-in-handlers` wants a ref, but the value is + * rendered — it goes out through context to every consumer. + * - `no-event-handler` flags the `data-theme` write on <html>, which + * is synchronising an external system with React state, the case + * effects are actually for. + */ + files: ['components/layout/theme/**'], + rules: [ + 'react-doctor/no-derived-useState', + 'react-doctor/rerender-state-only-in-handlers', + 'react-doctor/no-event-handler', + ], + }, + ], + }, + rules: { /** * Fires on any file named `page.*`, including files that are not Next From 315adf189c481fb05e213fe0eb4995cea71cefa9 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 18:59:55 -0300 Subject: [PATCH 19/22] fix(form): fire onSuccess/onError from the action, not from an effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The callbacks ran in an effect that watched formState and listed onSuccess and onError as dependencies. Callers pass those inline, so a parent re-render handed the effect fresh function identities, it re-ran, and it fired the callbacks a second time for a submission that had already been handled. Anything non-idempotent behind onSuccess — an analytics event, a redirect, a toast — ran twice. They now fire inside a wrapper around the action, where the result is already known. Exactly once per submission, and there is nothing left to observe after the fact. The reset effect that survives also had a cleanup hole. `resetTimer` is assigned inside the rAF write-queue callback, so unmounting before that queue drained left the timer to be created after teardown, with setKey firing on a component that no longer existed. A `cancelled` flag now covers that window. Found by react-doctor's no-prop-callback-in-effect. Worth noting the rule pointed at a lint concern and the actual payoff was two latent bugs. check passes, clean build. react-doctor: 12 issues -> 10. --- components/ui/form/index.tsx | 51 +++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/components/ui/form/index.tsx b/components/ui/form/index.tsx index e58ed438..7b6cb465 100644 --- a/components/ui/form/index.tsx +++ b/components/ui/form/index.tsx @@ -3,7 +3,6 @@ import cn from 'clsx' import { createContext, use, useEffect, useState } from 'react' -import type { FormState } from '@/lib/types/form' import { mutate } from '@/utils/raf' import { useForm } from './hook' @@ -84,6 +83,23 @@ export function Form<T = unknown>({ }: FormProps<T>) { const [key, setKey] = useState<string | null>(null) + // onSuccess/onError fire from the action itself rather than from an effect + // watching formState. The effect had to list them as dependencies, so a + // parent re-rendering with fresh inline callbacks re-ran it and fired them a + // second time for a submission that had already been handled. The result is + // known right here, so there is nothing to observe after the fact. + const actionWithCallbacks: FormAction<T> = async (prevState, formData) => { + const result = await action(prevState, formData) + + if (result.status === 200) { + onSuccess?.(result) + } else if (result.status >= 400) { + onError?.(result) + } + + return result + } + const { formAction, onSubmit, @@ -95,32 +111,35 @@ export function Form<T = unknown>({ errors, register, } = useForm({ - action: action as FormAction<unknown>, + action: actionWithCallbacks as FormAction<unknown>, ...(formId && { formId }), initialState: null, }) - // Handle success/error callbacks + // Clear the form a beat after a successful submit. Scheduling goes through + // the rAF write queue to keep it off the layout-read path. + // + // `cancelled` covers unmounting before that queue drains: `resetTimer` is + // assigned inside the queued callback, so without the flag the timer could be + // created after teardown and setKey would fire on a component that is gone. useEffect(() => { - if (!formState) return + if (formState?.status !== 200) return let resetTimer: ReturnType<typeof setTimeout> | undefined - if (formState.status === 200) { - onSuccess?.(formState as FormState<T>) - // Reset form after success - void mutate(() => { - resetTimer = setTimeout(() => { - setKey(crypto.randomUUID()) - }, 2000) - }) - } else if (formState.status >= 400) { - onError?.(formState as FormState<T>) - } + let cancelled = false + + void mutate(() => { + if (cancelled) return + resetTimer = setTimeout(() => { + setKey(crypto.randomUUID()) + }, 2000) + }) return () => { + cancelled = true if (resetTimer) clearTimeout(resetTimer) } - }, [formState, onSuccess, onError]) + }, [formState]) // Reset form function for actions const resetForm = () => { From 2939d79725b3c16580b79d5f9ea63881792ec8cf Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 19:22:09 -0300 Subject: [PATCH 20/22] feat(scripts): add a client-side navigation benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lighthouse audits one cold page load, which cannot see the instant-navigation work at all: partialPrefetching and cachedNavigations change how fast an already-loaded app swaps routes, and a load audit never navigates. Confirmed that the hard way — a 3x3 Lighthouse pass over this branch moved nothing outside noise, because none of it was measuring the thing that changed. `bun run bench:nav` clicks a real <Link> and records two numbers per run: time to the target route's shell, and time to its streamed content. Content is the control — it is server-bound by design, so if it moves, something other than prefetching did it. It throttles to 100ms RTT by default, and that default is load-bearing. On an unthrottled localhost the RTT is ~0, and hiding round trips is the entire reason prefetching exists, so the flags measure as useless-to-harmful. The first version had no throttling and said exactly that. What it found, 7 runs each with all four instant-nav flags toggled together: flags off flags on shell 110ms (spread 84-250) 90ms (spread 78-105) content 1420ms 1915ms The shell median barely moves; the spread is the story. An on-demand shell fetch ranges 84-250ms, a prefetched one 78-105ms — prefetching buys predictability more than raw speed, which is what "instant" actually means to someone clicking a link. The content cost is real, not noise: +537ms unthrottled and +495ms at 100ms RTT. Splitting shell from data serialises them, so this route's 1.5s server sleep starts only after the shell paints. That is a trade — an instant shell plus skeleton against a shorter blank wait — and it is written into the script's header so it does not get rediscovered. Reports the full sample alongside the median, because a 3-run median on a dev machine can be moved by a single outlier. That already bit once in this session: a 3x3 Lighthouse pass appeared to show +285ms TTI, and at n=5 it was one cold run in a small sample. --- lib/scripts/bench-navigation.ts | 158 ++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 159 insertions(+) create mode 100644 lib/scripts/bench-navigation.ts diff --git a/lib/scripts/bench-navigation.ts b/lib/scripts/bench-navigation.ts new file mode 100644 index 00000000..9457d5f1 --- /dev/null +++ b/lib/scripts/bench-navigation.ts @@ -0,0 +1,158 @@ +/** + * Client-side navigation benchmark. + * + * Lighthouse audits a single cold page load, which is the wrong instrument for + * Next's instant-navigation work: `partialPrefetching` and `cachedNavigations` + * change how fast an ALREADY-LOADED app swaps routes, and a load audit never + * navigates. This measures that instead. + * + * For each run it clicks a real <Link> and records two numbers: + * + * shell click -> the target route's heading is on screen. This is the one + * prefetching moves. With a prefetched shell it should be flat and + * small no matter how slow the route's data is. + * content click -> the streamed panel is on screen. Server-bound (the demo + * route sleeps ~1.5s on purpose), so it should NOT improve. It is + * here as a control: if it moves, something other than prefetching + * changed. + * + * Reports the median plus the full sample, because TBT/TTI-style numbers on a + * dev machine are noisy enough that a 3-run median can be moved by one + * outlier — the sample is what tells you whether a delta is real. + * + * Usage: + * bun run build && PORT=3123 bun run start & + * bun run bench:nav # defaults to http://localhost:3123 + * bun run bench:nav --runs 10 --url http://localhost:3000 + * + * Compare two configs by running it against each build and diffing the + * medians. + * + * What it found when it was written (7 runs each, 100ms RTT, this branch on + * next 16.3.0-preview.10), toggling partialPrefetching + cachedNavigations + + * varyParams + optimisticRouting together: + * + * flags off flags on + * shell 110ms (spread 84-250) 90ms (spread 78-105) + * content 1420ms 1915ms + * + * The shell median moves a little, but the useful part is the spread: an + * on-demand shell fetch ranges 84-250ms, a prefetched one 78-105ms. Prefetching + * buys predictability more than raw speed. + * + * The content number is a genuine cost, not noise — it reproduced unthrottled + * (+537ms) and at 100ms RTT (+495ms). Splitting shell from data serialises + * them: the shell paints from cache, and only then does the dynamic request + * start, so this route's 1.5s server sleep begins ~500ms later than it would + * in a single streamed response. Whether that trade is right depends on + * whether an instant shell plus skeleton beats a shorter blank wait for the + * route in question. It is a trade, not a free win. + */ +import { chromium } from 'playwright-core' + +type Sample = { shell: number; content: number } + +const args = process.argv.slice(2) +function arg(name: string, fallback: string) { + const i = args.indexOf(`--${name}`) + return i !== -1 && args[i + 1] ? (args[i + 1] as string) : fallback +} + +const BASE = arg('url', 'http://localhost:3123').replace(/\/+$/, '') +const RUNS = Number(arg('runs', '7')) +const HUB = `${BASE}/instant-nav-demo` + +/** + * Round-trip latency to emulate, in ms. This is not optional decoration: on + * localhost the RTT is ~0, and hiding round trips is the entire reason + * prefetching exists. Measured against an unthrottled local server the flags + * look useless — there is no latency for a prefetched shell to save, so the + * extra request the split introduces for dynamic content shows up as pure + * cost. Throttle, or the number you get is an artifact of the harness. + * + * `--latency 0` opts out, for when you specifically want the no-network case. + */ +const LATENCY = Number(arg('latency', '100')) +// Rough Fast-3G-ish pipe. Bandwidth matters far less than RTT here, but +// leaving it uncapped alongside a latency figure would be an odd mix. +const DOWNLOAD_MBPS = Number(arg('download', '10')) +const UPLOAD_MBPS = Number(arg('upload', '3')) + +const median = (xs: number[]) => + [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? Number.NaN + +async function main() { + const browser = await chromium.launch({ args: ['--no-sandbox'] }) + const page = await browser.newPage() + + if (LATENCY > 0) { + const cdp = await page.context().newCDPSession(page) + await cdp.send('Network.enable') + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: LATENCY, + downloadThroughput: (DOWNLOAD_MBPS * 1_000_000) / 8, + uploadThroughput: (UPLOAD_MBPS * 1_000_000) / 8, + }) + } + + const samples: Sample[] = [] + + for (let i = 0; i < RUNS; i++) { + // Full reload each iteration so every run starts from the same place: a + // freshly hydrated hub. Without this, run 2 onward would measure a warm + // client cache and read far faster than reality. + await page.goto(HUB, { waitUntil: 'load' }) + await page + .getByRole('heading', { name: 'Instant Navigations demo' }) + .waitFor() + + // Let prefetching settle. This is the whole point of the flags — the shell + // should already be in the client cache before the click happens. + await page.waitForTimeout(1000) + + const started = Date.now() + await page + .getByRole('link', { name: /slow route/i }) + .first() + .click() + + await page.getByRole('heading', { name: 'Slow route' }).waitFor() + const shell = Date.now() - started + + await page.getByText(/Streamed in after/).waitFor({ timeout: 30_000 }) + const content = Date.now() - started + + samples.push({ shell, content }) + process.stdout.write('.') + } + + await browser.close() + + const shells = samples.map((s) => s.shell) + const contents = samples.map((s) => s.content) + + console.log(`\n\nclient-side navigation: hub -> /instant-nav-demo/slow`) + console.log( + `${BASE} · ${RUNS} runs · ${ + LATENCY > 0 + ? `${LATENCY}ms RTT, ${DOWNLOAD_MBPS}/${UPLOAD_MBPS} Mbps` + : 'unthrottled (no RTT — prefetching has nothing to hide, see header)' + }\n` + ) + console.log( + ` shell median ${median(shells).toFixed(0)}ms ${JSON.stringify(shells)}` + ) + console.log( + ` content median ${median(contents).toFixed(0)}ms ${JSON.stringify(contents)}` + ) + console.log( + `\n shell is what prefetching moves. content is server-bound (~1.5s by` + + `\n design) and is the control — it should not improve.\n` + ) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/package.json b/package.json index 554fb6ec..f27a629d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "analyze:experimental": "next experimental-analyze", "dev:inspect": "bun ./lib/scripts/dev.ts --inspect", "lighthouse": "bunx @unlighthouse/cli --site http://localhost:3000", + "bench:nav": "bun ./lib/scripts/bench-navigation.ts", "setup:project": "bun ./lib/scripts/setup-project.ts", "test": "bun test", "test:e2e": "playwright test", From ef8cd4e4922b75e95b10aad154fbed6e7969c0dc Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 19:30:35 -0300 Subject: [PATCH 21/22] feat(scripts): add a re-render cost benchmark, and correct the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third measurement angle, covering what the other two structurally cannot: lighthouse audits one cold load and never re-renders; bench:nav measures route swaps, not the work a mounted tree does when state changes. This drives viewport changes and reads main-thread cost from CDP's Performance domain, so it works against a production build rather than React's dev-only profiling hooks. It also corrects something I claimed earlier in this branch. Reverting only lib/hooks/use-device-detection.ts to its pre-fix shape, 5 runs each, 30 viewport changes, 4x CPU throttle: pre-fix fixed script 115ms (spread 106-122) 121ms (spread 99-157) task 850ms 931ms No improvement. I described that fix as the highest-value one of the set on the grounds that a compiler bailout costs auto-memoization for the whole function. That reasoning holds, but the magnitude does not: the hook's body is a few media-query reads and boolean derivations, and losing memoization on work that cheap costs nothing measurable. The resize cascade is dominated by style recalculation (~250ms of ~900ms) and the WebGL canvas. The fix stays — it removes a real cascading-render pattern and a genuine bailout, and useSyncExternalStore is the correct shape for a session-constant synchronous read. But it is hygiene, not a speedup, and the script header says so, so the next person does not reach for a bailout fix expecting a number to move. Scope is documented too: the fluid and flowmap bailout fixes are not reachable from any route (only FlowmapProvider consumes them, and nothing mounts it), so no browser harness can measure them and this one does not pretend to. --- lib/scripts/bench-rerender.ts | 175 ++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 176 insertions(+) create mode 100644 lib/scripts/bench-rerender.ts diff --git a/lib/scripts/bench-rerender.ts b/lib/scripts/bench-rerender.ts new file mode 100644 index 00000000..9876b8f1 --- /dev/null +++ b/lib/scripts/bench-rerender.ts @@ -0,0 +1,175 @@ +/** + * Re-render cost benchmark. + * + * The third measurement angle in this repo, and the one that covers what the + * other two structurally cannot: + * + * bun run lighthouse one cold page load. Blind to re-renders entirely. + * bun run bench:nav client-side route swaps. Measures navigation, not + * the work a mounted tree does when state changes. + * bun run bench:rerender ← this. Main-thread cost of re-rendering an + * already-mounted tree. + * + * Why it exists: React Compiler bailouts (setState synchronously in an effect, + * mutating a useState value, creating components during render) cost you + * auto-memoization for the WHOLE function they occur in. That shows up as + * avoidable work on every subsequent re-render — and nothing else here measures + * it. A Lighthouse pass over exactly those fixes moved nothing outside noise, + * because it never re-renders anything. + * + * How: drives viewport resizes, which is the cheapest way to force a real + * re-render cascade through this app's shared hooks — useWindowSize feeds + * useDeviceDetection, which gates isWebGL and is consumed app-wide. Main-thread + * cost comes from CDP's Performance domain (ScriptDuration / TaskDuration / + * RecalcStyleDuration), which works against a production build. It does not + * depend on React's development-only profiling hooks, so the numbers reflect + * what ships. + * + * SCOPE, and be honest about it: this covers the hooks that are reachable from + * a route. `useDeviceDetection` is, and it is the highest-value fix of the set. + * The fluid and flowmap simulations are NOT — only FlowmapProvider consumes + * them and no route mounts it — so their bailout fixes cannot be measured from + * a browser at all, and this script does not pretend to. + * + * Usage: + * bun run build && PORT=3123 bun run start & + * bun run bench:rerender + * bun run bench:rerender --runs 5 --resizes 40 --path / + * + * A/B by running it against two builds and diffing the medians. Read the full + * sample, not just the median: on a dev machine a small sample's median moves + * on one outlier. + * + * What it found first time out, and it is worth knowing before you reach for a + * compiler-bailout fix expecting a speedup. Reverting ONLY + * lib/hooks/use-device-detection.ts to its pre-fix shape (setState called + * synchronously in an effect, so React Compiler bails out of the whole hook), + * 5 runs each, 30 viewport changes, 4x CPU throttle: + * + * pre-fix fixed + * script 115ms (spread 106-122) 121ms (spread 99-157) + * task 850ms 931ms + * + * No improvement. The fixed build's fastest run beats the pre-fix build's + * fastest, but the spread swamps the difference either way. + * + * The reasoning behind the fix was still right — a bailout does cost + * auto-memoization for the entire function — but the magnitude was not there, + * because this particular hook's body is a handful of media-query reads and + * boolean derivations. Losing memoization on work that cheap costs nothing you + * can measure, and the resize cascade here is dominated by style recalculation + * (~250ms of the ~900ms) and the WebGL canvas, not by that hook's JS. + * + * Treat compiler bailouts as correctness and hygiene, not as a performance + * lever, unless the function they occur in is doing real work. Measure before + * claiming a win. + */ +import { chromium } from 'playwright-core' + +const args = process.argv.slice(2) +function arg(name: string, fallback: string) { + const i = args.indexOf(`--${name}`) + return i !== -1 && args[i + 1] ? (args[i + 1] as string) : fallback +} + +const BASE = arg('url', 'http://localhost:3123').replace(/\/+$/, '') +const PATH = arg('path', '/') +const RUNS = Number(arg('runs', '5')) +const RESIZES = Number(arg('resizes', '30')) +// CPU throttle. Re-render cost is main-thread work, and an unthrottled dev +// machine is fast enough to bury the difference in noise. 4x is roughly a +// mid-range laptop, and it widens the signal without inventing it. +const CPU = Number(arg('cpu', '4')) + +type Sample = { script: number; task: number; style: number; wall: number } + +const median = (xs: number[]) => + [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? Number.NaN + +async function main() { + const browser = await chromium.launch({ args: ['--no-sandbox'] }) + const samples: Sample[] = [] + + for (let run = 0; run < RUNS; run++) { + // Fresh page per run: a re-used page carries warm JIT and a warm client + // cache, so run 2 onward would read faster than anything real. + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + }) + const cdp = await page.context().newCDPSession(page) + await cdp.send('Performance.enable') + if (CPU > 1) { + await cdp.send('Emulation.setCPUThrottlingRate', { rate: CPU }) + } + + await page.goto(`${BASE}${PATH}`, { waitUntil: 'load' }) + // Let hydration and the lazy root canvas settle, so we time steady-state + // re-renders rather than mount. + await page.waitForTimeout(1500) + + const readMetrics = async () => { + const { metrics } = await cdp.send('Performance.getMetrics') + const get = (n: string) => + metrics.find((m: { name: string; value: number }) => m.name === n) + ?.value ?? 0 + return { + script: get('ScriptDuration'), + task: get('TaskDuration'), + style: get('RecalcStyleDuration'), + } + } + + const before = await readMetrics() + const wallStart = Date.now() + + for (let i = 0; i < RESIZES; i++) { + // Alternate widths across the `dt` breakpoint so useDeviceDetection's + // media queries actually flip, instead of resizing within one bucket + // where nothing downstream changes. + const width = i % 2 === 0 ? 700 : 1280 + await page.setViewportSize({ width, height: 800 }) + // One frame, so React commits between resizes rather than batching the + // whole loop into a single update. + await page.evaluate( + () => new Promise((r) => requestAnimationFrame(() => r(null))) + ) + } + + const wall = Date.now() - wallStart + const after = await readMetrics() + + samples.push({ + script: (after.script - before.script) * 1000, + task: (after.task - before.task) * 1000, + style: (after.style - before.style) * 1000, + wall, + }) + + await page.close() + process.stdout.write('.') + } + + await browser.close() + + const col = (k: keyof Sample) => samples.map((s) => s[k]) + const fmt = (k: keyof Sample) => + `${median(col(k)).toFixed(0)}ms ${JSON.stringify(col(k).map((v) => Math.round(v)))}` + + console.log(`\n\nre-render cost: ${RESIZES} viewport changes on ${PATH}`) + console.log( + `${BASE} · ${RUNS} runs · ${CPU > 1 ? `${CPU}x CPU throttle` : 'no CPU throttle'}\n` + ) + console.log(` script ${fmt('script')}`) + console.log(` task ${fmt('task')}`) + console.log(` style ${fmt('style')}`) + console.log(` wall ${fmt('wall')}`) + console.log( + `\n script is the one to watch — main-thread JS for the re-render` + + `\n cascade. task includes style/layout/paint on top of it.\n` + ) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/package.json b/package.json index f27a629d..a41ca5ef 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dev:inspect": "bun ./lib/scripts/dev.ts --inspect", "lighthouse": "bunx @unlighthouse/cli --site http://localhost:3000", "bench:nav": "bun ./lib/scripts/bench-navigation.ts", + "bench:rerender": "bun ./lib/scripts/bench-rerender.ts", "setup:project": "bun ./lib/scripts/setup-project.ts", "test": "bun test", "test:e2e": "playwright test", From 3ce30ad113c7e112db33991b19a29a5e5a893e77 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Wed, 29 Jul 2026 19:44:40 -0300 Subject: [PATCH 22/22] feat(scripts): add a 404-to-home nav scenario so bench:nav runs on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bench:nav was hardcoded to the instant-nav demo routes, which live only on this branch, so it could not run against main at all — it just timed out waiting for a heading that does not exist. It now takes --scenario, with the demo flow as the default and a fallback that works on any build. The fallback is the 404's "Go Home" link, because that is the only real client-side navigation main has. Main's happy path has no navigable surface: the only internal <Link>s are in error.tsx and not-found.tsx, there is no header nav, and nothing links / to /sanity. Prefetching is a <Link> behaviour, so with no links there is nothing to measure. It deliberately reports no `content` number. / is statically prerendered and has nothing streaming, and a second noisier reading of the same shell timing dressed up as a different metric would be worse than no metric. Comparing main (16.2.12, no flags) against this branch (16.3.0-preview.10, all four flags): main shell 116ms (spread 83-223) branch shell 107ms (spread 81-327) No signal — 9ms apart with ~40ms of run-to-run variation and one large outlier each side. That is the expected result and it is the useful half: a static route has no shell-versus-data split for partialPrefetching to make, so it is equally prefetchable before and after. The flags target dynamic routes, which is why the demo scenario shows a difference and this one does not. The header says that explicitly, so nobody reads this scenario as proof the flags are inert. --- lib/scripts/bench-navigation.ts | 170 ++++++++++++++++++++++++-------- 1 file changed, 129 insertions(+), 41 deletions(-) diff --git a/lib/scripts/bench-navigation.ts b/lib/scripts/bench-navigation.ts index 9457d5f1..63ff34ce 100644 --- a/lib/scripts/bench-navigation.ts +++ b/lib/scripts/bench-navigation.ts @@ -6,30 +6,27 @@ * change how fast an ALREADY-LOADED app swaps routes, and a load audit never * navigates. This measures that instead. * - * For each run it clicks a real <Link> and records two numbers: + * For each run it clicks a real <Link> and records: * * shell click -> the target route's heading is on screen. This is the one - * prefetching moves. With a prefetched shell it should be flat and - * small no matter how slow the route's data is. - * content click -> the streamed panel is on screen. Server-bound (the demo - * route sleeps ~1.5s on purpose), so it should NOT improve. It is - * here as a control: if it moves, something other than prefetching - * changed. + * prefetching moves. + * content click -> the target's streamed content is on screen. Server-bound, + * so it should NOT improve — it is the control. Only reported for + * scenarios that actually have something streaming; see below. * * Reports the median plus the full sample, because TBT/TTI-style numbers on a - * dev machine are noisy enough that a 3-run median can be moved by one + * dev machine are noisy enough that a small-sample median can be moved by one * outlier — the sample is what tells you whether a delta is real. * * Usage: * bun run build && PORT=3123 bun run start & - * bun run bench:nav # defaults to http://localhost:3123 - * bun run bench:nav --runs 10 --url http://localhost:3000 + * bun run bench:nav # demo scenario + * bun run bench:nav --scenario notfound # works on any build + * bun run bench:nav --runs 10 --url http://localhost:3124 * - * Compare two configs by running it against each build and diffing the - * medians. - * - * What it found when it was written (7 runs each, 100ms RTT, this branch on - * next 16.3.0-preview.10), toggling partialPrefetching + cachedNavigations + + * --------------------------------------------------------------------------- + * What it found (7 runs each, 100ms RTT, `demo` scenario, this branch on next + * 16.3.0-preview.10), toggling partialPrefetching + cachedNavigations + * varyParams + optimisticRouting together: * * flags off flags on @@ -47,10 +44,82 @@ * in a single streamed response. Whether that trade is right depends on * whether an instant shell plus skeleton beats a shorter blank wait for the * route in question. It is a trade, not a free win. + * + * The `notfound` scenario, same 7 runs and 100ms RTT, comparing `main` (next + * 16.2.12, no instant-nav flags) against this branch (16.3.0-preview.10, all + * four on): + * + * main shell 116ms (spread 83-223) + * branch shell 107ms (spread 81-327) + * + * No signal. 9ms apart with ~40ms of run-to-run variation and one large outlier + * on each side. That is the expected result, and it is the useful half of the + * comparison: `/` is statically prerendered, so there is no shell-versus-data + * split for `partialPrefetching` to make. A static route is equally + * prefetchable before and after. The flags target dynamic routes, which is + * precisely why `demo` shows a difference and this does not — so do not read + * this scenario as evidence the flags do nothing. + * --------------------------------------------------------------------------- */ -import { chromium } from 'playwright-core' +import { chromium, type Page } from 'playwright-core' + +type Scenario = { + /** Path to load before clicking. */ + from: string + /** Heading that confirms the starting page is hydrated. */ + fromHeading: string | RegExp + /** Accessible name of the <Link> to click. */ + linkName: RegExp + /** Heading that confirms the target route's shell has painted. */ + shellHeading: string | RegExp + /** + * Text that confirms the target's server-bound content arrived. Omit when + * the target has nothing streaming — reporting a `content` number for a + * fully static route would just be a second, noisier reading of `shell`. + */ + contentText?: RegExp + describe: string +} -type Sample = { shell: number; content: number } +const SCENARIOS: Record<string, Scenario> = { + /** + * The real measurement. Needs `app/instant-nav-demo/**`, which lives only on + * the Next 16.3 preview branch — those routes exist specifically to create a + * surface where instant navigation is observable. + */ + demo: { + from: '/instant-nav-demo', + fromHeading: 'Instant Navigations demo', + linkName: /slow route/i, + shellHeading: 'Slow route', + contentText: /Streamed in after/, + describe: 'demo hub -> /instant-nav-demo/slow (shell + streamed content)', + }, + + /** + * Fallback that runs against ANY build, including `main`. + * + * `main` has no client-side navigation surface on its happy path: the only + * internal <Link>s are in error.tsx and not-found.tsx, there is no header + * nav, and nothing links `/` to `/sanity`. Since prefetching is a <Link> + * behaviour, the 404's "Go Home" link is the one real navigation available. + * + * Read it narrowly. `/` is statically prerendered, so this times a prefetched + * STATIC shell and nothing else. It cannot show the shell-vs-data + * serialisation trade the `demo` scenario found, because there is no dynamic + * data to serialise — which is exactly why it reports no `content` number. + * Useful as a same-scenario A/B across two builds; not comparable to `demo`. + */ + notfound: { + from: '/__bench-nonexistent-path', + fromHeading: '404', + linkName: /go home/i, + shellHeading: 'Satūs', + describe: '404 -> / via "Go Home" (static shell only, no streamed content)', + }, +} + +type Sample = { shell: number; content?: number } const args = process.argv.slice(2) function arg(name: string, fallback: string) { @@ -60,7 +129,7 @@ function arg(name: string, fallback: string) { const BASE = arg('url', 'http://localhost:3123').replace(/\/+$/, '') const RUNS = Number(arg('runs', '7')) -const HUB = `${BASE}/instant-nav-demo` +const SCENARIO_NAME = arg('scenario', 'demo') /** * Round-trip latency to emulate, in ms. This is not optional decoration: on @@ -81,7 +150,19 @@ const UPLOAD_MBPS = Number(arg('upload', '3')) const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? Number.NaN +function heading(page: Page, name: string | RegExp) { + return page.getByRole('heading', { name }).first() +} + async function main() { + const scenario = SCENARIOS[SCENARIO_NAME] + if (!scenario) { + console.error( + `Unknown scenario "${SCENARIO_NAME}". Available: ${Object.keys(SCENARIOS).join(', ')}` + ) + process.exit(1) + } + const browser = await chromium.launch({ args: ['--no-sandbox'] }) const page = await browser.newPage() @@ -100,39 +181,36 @@ async function main() { for (let i = 0; i < RUNS; i++) { // Full reload each iteration so every run starts from the same place: a - // freshly hydrated hub. Without this, run 2 onward would measure a warm - // client cache and read far faster than reality. - await page.goto(HUB, { waitUntil: 'load' }) - await page - .getByRole('heading', { name: 'Instant Navigations demo' }) - .waitFor() + // freshly hydrated starting page. Without this, run 2 onward would measure + // a warm client cache and read far faster than reality. + await page.goto(`${BASE}${scenario.from}`, { waitUntil: 'load' }) + await heading(page, scenario.fromHeading).waitFor() // Let prefetching settle. This is the whole point of the flags — the shell // should already be in the client cache before the click happens. await page.waitForTimeout(1000) const started = Date.now() - await page - .getByRole('link', { name: /slow route/i }) - .first() - .click() + await page.getByRole('link', { name: scenario.linkName }).first().click() - await page.getByRole('heading', { name: 'Slow route' }).waitFor() + await heading(page, scenario.shellHeading).waitFor() const shell = Date.now() - started - await page.getByText(/Streamed in after/).waitFor({ timeout: 30_000 }) - const content = Date.now() - started + if (scenario.contentText) { + await page.getByText(scenario.contentText).waitFor({ timeout: 30_000 }) + samples.push({ shell, content: Date.now() - started }) + } else { + samples.push({ shell }) + } - samples.push({ shell, content }) process.stdout.write('.') } await browser.close() const shells = samples.map((s) => s.shell) - const contents = samples.map((s) => s.content) - console.log(`\n\nclient-side navigation: hub -> /instant-nav-demo/slow`) + console.log(`\n\nclient-side navigation: ${scenario.describe}`) console.log( `${BASE} · ${RUNS} runs · ${ LATENCY > 0 @@ -143,13 +221,23 @@ async function main() { console.log( ` shell median ${median(shells).toFixed(0)}ms ${JSON.stringify(shells)}` ) - console.log( - ` content median ${median(contents).toFixed(0)}ms ${JSON.stringify(contents)}` - ) - console.log( - `\n shell is what prefetching moves. content is server-bound (~1.5s by` + - `\n design) and is the control — it should not improve.\n` - ) + + if (scenario.contentText) { + const contents = samples.map((s) => s.content ?? Number.NaN) + console.log( + ` content median ${median(contents).toFixed(0)}ms ${JSON.stringify(contents)}` + ) + console.log( + `\n shell is what prefetching moves. content is server-bound and is the` + + `\n control — it should not improve.\n` + ) + } else { + console.log( + `\n no content metric: this target has nothing streaming, so the only` + + `\n honest number is the shell. Compare against the same scenario on` + + `\n another build, not against the demo scenario.\n` + ) + } } main().catch((error) => {