Skip to content
3 changes: 3 additions & 0 deletions app/(app)/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 the authenticated app. Because it lives inside the (app)
// route group it renders within the app shell (sidebar/nav stay put), so a
Expand All @@ -20,6 +21,8 @@ export default function AppError({
const t = useTranslations();
useEffect(() => {
console.error('[Bubaly] app route error:', error);
// A chunk that failed to load fails again on reset(); a reload fetches it.
reloadOnceForChunkFailure(error);
}, [error]);

return (
Expand Down
3 changes: 3 additions & 0 deletions app/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { useTranslations } from '@/components/i18n/locale-provider';
import { reloadOnceForChunkFailure } from '@/lib/utils/stale-bundle-reload';

export default function GlobalError({
error,
Expand All @@ -16,6 +17,8 @@ export default function GlobalError({
useEffect(() => {
// Surfaced to the browser console and any attached logging service.
console.error('[Bubaly] route error:', error);
// A chunk that failed to load fails again on reset(); a reload fetches it.
reloadOnceForChunkFailure(error);
}, [error]);

return (
Expand Down
8 changes: 6 additions & 2 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { useEffect } from 'react';
import { translate } from '@/lib/i18n/translate';
import { GLOBAL_ERROR_MESSAGES } from '@/lib/i18n/global-error-messages';
import { reloadOnceForChunkFailure } from '@/lib/utils/stale-bundle-reload';

// Not `useTranslations()`, and not a LocaleProvider of its own either.
//
Expand Down Expand Up @@ -36,6 +37,8 @@ export default function GlobalError({
const t = (key: string) => translate(GLOBAL_ERROR_MESSAGES, key);
useEffect(() => {
console.error('[Bubaly] root error:', error);
// A chunk that failed to load fails again on reset(); a reload fetches it.
reloadOnceForChunkFailure(error);
}, [error]);

return (
Expand Down Expand Up @@ -94,7 +97,8 @@ export default function GlobalError({
fontSize: '0.875rem',
fontWeight: 600,
color: '#ffffff',
background: '#7c5dff',
// The brand (--brand), 5.36:1 under white; #7c5dff was 4.31:1.
background: '#744be8',
}}
>
{t('globalError.tryAgain')}
Expand All @@ -116,7 +120,7 @@ export default function GlobalError({
</a>
</div>
{error.digest && (
<p style={{ margin: 0, fontSize: '0.75rem', color: '#6b7688' }}>
<p style={{ margin: 0, fontSize: '0.75rem', color: '#7d889b' }}>
Reference:{' '}
<code style={{ background: 'rgba(237,240,247,0.08)', borderRadius: 4, padding: '0.1rem 0.35rem' }}>
{error.digest}
Expand Down
3 changes: 3 additions & 0 deletions components/app/section-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import { refusalFromDigest } from '@/lib/actions/refusal';

/**
Expand All @@ -28,6 +29,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]);

// A server action that refused its input says why in the digest
Expand Down
76 changes: 76 additions & 0 deletions lib/utils/stale-bundle-reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// 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 | 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 parsed = JSON.parse(raw) as Partial<LastReload>;
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();
const last = readLast();
if (last === undefined || !shouldReloadForChunkFailure(error, path, last, now)) return false;
try {
sessionStorage.setItem(RELOAD_KEY, JSON.stringify({ path, at: now }));
} catch {
return false;
}
window.location.reload();
return true;
}
16 changes: 16 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -33,6 +38,17 @@ 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 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 }] });
return config;
},
images: {
remotePatterns: [
{ protocol: 'https', hostname: '*.supabase.co' },
Expand Down
87 changes: 87 additions & 0 deletions scripts/react-hydration-replay-fix.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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 <div>, an <svg>'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. 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 <svg>, an <h1>, a <nav>).
//
// The fix is React's own, as React 19.3 ships it (and so as Next 16 bundles
// it): when the replayed host fiber is the current hydration parent, put the
// parent back on its host parent and the cursor back on the fiber's node, or,
// if hydration had been suspended for an insertion below it, resume it. It
// goes where React put it, in the replay's HostComponent case, and calls only
// hydration helpers that are identical in 19.2 and 19.3.
//
// A React that already carries the fix (Next 16's) is left as it is. A React
// this does not recognise fails the build rather than being skipped, so an
// upgrade is a prompt to check the fix and remove this loader and its rule in
// next.config.mjs.

const MARKER = '/* bubaly: react/react#37584, as React 19.3 ships it */';
// The replay's HostComponent case: `case 5: resetHooksOnUnwind(<fiber>);`.
// The production build calls the replayed fiber `next`, the development build
// `unitOfWork`.
const SITE = /case 5:\s*resetHooksOnUnwind\((\w+)\);/g;
// What React 19.3 has right after that site.
const ALREADY_FIXED = /^\s*(?:var \w+ = \w+;\s*)?\w+ === hydrationParentFiber &&\s*\(\s*isHydrating\s*\?\s*\(\s*popToNextHostParent\(/;
// Identifiers the fix uses; each must be declared in the file it is injected
// into (the production and development builds declare them in differently
// shaped var lists, so these match the declaration, not a line).
const REQUIRED = [
['hydrationParentFiber', /\bhydrationParentFiber\s*=\s*null\b/],
['nextHydratableInstance', /\bnextHydratableInstance\s*=\s*null\b/],
['isHydrating', /\bisHydrating\s*=\s*!1\b/],
['popToNextHostParent', /function popToNextHostParent\(fiber\)/],
];

function fix(fiber) {
return [
'',
MARKER,
`var replayedHostFiber = ${fiber};`,
'replayedHostFiber === hydrationParentFiber &&',
' (isHydrating',
' ? (popToNextHostParent(replayedHostFiber),',
' 5 === replayedHostFiber.tag &&',
' null != replayedHostFiber.stateNode &&',
' (nextHydratableInstance = replayedHostFiber.stateNode))',
' : (popToNextHostParent(replayedHostFiber), (isHydrating = !0)));',
].join('\n');
}

function applyReplayFix(source, file = 'react-dom-client') {
if (source.includes(MARKER)) return source;
const sites = [...source.matchAll(SITE)];
if (sites.length !== 1) {
throw new Error(
`react-hydration-replay-fix: expected exactly one "case 5: resetHooksOnUnwind(…);" in ${file}, found ${sites.length}. ` +
'Next\'s bundled React has changed: check whether it already fixes react/react#37584 ' +
'and, if so, remove scripts/react-hydration-replay-fix.cjs and its rule in next.config.mjs.',
);
}
const [site] = sites;
const end = site.index + site[0].length;
if (ALREADY_FIXED.test(source.slice(end, end + 400))) return source;
const missing = REQUIRED.filter(([, declared]) => !declared.test(source)).map(([name]) => name);
if (missing.length) {
throw new Error(`react-hydration-replay-fix: ${file} no longer defines ${missing.join(', ')}; the fix cannot be applied safely.`);
}
return `${source.slice(0, end)}${fix(site[1])}${source.slice(end)}`;
}

module.exports = function reactHydrationReplayFix(source) {
return applyReplayFix(source, this && this.resourcePath);
};
module.exports.applyReplayFix = applyReplayFix;
module.exports.MARKER = MARKER;
Loading
Loading