diff --git a/DECISIONS.md b/DECISIONS.md index 5874728e..516a2200 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -95,3 +95,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-12] Archive reports get CSV twins (semicolon-separated, decimal comma, UTF-8 BOM for Swedish Excel) instead of PDF: zero new dependencies; the JSON stays canonical and a CSV formatting error can never take down the archive (per-file try/catch). [2026-07-12] Kvittens email dedup: notification_log row is now inserted FIRST as an atomic claim (partial unique index 20260712113000 on user_id+reference_id where notification_type = 'skv_kvittens'; 23505 = already claimed, claim released on send failure), and non-uuid reference ids (the VAT cron's composite key) are mapped to a deterministic SHA-256-derived uuid inside kvittens-notification.ts: reference_id is a uuid column, so the old string key silently failed both the dedup select and the insert (22P02); normalizing in-module beats widening the shared column to text or changing the cron's key formula. [2026-07-12] applyPaymentLinkToInvoice (shared send-route payment-link helper) lives in lib/extensions/payment-links.ts, not extensions/general/stripe/lib/payment-links.ts as the review suggested: both send routes reach payment links through the core registry bridge, and a core route importing the Stripe extension directly would break the zero-extensions core build; per-route logging differences are preserved via logPrefix/logContext options. +[2026-07-12] Global/app error boundaries recover via a guarded hard `window.location.reload()`, not React `reset()`: reset() re-renders against the same stale server payload/bundle and re-throws, whereas a reload re-runs middleware (fresh rotated Supabase auth cookie) and fetches a fresh bundle (ChunkLoadError after a deploy), matching the browser-navigation self-heal these transients already relied on. A per-path, per-tab-session sessionStorage flag (a monotonic one-shot, not a time window, which could still loop when a failing render takes longer than the window) bounds it to one auto-reload per path so a persistent error shows the manual fallback instead of looping. diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 00000000..e87f26a9 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,19 @@ +'use client' + +import { AppErrorBoundary } from '@/components/system/AppErrorBoundary' + +// App-wide error boundary. Before this existed, only (dashboard) had an +// error.tsx, so a transient error anywhere else (notably the /select-company +// picker and the onboarding/auth layouts, which hit Supabase auth right after +// login) escalated to the full-screen app/global-error.tsx. This contains those +// errors and recovers via a single hard reload instead. Dashboard errors still +// hit the closer app/(dashboard)/error.tsx; root-layout failures still hit +// global-error.tsx. +export default function AppError({ + error, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/app/global-error.tsx b/app/global-error.tsx index e4196554..ffff5c91 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -1,32 +1,90 @@ "use client"; +import { useEffect, useState } from "react"; + +// Last-resort boundary: only fires when the root layout itself throws, so it +// renders its own 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, - reset, }: { 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 ( -
-
-

Något gick fel

-

- Ett oväntat fel inträffade. Försök igen. -

- + {phase === "fallback" ? ( +
+
+

Något gick fel

+

+ Ett oväntat fel inträffade. Försök igen eller{" "} + + kontakta support + {" "} + om problemet kvarstår. +

+ +
-
+ ) : null} ); diff --git a/components/system/AppErrorBoundary.tsx b/components/system/AppErrorBoundary.tsx new file mode 100644 index 00000000..7efbf3db --- /dev/null +++ b/components/system/AppErrorBoundary.tsx @@ -0,0 +1,106 @@ +'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 ( +
+

Något gick fel

+

+ Ett oväntat fel uppstod. Försök igen eller{' '} + + kontakta support + {' '} + om problemet kvarstår. +

+ +
+ ) +}