715c671b67
* fix(app): stop transient login errors flashing the full-screen fallback The BankID login landing (/auth/callback then the /select-company picker) fires several Supabase auth/DB queries right as the session cookies are set, so a transient failure there (most often a refresh-token rotation race, seen in prod as "Invalid Refresh Token: Already Used/Not Found" on /middleware, or a stale JS chunk after a deploy) threw during render. Only (dashboard) had an error.tsx, so these escaped every boundary and hit app/global-error.tsx, blanking the whole document with a bare "Nagot gick fel" screen for ~1s before the next request repainted and logged the user in as usual. Add an app-level error.tsx (AppErrorBoundary) that catches those segments and their layouts, and harden global-error.tsx. Both recover via a single guarded hard reload instead of React reset(): a reload re-runs middleware (fresh rotated auth cookie) and fetches a fresh bundle (ChunkLoadError after a deploy), matching the browser-navigation self-heal these transients already relied on, whereas reset() re-renders against the same stale payload/bundle. A per-path sessionStorage time-guard bounds it to one reload so a persistent error shows the manual fallback instead of looping. Reported via support: transient "Nagot gick fel" flash on BankID login. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): make the error-boundary reload guard loop-proof (review) CodeRabbit and the PR Agent both flagged that the 12s time-window guard could still loop if a failing render takes longer than the window (e.g. a slow SSR that eventually throws). Replace the time window with a per-path, per-tab-session one-shot flag, so the auto-reload fires at most once per path regardless of timing and a genuinely persistent error settles on the manual fallback. sessionStorage is per-tab, so a fresh visit (or a different path) still gets a fresh auto-recovery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): claim the reload flag atomically + add support escape hatch (review) CodeRabbit round 2: - Major: the one-shot flag was written in the effect but the reload fired even if the write threw (sessionStorage quota full), so it could reload forever without ever recording the attempt. Claim the flag inside decideInitialPhase instead, so entering the 'reloading' phase guarantees the flag persisted; any write failure falls through to the manual 'fallback' (no reload). - Minor: give global-error.tsx a support escape hatch. It can't use SupportLink (no providers when the root layout fails), so use a dependency-free mailto to the hardcoded support address. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
// Last-resort boundary: only fires when the root layout itself throws, so it
|
|
// renders its own <html> and must stay free of app providers/components
|
|
// (NextIntlClientProvider, CompanyContext, etc. are not mounted here). It is
|
|
// also server-rendered when the root layout throws during SSR, so the lazy
|
|
// initializer must never touch window without a guard.
|
|
//
|
|
// Like app/error.tsx, transient failures here (e.g. a token race in the first
|
|
// request after login) heal on a fresh request, so auto-reload ONCE before
|
|
// showing the manual fallback. A per-path, per-tab-session flag (a monotonic
|
|
// one-shot, not a time window that could still loop on a slow failing render)
|
|
// keeps the single reload from becoming a loop on a genuinely broken root layout.
|
|
const RELOAD_FLAG_PREFIX = "accounted:global-error-reloaded:";
|
|
|
|
function reloadKey(): string {
|
|
return (
|
|
RELOAD_FLAG_PREFIX +
|
|
(typeof window !== "undefined" ? window.location.pathname : "")
|
|
);
|
|
}
|
|
|
|
// support@gnubok.se is hardcoded on purpose: this boundary renders when the root
|
|
// layout failed, so the branding service and any provider are unavailable here.
|
|
const SUPPORT_EMAIL = "support@gnubok.se";
|
|
|
|
function decideInitialPhase(): "reloading" | "fallback" {
|
|
if (typeof window === "undefined") return "reloading";
|
|
try {
|
|
if (window.sessionStorage.getItem(reloadKey())) return "fallback";
|
|
// Claim the one-shot and reload only if it persisted: writing here (not in
|
|
// the effect) keeps "mark reloaded" and "decide to reload" atomic, so a
|
|
// failed write (quota full / blocked) falls through to 'fallback' instead
|
|
// of reloading forever without ever recording it.
|
|
window.sessionStorage.setItem(reloadKey(), "1");
|
|
return "reloading";
|
|
} catch {
|
|
return "fallback";
|
|
}
|
|
}
|
|
|
|
export default function GlobalError({
|
|
error,
|
|
}: {
|
|
error: Error & { digest?: string };
|
|
reset: () => void;
|
|
}) {
|
|
const [phase] = useState<"reloading" | "fallback">(decideInitialPhase);
|
|
|
|
useEffect(() => {
|
|
console.error("[global] Unhandled error:", error);
|
|
// The one-shot flag is already claimed in decideInitialPhase, so reaching
|
|
// 'reloading' guarantees it persisted: reload exactly once.
|
|
if (phase === "reloading") window.location.reload();
|
|
}, [phase, error]);
|
|
|
|
return (
|
|
<html lang="sv" translate="no">
|
|
<head>
|
|
<meta name="google" content="notranslate" />
|
|
</head>
|
|
<body>
|
|
{phase === "fallback" ? (
|
|
<div className="flex min-h-screen items-center justify-center p-8">
|
|
<div className="text-center space-y-4">
|
|
<h2 className="text-xl font-semibold">Något gick fel</h2>
|
|
<p className="text-muted-foreground">
|
|
Ett oväntat fel inträffade. Försök igen eller{" "}
|
|
<a
|
|
href={`mailto:${SUPPORT_EMAIL}?subject=Oväntat fel`}
|
|
className="underline underline-offset-4 hover:text-foreground"
|
|
>
|
|
kontakta support
|
|
</a>{" "}
|
|
om problemet kvarstår.
|
|
</p>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
|
>
|
|
Försök igen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|