Files
accounted/components/system/AppErrorBoundary.tsx
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

107 lines
4.6 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { SupportLink } from '@/components/ui/support-link'
/**
* App-wide error boundary (rendered from app/error.tsx). It catches any render
* error below the root layout that has no closer boundary, which includes the
* auth and onboarding segments AND their layouts (an error.tsx never catches
* its own sibling layout, only a parent boundary does).
*
* Those segments fire several Supabase auth/DB queries at the exact moment a
* session is established (BankID login -> /auth/callback -> the /select-company
* picker). A transient failure there, most often a Supabase refresh-token
* rotation race in the first request after the cookies are set, or a stale JS
* chunk right after a deploy, used to escape every boundary and hit
* app/global-error.tsx, which blanks the whole document with a bare "Nagot gick
* fel" screen for a second before the next request repainted and logged the
* user in normally.
*
* Recovery is a single hard reload, NOT React's reset(). A reload re-runs
* middleware (picking up the freshly rotated auth cookie) and fetches a fresh
* bundle (recovering a ChunkLoadError after a deploy): the same
* browser-navigation heal these transients already relied on. A soft reset()
* re-renders against the same stale server payload / bundle and just re-throws.
*
* A per-path, per-tab-session sessionStorage flag makes the auto-reload fire at
* most once per path, so a genuinely persistent error settles on the manual
* fallback instead of looping. This is a monotonic one-shot, not a time window:
* a time window can still loop if each failing render takes longer than the
* window (slow SSR that eventually throws), whereas the flag caps auto-reloads
* regardless of timing. sessionStorage is per-tab and cleared on tab close, so
* a fresh visit gets a fresh auto-recovery; a different path keeps its own flag.
*/
const RELOAD_FLAG_PREFIX = 'accounted:app-error-reloaded:'
function reloadKey(): string {
return (
RELOAD_FLAG_PREFIX +
(typeof window !== 'undefined' ? window.location.pathname : '')
)
}
// Decided once at mount via a lazy state initializer, never in an effect, so
// the auto-reload stays off React's setState-in-effect path. 'reloading'
// renders nothing while the single hard reload is issued; 'fallback' shows the
// manual UI. On the server (root layout threw during SSR) there is no window,
// so it starts in 'reloading' -> renders nothing and defers to the client,
// avoiding both a server crash and a flash in the common recover-invisibly case.
function decideInitialPhase(): 'reloading' | 'fallback' {
if (typeof window === 'undefined') return 'reloading'
try {
// Already auto-reloaded this path in this tab session: don't loop, show the
// manual fallback.
if (window.sessionStorage.getItem(reloadKey())) return 'fallback'
// Claim the one-shot and reload only if the flag actually 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 {
// sessionStorage blocked or full: don't risk a reload loop, show the
// fallback so the user always has an explicit way forward.
return 'fallback'
}
}
export function AppErrorBoundary({
error,
scope,
}: {
error: Error & { digest?: string }
scope: string
}) {
const [phase] = useState<'reloading' | 'fallback'>(decideInitialPhase)
useEffect(() => {
console.error(
`[${scope}] Unhandled error${error.digest ? ` (digest ${error.digest})` : ''}:`,
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, scope])
// During the single automatic reload, render nothing so a transient error
// never flashes any UI at all.
if (phase === 'reloading') return null
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-6 text-center">
<h2 className="text-xl">Något gick fel</h2>
<p className="max-w-md text-sm text-muted-foreground">
Ett oväntat fel uppstod. Försök igen eller{' '}
<SupportLink variant="inline" subject="Oväntat fel">
kontakta support
</SupportLink>{' '}
om problemet kvarstår.
</p>
<Button onClick={() => window.location.reload()}>Försök igen</Button>
</div>
)
}