Files
accounted/components/system/AppErrorBoundary.tsx
T
Jakob Wennberg 314efe8b22 fix(design): stop synthesizing bold on Hedvig display headings (#1555)
* fix(design): stop synthesizing bold on Hedvig display headings

Hedvig Letters Serif ships weight 400 only, but the h1-h3 base rule
forced font-weight 500 and DialogTitle/SheetTitle stacked font-semibold
on top, so every display heading rendered browser-synthesized bold: the
smudged heavy look on dialog titles and page headings. Drop the base
rule to 400, remove the weight utilities from the title primitives, and
sweep the 41 files that hand-set font-medium/semibold/bold on serif
headings (a pattern design.md already forbids). Headings that opt into
font-sans keep their weight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(design): drop empty className left by the weight sweep

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:27:25 +02: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>
)
}