From d1459b26d84dabf69dc7484a8b58e95570673aa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:15:51 +0000 Subject: [PATCH 1/4] Page audit: backport React's hydration replay fix (react/react#37584) The page audit's last intermittent defect: React #418 on about 0.7% of signed-in loads, a different page each time, on a tree identical on the server and the client. The React that Next 15.5 bundles (19.2.0-canary-0bdb9206) replays a host element that suspended mid-hydration without rewinding the hydration cursor, so the replay tries to claim the element against its own first child. An instrumented build showed that shape in 7 of 7 hits. A webpack loader applies the upstream fix to Next's compiled react-dom-client (production and development, stable and experimental): before the replay, a host fiber that is the current hydration parent and has claimed a node puts the cursor back on that node. It fails the build, not silently, if Next's React changes shape, so an upgrade prompts its removal. The owner approved the backport. tests/react-hydration-replay-fix.test.ts holds that it applies to the installed React, lands at the top of replaySuspendedUnitOfWork, is idempotent, refuses a React it does not recognise, and is wired into the build. Verified in a production build: the framework chunk carries the rewind with its identifiers mapped to isHydrating, hydrationParentFiber, nextHydratableInstance and popToNextHostParent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K --- next.config.mjs | 15 +++++ scripts/react-hydration-replay-fix.cjs | 75 ++++++++++++++++++++++++ tests/react-hydration-replay-fix.test.ts | 55 +++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 scripts/react-hydration-replay-fix.cjs create mode 100644 tests/react-hydration-replay-fix.test.ts diff --git a/next.config.mjs b/next.config.mjs index 0b3d3afbd..1365aaa77 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,11 @@ import { buildContentSecurityPolicy } from './lib/security/csp.mjs'; import { parseBuildRevision } from './lib/build-identity.mjs'; +// Next's bundled react-dom client, production and development, stable and +// experimental: where scripts/react-hydration-replay-fix.cjs applies. +const REACT_DOM_CLIENT = /[\\/]next[\\/]dist[\\/]compiled[\\/]react-dom(?:-experimental)?[\\/]cjs[\\/]react-dom-client\.(?:production|development)\.js$/; +const REACT_HYDRATION_REPLAY_FIX = new URL('./scripts/react-hydration-replay-fix.cjs', import.meta.url).pathname; + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, @@ -33,6 +38,16 @@ const nextConfig = { }, // Pin tracing to this project (a stray lockfile in the home dir confuses inference). outputFileTracingRoot: import.meta.dirname, + // React #418 on about 0.7% of signed-in loads, on a tree identical on server + // and client: react/react#37584 in the React Next 15.5 bundles, where a host + // element that suspends mid-hydration is replayed without rewinding the + // hydration cursor. The loader backports the upstream fix into Next's own + // react-dom client and fails the build if that React changes shape. + // tests/react-hydration-replay-fix.test.ts holds the wiring. + webpack(config) { + config.module.rules.push({ test: REACT_DOM_CLIENT, use: [{ loader: REACT_HYDRATION_REPLAY_FIX }] }); + return config; + }, images: { remotePatterns: [ { protocol: 'https', hostname: '*.supabase.co' }, diff --git a/scripts/react-hydration-replay-fix.cjs b/scripts/react-hydration-replay-fix.cjs new file mode 100644 index 000000000..976a849a0 --- /dev/null +++ b/scripts/react-hydration-replay-fix.cjs @@ -0,0 +1,75 @@ +// A build-time backport of the upstream fix for react/react#37584 into the +// React that Next 15.5 bundles (19.2.0-canary-0bdb9206 in 15.5.25 and 15.5.26). +// +// The bug: when a host element (a
, an 's parent) suspends during +// hydration on a time-sliced render — its children include a React Server +// Components chunk that has not arrived yet — React replays that element with +// replaySuspendedUnitOfWork. The first attempt had already claimed the +// element's DOM node and moved the hydration cursor inside it; the replay does +// not move it back, so it tries to claim the element against its own first +// child and throws React #418 ("Hydration failed because the server rendered +// HTML didn't match the client") on a tree that is identical. React then +// discards the server HTML and renders the whole boundary again. +// +// The page audit found it on about 0.7% of signed-in loads under load, a +// different page each time. An instrumented build showed the same shape in 7 of +// 7 hits: React hydrating an element while its cursor pointed at that +// element's own first child (an , an

, a

