From 1c04262e063732f2ba1c4f18e845bef807c44ceb Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 1 Sep 2026 14:51:03 +0200 Subject: [PATCH] fix(byra): stop passing a lucide icon across the server boundary on the KPI empty state (#2110) app/(dashboard)/byra/kpi/page.tsx is a Server Component that passed the lucide icon TrendingUp as icon={TrendingUp} into EmptyState, a Client Component. lucide builds every icon with forwardRef, so the value cannot be serialized across the RSC boundary: the render throws and the route 500s into the dashboard error boundary. The branch runs only when the byra team has zero non-archived clients, which is every brand-new byra on day one, and retrying never helps. Adds an EmptyByraClients preset beside the existing ones. A reference to a client component is serializable where the icon is not, so the icon, the copy and the design treatment are unchanged. The preset reads from the byra namespace under its own translator name rather than t, because i18n/__tests__/message-keys.test.ts maps one variable name to one namespace per file: reusing t would silently re-point every other preset's key in this file at byra. Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc Co-authored-by: Claude Opus 5 (1M context) --- app/(dashboard)/byra/kpi/page.tsx | 9 +-- .../empty-state-server-boundary.test.ts | 73 +++++++++++++++++++ components/ui/empty-state.tsx | 23 ++++++ 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 components/ui/__tests__/empty-state-server-boundary.test.ts diff --git a/app/(dashboard)/byra/kpi/page.tsx b/app/(dashboard)/byra/kpi/page.tsx index f0c903f9..54a6b526 100644 --- a/app/(dashboard)/byra/kpi/page.tsx +++ b/app/(dashboard)/byra/kpi/page.tsx @@ -1,8 +1,7 @@ import { redirect } from 'next/navigation' import { getTranslations } from 'next-intl/server' -import { TrendingUp } from 'lucide-react' import { PageHeader } from '@/components/ui/page-header' -import { EmptyState } from '@/components/ui/empty-state' +import { EmptyByraClients } from '@/components/ui/empty-state' import { fetchByraKpiOverview } from '@/lib/byra/kpi-overview' import ByraKpiView from '@/components/byra/ByraKpiView' import { getDashboardAuthContext } from '../../request-context' @@ -46,11 +45,7 @@ export default async function ByraKpiPage({
{overview.allClients.length === 0 ? ( - + ) : ( { + it('is only passed from client components', () => { + const files = [ + ...collectTsxFiles(path.join(repoRoot, 'app')), + ...collectTsxFiles(path.join(repoRoot, 'components')), + ] + + const offenders = files.filter((file) => { + const source = fs.readFileSync(file, 'utf8') + if (isClientComponent(source)) return false + // `[^>]*` keeps the match inside a single JSX element, newlines included. + return /]*\bicon=\{/.test(source) + }) + + expect(offenders.map((f) => path.relative(repoRoot, f))).toEqual([]) + }) +}) + +describe('byrå KPI empty state', () => { + const pagePath = path.join(repoRoot, 'app', '(dashboard)', 'byra', 'kpi', 'page.tsx') + + it('renders the preset from the server page instead of passing an icon', () => { + const source = fs.readFileSync(pagePath, 'utf8') + expect(isClientComponent(source)).toBe(false) + expect(source).toMatch(//) + expect(source).not.toMatch(/from 'lucide-react'/) + }) + + it('keeps the icon and the copy in the preset', () => { + const source = fs.readFileSync(path.join(repoRoot, 'components', 'ui', 'empty-state.tsx'), 'utf8') + expect(source).toMatch(/export function EmptyByraClients\(\)/) + expect(source).toMatch(/icon=\{TrendingUp\}/) + // Variable-agnostic: the preset reads from the `byra` namespace under its + // own translator name, so assert the keys, not the caller's identifier. + expect(source).toMatch(/\w+\('kpi_empty_title'\)/) + expect(source).toMatch(/\w+\('kpi_empty_description'\)/) + }) +}) diff --git a/components/ui/empty-state.tsx b/components/ui/empty-state.tsx index b7a32f5c..02b62947 100644 --- a/components/ui/empty-state.tsx +++ b/components/ui/empty-state.tsx @@ -13,6 +13,7 @@ import { FileText, Calendar, Plus, + TrendingUp, type LucideIcon, } from 'lucide-react' import { SupportLink } from '@/components/ui/support-link' @@ -185,3 +186,25 @@ export function EmptyReports() { /> ) } + +/** + * Byrå cockpit: no client companies yet. A preset, not a bare , because the only caller is a Server Component: a + * lucide icon is a forwardRef object that cannot cross the RSC boundary as a + * prop, while a reference to this client component can. The copy lives in the + * byra namespace, where the byrå surfaces already keep it. + */ +export function EmptyByraClients() { + // Named tByra, not t: this is the only preset here that reads from a + // namespace other than `empty`, and i18n/__tests__/message-keys.test.ts maps + // one variable name to one namespace per file. Reusing `t` would silently + // re-point every other preset's key in this file at `byra`. + const tByra = useTranslations('byra') + return ( + + ) +}