fix: counterparty template pick crashes the page (#1291)
Picking a suggestion under "Tidigare motparter" in Bokför transaktion replaced
the page with "Något gick fel". handleOpenTemplateReview built the review state
from `{ id, name_sv } as BookingTemplate`, so `template.debit_account` was
undefined, reached QuickReviewDialog's required `defaultAccount: string`, and
threw on `accountOverride.startsWith('2')` during the first render.
Typed the dialog's template prop as a narrow ReviewTemplate whose optional
fields are actually optional, so the cast disappears and the compiler owns this
class of bug. Also carries the counterparty's learned accounts and VAT (the
preview showed the category fallback, not what the server books) and decides
"is this a counterparty booking" from the template id rather than the presence
of a line_pattern (single-line templates got an account/VAT editor the
categorize route discards).
Five more page-crashes of the same shape, adversarially verified:
- suppliers/[id] and supplier-invoices/[id] passed the error envelope OBJECT as
a toast description. The Toaster is a sibling of {children} in the ROOT
layout, so that throw escapes both segment error boundaries onto global-error.
- components/reports/views wrote the same object into a useState<string | null>
at 13 sites and rendered it bare.
- components/ui/toaster.tsx now coerces non-renderable values as a choke point.
- skattekonto read data.informationstext.length off Skatteverket's raw JSON,
where the field is not required.
- TicWorkspace read profile.statuses.length off a persisted jsonb blob. 17 of 17
prod rows predate the TIC v2 upgrade (#584) and lack the key, so that
workspace was in the error boundary for every company that had opened it.
Plus hardening: formatCurrency coerces a null currency to SEK (prod has 0 NULL
across 28 416 transactions, so defense not a live bug) and cleanSignatory
returns [] for a missing description.
Verified by rendering the real dialog against a throwaway /sandbox route: the
pre-fix prop shape reproduces the exact error boundary, the fixed one renders
D: 6570 Bankavgifter / K: 1930 Företagskonto and the matching verifikat.
No migrations.
This commit is contained in:
@@ -32,8 +32,42 @@ import {
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import Link from 'next/link'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { TICCompanyProfile } from '@/extensions/general/tic/lib/tic-types'
|
||||
|
||||
/**
|
||||
* The profile is hydrated from a persisted `extension_data` jsonb blob, so its
|
||||
* shape is whatever the TIC schema looked like when it was cached, not what
|
||||
* TICCompanyProfile promises today. A blob written before the v2 upgrade (#584)
|
||||
* has no `statuses` key at all, and `profile.statuses.length` on a rendered
|
||||
* blob like that dropped the whole workspace into the error boundary.
|
||||
*
|
||||
* Normalising once at the hydration boundary keeps every list read below
|
||||
* honest, including the ones a future schema change would otherwise break.
|
||||
*/
|
||||
function normalizeProfile(raw: unknown): TICCompanyProfile {
|
||||
const p = (raw ?? {}) as Partial<TICCompanyProfile>
|
||||
const list = <T,>(value: T[] | undefined | null): T[] => (Array.isArray(value) ? value : [])
|
||||
return {
|
||||
...(p as TICCompanyProfile),
|
||||
registration: p.registration ?? { fTax: false, vat: false, payroll: false },
|
||||
sniCodes: list(p.sniCodes),
|
||||
bankAccounts: list(p.bankAccounts),
|
||||
beneficialOwners: list(p.beneficialOwners),
|
||||
financialReports: list(p.financialReports),
|
||||
fiscalYearHistory: list(p.fiscalYearHistory),
|
||||
signatory: list(p.signatory),
|
||||
representatives: list(p.representatives),
|
||||
payrolls: list(p.payrolls),
|
||||
statuses: list(p.statuses),
|
||||
// Declared nullable, so undefined would already read as falsy at the
|
||||
// render guards; normalised anyway so the object matches its own type.
|
||||
fiscalYear: p.fiscalYear ?? null,
|
||||
board: p.board ?? null,
|
||||
financials: p.financials ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function formatKSEK(value: number | null): string {
|
||||
if (value === null) return '-'
|
||||
return `${(value * 1000).toLocaleString('sv-SE')} kr`
|
||||
@@ -165,8 +199,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
useEffect(() => {
|
||||
if (isDataLoading) return
|
||||
const cached = getByKey('company_profile')
|
||||
if (cached?.value) {
|
||||
setProfile(cached.value as unknown as TICCompanyProfile)
|
||||
// Blobs written before the TIC v2 upgrade (#584) predate the statuses /
|
||||
// board / payroll sections entirely. Rendering one normalized would show
|
||||
// a permanently section-less profile, because the success path has no
|
||||
// refresh button to repair it: leaving `profile` null instead lets the
|
||||
// auto-fetch effect below pull the current shape and re-save it.
|
||||
if (cached?.value && 'statuses' in cached.value) {
|
||||
setProfile(normalizeProfile(cached.value))
|
||||
}
|
||||
setInitialLoad(false)
|
||||
}, [isDataLoading, getByKey])
|
||||
@@ -196,14 +235,19 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const { error } = await res.json()
|
||||
toast({ title: error ?? t('toast_profile_failed'), variant: 'destructive' })
|
||||
// `error` is the canonical envelope object, not a string: as a bare
|
||||
// toast title it would render an object as a React child.
|
||||
const body = await res.json().catch(() => null)
|
||||
toast({
|
||||
title: body?.error ? getErrorMessage(body, { statusCode: res.status }) : t('toast_profile_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setFetchFailed(true)
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await res.json()
|
||||
setProfile(data)
|
||||
setProfile(normalizeProfile(data))
|
||||
await save('company_profile', data)
|
||||
} catch {
|
||||
toast({ title: t('toast_unexpected_error'), variant: 'destructive' })
|
||||
|
||||
@@ -17,6 +17,7 @@ import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from '@/lib/reports/latest-vouchers-format'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
@@ -113,7 +114,10 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
// `result.error` is the canonical envelope OBJECT; assigning it to a
|
||||
// string state and rendering it bare threw "Objects are not valid as
|
||||
// a React child" and blanked the report page.
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
@@ -433,7 +437,10 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
// `result.error` is the canonical envelope OBJECT; assigning it to a
|
||||
// string state and rendering it bare threw "Objects are not valid as
|
||||
// a React child" and blanked the report page.
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
@@ -591,7 +598,10 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
// `result.error` is the canonical envelope OBJECT; assigning it to a
|
||||
// string state and rendering it bare threw "Objects are not valid as
|
||||
// a React child" and blanked the report page.
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
@@ -717,7 +727,10 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
// `result.error` is the canonical envelope OBJECT; assigning it to a
|
||||
// string state and rendering it bare threw "Objects are not valid as
|
||||
// a React child" and blanked the report page.
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
@@ -872,7 +885,10 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
// `result.error` is the canonical envelope OBJECT; assigning it to a
|
||||
// string state and rendering it bare threw "Objects are not valid as
|
||||
// a React child" and blanked the report page.
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
@@ -2351,7 +2367,9 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}&as_of_date=${asOfDate}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
// Envelope object, not a string: see the note on the other report
|
||||
// fetches. Rendering it bare blanks the page.
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
@@ -2628,7 +2646,9 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
|
||||
const res = await fetch(`/api/reports/general-ledger?${params}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
// Envelope object, not a string: see the note on the other report
|
||||
// fetches. Rendering it bare blanks the page.
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
@@ -2833,7 +2853,9 @@ export function JournalRegisterView({ periodId }: { periodId: string }) {
|
||||
const res = await fetch(`/api/reports/journal-register?period_id=${periodId}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
// Envelope object, not a string: see the note on the other report
|
||||
// fetches. Rendering it bare blanks the page.
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
@@ -3133,7 +3155,9 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}&as_of_date=${asOfDate}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
// Envelope object, not a string: see the note on the other report
|
||||
// fetches. Rendering it bare blanks the page.
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,11 @@ interface SnapshotShape {
|
||||
// and collapses several rules onto one line. Strip the markers, normalise
|
||||
// whitespace, and split run-on "Firman tecknas …" clauses onto their own
|
||||
// lines so each rule reads as a sentence.
|
||||
function cleanSignatory(raw: string): string[] {
|
||||
function cleanSignatory(raw: string | null | undefined): string[] {
|
||||
// The snapshot is unvalidated registry JSON: `description` is declared
|
||||
// required inside an interface whose every other field is optional, so a
|
||||
// signatory row without one would throw here and blank the settings panel.
|
||||
if (!raw) return []
|
||||
const normalised = raw
|
||||
.replace(/>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
|
||||
@@ -12,7 +12,9 @@ import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { linkDocuments, formatFailedDocumentNames } from '@/lib/documents/link-documents'
|
||||
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
|
||||
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
|
||||
import type { ReviewTemplate } from '@/lib/transactions/quick-review-defaults'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import JournalEntryPreview from './JournalEntryPreview'
|
||||
@@ -33,10 +35,11 @@ interface QuickReviewDialogProps {
|
||||
transaction: TransactionWithInvoice | null
|
||||
category: TransactionCategory | null
|
||||
categoryLabel: string
|
||||
/** Empty string when there is no sensible default: never undefined. */
|
||||
defaultAccount: string
|
||||
defaultVat: VatTreatment | 'none'
|
||||
entityType?: EntityType
|
||||
template?: BookingTemplate | null
|
||||
template?: ReviewTemplate | null
|
||||
templateId?: string
|
||||
counterpartyLinePattern?: LinePatternEntry[] | null
|
||||
/**
|
||||
@@ -76,7 +79,12 @@ export default function QuickReviewDialog({
|
||||
const tCat = useTranslations('tx_categories')
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const [accountOverride, setAccountOverride] = useState(defaultAccount)
|
||||
// `?? ''` is deliberate belt-and-braces: the prop is a required string, but
|
||||
// a caller that hands over a template-shaped object missing debit_account
|
||||
// used to make this undefined and take the whole page down on the
|
||||
// .startsWith() below. An empty account disables the confirm button; it
|
||||
// never throws.
|
||||
const [accountOverride, setAccountOverride] = useState(defaultAccount ?? '')
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
@@ -103,8 +111,8 @@ export default function QuickReviewDialog({
|
||||
|
||||
// Handle account changes: clear VAT for liability/equity accounts (class 2)
|
||||
const handleAccountChange = useCallback((account: string) => {
|
||||
setAccountOverride(account)
|
||||
if (account.startsWith('2')) {
|
||||
setAccountOverride(account ?? '')
|
||||
if (account?.startsWith('2')) {
|
||||
setVatTreatment('none')
|
||||
}
|
||||
}, [])
|
||||
@@ -200,9 +208,17 @@ export default function QuickReviewDialog({
|
||||
|
||||
const tx = enrichedTx ?? transaction
|
||||
const isIncome = tx.amount > 0
|
||||
const isCounterpartyTemplate = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0)
|
||||
// Keyed off the template ID, not off the presence of a line pattern: a
|
||||
// *learned* counterparty (one business line, no pattern) is still booked
|
||||
// server-side from counterparty_template_id, so its accounts and VAT come
|
||||
// from the stored template. Deciding this from counterparties that happen
|
||||
// to have a multi-line pattern made single-line ones fall through to the
|
||||
// category branch, which previewed the wrong accounts and offered an
|
||||
// account/VAT editor whose values the categorize route discards.
|
||||
const isCounterpartyTemplate = !!template?.id && isCounterpartyTemplateId(template.id)
|
||||
const hasCounterpartyPattern = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0)
|
||||
const isTemplateBooking = !!templateId || isCounterpartyTemplate
|
||||
const isLiabilityAccount = accountOverride.startsWith('2')
|
||||
const isLiabilityAccount = accountOverride?.startsWith('2') ?? false
|
||||
// For non-SEK transactions, the verifikation and the headline must show
|
||||
// the SEK-converted total: the mall/category booking always posts in SEK.
|
||||
const sekAmount = resolveSekAmount(
|
||||
@@ -399,7 +415,7 @@ export default function QuickReviewDialog({
|
||||
{patternDimsLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{onChangeTemplate && !isCounterpartyTemplate && (
|
||||
{onChangeTemplate && !hasCounterpartyPattern && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
@@ -409,7 +425,10 @@ export default function QuickReviewDialog({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{template && !isCounterpartyTemplate && (
|
||||
{/* Only when there IS a single debit/credit pair to show: a
|
||||
multi-line counterparty pattern has none, and a template that
|
||||
never carried accounts would render "D: → K: ". */}
|
||||
{!hasCounterpartyPattern && template?.debit_account && template?.credit_account && (
|
||||
<p className="mt-1.5 text-xs font-mono text-muted-foreground">
|
||||
D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)}
|
||||
</p>
|
||||
@@ -452,15 +471,26 @@ export default function QuickReviewDialog({
|
||||
<JournalEntryPreview
|
||||
amount={tx.amount}
|
||||
amountSek={sekAmount}
|
||||
{...(isCounterpartyTemplate
|
||||
{...(hasCounterpartyPattern
|
||||
? { linePattern: counterpartyLinePattern ?? undefined }
|
||||
: templateId && template
|
||||
: isTemplateBooking && template?.debit_account && template?.credit_account
|
||||
? {
|
||||
templateDebitAccount: template.debit_account,
|
||||
templateCreditAccount: template.credit_account,
|
||||
templateVatRate: template.vat_rate,
|
||||
templateVatTreatment: template.vat_treatment,
|
||||
templateSupplierType: template.reverse_charge_supplier_type,
|
||||
// A counterparty template carries a treatment but no rate,
|
||||
// and its legacy booking path emits an input-VAT leg from
|
||||
// that treatment only (no basbelopp pair), so it gets the
|
||||
// rate alone: passing the treatment too would preview
|
||||
// reverse-charge lines the engine never books.
|
||||
templateVatRate: isCounterpartyTemplate
|
||||
? (template.vat_treatment ? getVatRate(template.vat_treatment) : 0)
|
||||
: template.vat_rate,
|
||||
...(isCounterpartyTemplate
|
||||
? {}
|
||||
: {
|
||||
templateVatTreatment: template.vat_treatment,
|
||||
templateSupplierType: template.reverse_charge_supplier_type,
|
||||
}),
|
||||
}
|
||||
: { category, vatTreatment: isLiabilityAccount ? 'none' : vatTreatment, accountOverride, entityType }
|
||||
)}
|
||||
@@ -518,7 +548,7 @@ export default function QuickReviewDialog({
|
||||
library-template and legacy counterparty bookings. Multi-line
|
||||
counterparty patterns are excluded: their per-line bags are
|
||||
authoritative server-side and an edit here would be ignored. */}
|
||||
{dimensionsEnabled && !isCounterpartyTemplate && (
|
||||
{dimensionsEnabled && !hasCounterpartyPattern && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">{t('label_dimensions')}</label>
|
||||
<div className="mt-1">
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createElement, isValidElement } from 'react'
|
||||
import { coerceToastNode } from '../toaster'
|
||||
|
||||
/**
|
||||
* The Toaster is a sibling of {children} in the ROOT layout, so a throw here
|
||||
* escapes both app/error.tsx and app/(dashboard)/error.tsx and lands on
|
||||
* global-error, blanking the whole app (and, because global-error reloads
|
||||
* once, pinning the user on the fallback for that path).
|
||||
*
|
||||
* Call sites that forward an unchecked `await res.json()` field pass the
|
||||
* canonical `{ code, message, … }` envelope OBJECT instead of a string, which
|
||||
* React refuses to render. This coercion is the choke point.
|
||||
*/
|
||||
describe('coerceToastNode', () => {
|
||||
it('passes strings and numbers through untouched', () => {
|
||||
expect(coerceToastNode('Kunde inte spara')).toBe('Kunde inte spara')
|
||||
expect(coerceToastNode(42)).toBe(42)
|
||||
})
|
||||
|
||||
it('passes null/undefined through so the description simply does not render', () => {
|
||||
expect(coerceToastNode(null)).toBeNull()
|
||||
expect(coerceToastNode(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps React elements renderable', () => {
|
||||
const el = createElement('span', null, 'hej')
|
||||
expect(coerceToastNode(el)).toBe(el)
|
||||
expect(isValidElement(coerceToastNode(el))).toBe(true)
|
||||
})
|
||||
|
||||
it('turns a canonical error envelope into a readable string instead of throwing', () => {
|
||||
const result = coerceToastNode({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Leverantören hittades inte',
|
||||
} as unknown as React.ReactNode)
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result).toBe('Leverantören hittades inte')
|
||||
})
|
||||
|
||||
it('turns any other object into a string rather than a React child throw', () => {
|
||||
const result = coerceToastNode({ requestId: 'abc' } as unknown as React.ReactNode)
|
||||
expect(typeof result).toBe('string')
|
||||
expect((result as string).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('coerces objects nested inside an array child', () => {
|
||||
const el = createElement('span', { key: 'a' }, 'hej')
|
||||
const result = coerceToastNode([
|
||||
'text',
|
||||
{ code: 'NOT_FOUND', message: 'Hittades inte' },
|
||||
el,
|
||||
] as unknown as React.ReactNode) as unknown[]
|
||||
expect(result[0]).toBe('text')
|
||||
expect(result[1]).toBe('Hittades inte')
|
||||
// Elements pass through by identity so their keys survive.
|
||||
expect(result[2]).toBe(el)
|
||||
})
|
||||
|
||||
it('drops booleans, which React would render as nothing anyway', () => {
|
||||
expect(coerceToastNode(true)).toBeNull()
|
||||
expect(coerceToastNode(false)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,31 @@ import {
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { isValidElement, type ReactNode } from "react"
|
||||
import { getErrorMessage } from "@/lib/errors/get-error-message"
|
||||
|
||||
/**
|
||||
* The Toaster lives in the ROOT layout, as a sibling of {children}. A throw in
|
||||
* here escapes every segment error boundary and lands on global-error, which
|
||||
* blanks the entire app. Callers that hand over an unchecked `await
|
||||
* res.json()` field pass the canonical `{ code, message, … }` envelope object
|
||||
* rather than a string, and React throws "Objects are not valid as a React
|
||||
* child" on it.
|
||||
*
|
||||
* Individual call sites are still expected to run their errors through
|
||||
* getErrorMessage. This is the choke point that keeps the one that forgets
|
||||
* from taking the whole app down with it.
|
||||
*/
|
||||
export function coerceToastNode(value: ReactNode): ReactNode {
|
||||
if (value == null || typeof value === "string" || typeof value === "number") return value
|
||||
if (typeof value === "boolean") return null
|
||||
if (isValidElement(value)) return value
|
||||
// Arrays are legitimate React children, but an unchecked JSON array can hold
|
||||
// objects, and one of those still throws. Coerce members too; elements pass
|
||||
// through by identity so their keys survive.
|
||||
if (Array.isArray(value)) return value.map(coerceToastNode)
|
||||
return getErrorMessage(value)
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
@@ -16,12 +41,14 @@ export function Toaster() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
const safeTitle = coerceToastNode(title)
|
||||
const safeDescription = coerceToastNode(description)
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
{safeTitle && <ToastTitle>{safeTitle}</ToastTitle>}
|
||||
{safeDescription && (
|
||||
<ToastDescription>{safeDescription}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
|
||||
Reference in New Issue
Block a user