{error.digest && ( -

+

Reference:{' '} {error.digest} diff --git a/components/app/section-error.tsx b/components/app/section-error.tsx index 9482255f0..b7c51a264 100644 --- a/components/app/section-error.tsx +++ b/components/app/section-error.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { AlertTriangle, RotateCw, Home } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useTranslations } from '@/components/i18n/locale-provider'; +import { reloadOnceForChunkFailure } from '@/lib/utils/stale-bundle-reload'; /** * Error boundary for one authenticated SECTION, mounted inside that section's @@ -27,6 +28,8 @@ export function SectionError({ const t = useTranslations(); useEffect(() => { console.error('[Bubaly] section error:', error); + // A chunk that failed to load fails again on reset(); a reload fetches it. + reloadOnceForChunkFailure(error); }, [error]); return ( diff --git a/lib/utils/stale-bundle-reload.ts b/lib/utils/stale-bundle-reload.ts new file mode 100644 index 000000000..b39069ba1 --- /dev/null +++ b/lib/utils/stale-bundle-reload.ts @@ -0,0 +1,69 @@ +// A page whose JavaScript chunk failed to load lands on an error boundary +// whose "Try again" is reset(), and reset() re-renders the same bundle, which +// asks for the same missing chunk and fails the same way. The chunk is missing +// because one request for it failed in transit, or because a deploy replaced +// it while the tab was open. Either way only a reload fetches it again. +// +// The page audit's production crawl hit it on 8 of 786 loads: one chunk +// answered 502, the chunk that answered was not JavaScript, and the page sat +// on "Something went wrong" until the visitor reloaded it themselves. +// +// So a boundary that catches a chunk failure reloads the page for them: once +// per page, and not again within RELOAD_WINDOW_MS, so a chunk that is really +// gone leaves the error page up rather than reloading forever. The Kitchen +// Display keeps its own, stricter policy (lib/display/recover.ts). +import { isStaleBundleError } from '@/lib/display/recover'; + +const RELOAD_KEY = 'bubaly.staleBundleReload'; +export const RELOAD_WINDOW_MS = 60_000; + +export interface LastReload { path: string; at: number } + +/** The error names a chunk that failed to load (by its name or its message). */ +export function isChunkFailure(error: { name?: string; message?: string } | null | undefined): boolean { + if (!error) return false; + return isStaleBundleError(`${error.name ?? ''}: ${error.message ?? ''}`); +} + +/** Whether a boundary showing `error` on `path` should reload the page now. */ +export function shouldReloadForChunkFailure( + error: { name?: string; message?: string } | null | undefined, + path: string, + last: LastReload | null, + now: number, +): boolean { + if (!isChunkFailure(error)) return false; + return !(last && last.path === path && now - last.at < RELOAD_WINDOW_MS); +} + +function readLast(): LastReload | null { + try { + const raw = sessionStorage.getItem(RELOAD_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return typeof parsed.path === 'string' && typeof parsed.at === 'number' ? { path: parsed.path, at: parsed.at } : null; + } catch { + return null; + } +} + +/** + * Called from an error boundary's effect. Reloads the page and returns true + * when the error is a chunk failure this page has not just reloaded for; + * otherwise returns false and the boundary shows its card. Without storage + * (blocked, private mode) it does not reload, since it could not tell a + * second failure from the first. + */ +export function reloadOnceForChunkFailure(error: { name?: string; message?: string } | null | undefined): boolean { + if (typeof window === 'undefined') return false; + const path = window.location.pathname + window.location.search; + const now = Date.now(); + if (!shouldReloadForChunkFailure(error, path, readLast(), now)) return false; + try { + sessionStorage.setItem(RELOAD_KEY, JSON.stringify({ path, at: now })); + } catch { + return false; + } + window.location.reload(); + return true; +} diff --git a/tests/a-failed-chunk-reloads-the-page.test.ts b/tests/a-failed-chunk-reloads-the-page.test.ts new file mode 100644 index 000000000..d0425ac12 --- /dev/null +++ b/tests/a-failed-chunk-reloads-the-page.test.ts @@ -0,0 +1,78 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import GlobalError from '@/app/global-error'; +import { contrastRatio } from '@/lib/utils/readable-text'; +import { RELOAD_WINDOW_MS, shouldReloadForChunkFailure } from '@/lib/utils/stale-bundle-reload'; + +// The production crawl's 8 of 786: one JavaScript chunk answered 502 and the +// page sat on the error boundary, whose "Try again" re-renders the same bundle +// and asks for the same chunk. A chunk failure reloads the page once instead. +// The same crawl's axe pass caught the root error page's button, white on +// #7c5dff at 4.31:1. + +const CHUNK = { name: 'ChunkLoadError', message: 'Loading chunk 7177 failed.\n(error: https://www.bubaly.com/_next/static/chunks/app/layout-51451d37b525791a.js)' }; + +describe('a boundary that catches a chunk that failed to load', () => { + it('reloads the page once', () => { + expect(shouldReloadForChunkFailure(CHUNK, '/dashboard/memories', null, 1_000)).toBe(true); + expect(shouldReloadForChunkFailure({ name: 'Error', message: 'Failed to fetch dynamically imported module: https://x/_next/a.js' }, '/a', null, 1)).toBe(true); + }); + + it('does not reload again for the same page within the window, so a chunk that is gone cannot loop', () => { + const last = { path: '/dashboard/memories', at: 1_000 }; + expect(shouldReloadForChunkFailure(CHUNK, '/dashboard/memories', last, 1_000 + RELOAD_WINDOW_MS - 1)).toBe(false); + expect(shouldReloadForChunkFailure(CHUNK, '/dashboard/memories', last, 1_000 + RELOAD_WINDOW_MS)).toBe(true); + expect(shouldReloadForChunkFailure(CHUNK, '/wallet/invest', last, 1_001)).toBe(true); + }); + + it('leaves every other error to the card and its Try again', () => { + expect(shouldReloadForChunkFailure({ name: 'TypeError', message: 'x is not a function' }, '/a', null, 1)).toBe(false); + expect(shouldReloadForChunkFailure(null, '/a', null, 1)).toBe(false); + }); + + it('is what every error boundary in the app does', () => { + const boundaries: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) walk(p); + else if (entry === 'error.tsx' || entry === 'global-error.tsx') boundaries.push(p); + } + }; + walk('app'); + expect(boundaries.length).toBeGreaterThan(10); + const sectionError = readFileSync('components/app/section-error.tsx', 'utf8'); + expect(sectionError).toMatch(/reloadOnceForChunkFailure\(error\)/); + const bare = boundaries.filter((file) => { + const src = readFileSync(file, 'utf8'); + // The Kitchen Display hard-reloads on its own, stricter policy. + if (/isStaleBundleError\(/.test(src) && /window\.location\.reload\(\)/.test(src)) return false; + if (/from '@\/components\/app\/section-error'/.test(src)) return false; + return !/reloadOnceForChunkFailure\(error\)/.test(src); + }); + expect(bare).toEqual([]); + }); +}); + +describe('the root error page', () => { + const html = renderToStaticMarkup(createElement(GlobalError, { error: Object.assign(new Error('x'), { digest: 'abc' }), reset: () => {} })); + const style = (tag: RegExp) => { + const m = tag.exec(html); + expect(m, String(tag)).not.toBeNull(); + return Object.fromEntries(m![1].split(';').filter(Boolean).map((d) => d.split(':').map((s) => s.trim()) as [string, string])); + }; + + it('draws its Try again button at AA', () => { + const button = style(/]*style="([^"]+)"/); + expect(contrastRatio(button.color, button.background)!).toBeGreaterThanOrEqual(4.5); + }); + + it('draws its reference line at AA on the page background', () => { + const body = style(/]*style="([^"]+)"/); + const reference = style(/

Reference:/); + expect(contrastRatio(reference.color, body.background)!).toBeGreaterThanOrEqual(4.5); + }); +}); From cbb0dc456b1346ef23e3d54e41eae5453e4f9549 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:45:37 +0000 Subject: [PATCH 3/4] React replay backport: inject React 19.3's own fix, and leave a React that has it alone Review on #605 pointed out that Next 16 (#602) bundles a React that already fixes react/react#37584, and that the loader would reject it. Reading that React (Next 16.3.6's react-dom-client, 19.3.0-canary-cbb046ab) shows where React put the fix: in the replay's HostComponent case, right after the hooks reset, the replayed fiber that is the current hydration parent puts the parent back on its host parent and the cursor back on its node, or resumes hydration if it had been suspended for an insertion below it. The loader now injects exactly that code at exactly that site in Next 15.5's React (production and development, stable and experimental). The hydration helpers it calls (popToNextHostParent, tryToClaimNextHydratableInstance, resetHydrationState, popHydrationState) are identical in 19.2 and 19.3. A React whose site already carries the fix is returned unchanged, so #602 can land after this without touching it; a React without the site still fails the build. The test adds Next 16's case verbatim as a fixture that must come back unchanged, checks the injection sits between the hooks reset and the default branch, and parses the patched React. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K --- lib/utils/stale-bundle-reload.ts | 15 ++- next.config.mjs | 5 +- scripts/react-hydration-replay-fix.cjs | 92 +++++++++++-------- tests/a-failed-chunk-reloads-the-page.test.ts | 63 ++++++++++++- tests/react-hydration-replay-fix.test.ts | 63 +++++++++---- 5 files changed, 172 insertions(+), 66 deletions(-) diff --git a/lib/utils/stale-bundle-reload.ts b/lib/utils/stale-bundle-reload.ts index b39069ba1..8e94c957e 100644 --- a/lib/utils/stale-bundle-reload.ts +++ b/lib/utils/stale-bundle-reload.ts @@ -36,10 +36,16 @@ export function shouldReloadForChunkFailure( return !(last && last.path === path && now - last.at < RELOAD_WINDOW_MS); } -function readLast(): LastReload | null { +function readLast(): LastReload | null | undefined { + let raw: string | null; + try { + raw = sessionStorage.getItem(RELOAD_KEY); + } catch { + // Without a readable marker, even a successful write cannot prevent a loop. + return undefined; + } + if (!raw) return null; try { - const raw = sessionStorage.getItem(RELOAD_KEY); - if (!raw) return null; const parsed = JSON.parse(raw) as Partial; return typeof parsed.path === 'string' && typeof parsed.at === 'number' ? { path: parsed.path, at: parsed.at } : null; } catch { @@ -58,7 +64,8 @@ export function reloadOnceForChunkFailure(error: { name?: string; message?: stri if (typeof window === 'undefined') return false; const path = window.location.pathname + window.location.search; const now = Date.now(); - if (!shouldReloadForChunkFailure(error, path, readLast(), now)) return false; + const last = readLast(); + if (last === undefined || !shouldReloadForChunkFailure(error, path, last, now)) return false; try { sessionStorage.setItem(RELOAD_KEY, JSON.stringify({ path, at: now })); } catch { diff --git a/next.config.mjs b/next.config.mjs index 1365aaa77..eacb0bca5 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -41,8 +41,9 @@ const nextConfig = { // React #418 on about 0.7% of signed-in loads, on a tree identical on server // and client: react/react#37584 in the React Next 15.5 bundles, where a host // element that suspends mid-hydration is replayed without rewinding the - // hydration cursor. The loader backports the upstream fix into Next's own - // react-dom client and fails the build if that React changes shape. + // hydration cursor. The loader backports React 19.3's fix into Next's own + // react-dom client, leaves a React that already has it (Next 16's) alone, and + // fails the build on a React it does not recognise. // tests/react-hydration-replay-fix.test.ts holds the wiring. webpack(config) { config.module.rules.push({ test: REACT_DOM_CLIENT, use: [{ loader: REACT_HYDRATION_REPLAY_FIX }] }); diff --git a/scripts/react-hydration-replay-fix.cjs b/scripts/react-hydration-replay-fix.cjs index 976a849a0..43781c0d7 100644 --- a/scripts/react-hydration-replay-fix.cjs +++ b/scripts/react-hydration-replay-fix.cjs @@ -1,48 +1,43 @@ -// A build-time backport of the upstream fix for react/react#37584 into the -// React that Next 15.5 bundles (19.2.0-canary-0bdb9206 in 15.5.25 and 15.5.26). +// A build-time backport of React's fix for react/react#37584 into the React +// that Next 15.5 bundles (19.2.0-canary-0bdb9206 in 15.5.25 and 15.5.26). // // The bug: when a host element (a

, an 's parent) suspends during // hydration on a time-sliced render — its children include a React Server -// Components chunk that has not arrived yet — React replays that element with -// replaySuspendedUnitOfWork. The first attempt had already claimed the -// element's DOM node and moved the hydration cursor inside it; the replay does -// not move it back, so it tries to claim the element against its own first -// child and throws React #418 ("Hydration failed because the server rendered -// HTML didn't match the client") on a tree that is identical. React then -// discards the server HTML and renders the whole boundary again. +// Components chunk that has not arrived yet — React replays that element. The +// first attempt had already claimed the element's DOM node and moved the +// hydration cursor inside it; the replay does not move it back, so it tries to +// claim the element against its own first child and throws React #418 +// ("Hydration failed because the server rendered HTML didn't match the +// client") on a tree that is identical. React then discards the server HTML +// and renders the whole boundary again. // // The page audit found it on about 0.7% of signed-in loads under load, a // different page each time. An instrumented build showed the same shape in 7 of // 7 hits: React hydrating an element while its cursor pointed at that // element's own first child (an , an

, a