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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4ec2ff4b4d
commit
1c04262e06
@@ -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({
|
||||
<div className="space-y-8">
|
||||
<PageHeader title={t('kpi_title')} />
|
||||
{overview.allClients.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={TrendingUp}
|
||||
title={t('kpi_empty_title')}
|
||||
description={t('kpi_empty_description')}
|
||||
/>
|
||||
<EmptyByraClients />
|
||||
) : (
|
||||
<ByraKpiView
|
||||
preset={overview.preset}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..', '..')
|
||||
|
||||
function collectTsxFiles(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
collectTsxFiles(full, out)
|
||||
} else if (entry.name.endsWith('.tsx')) {
|
||||
out.push(full)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function isClientComponent(source: string): boolean {
|
||||
return /^\s*(['"])use client\1/.test(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyState is a Client Component and its `icon` prop is a COMPONENT
|
||||
* reference (`icon?: LucideIcon`), not an element. lucide builds every icon
|
||||
* with forwardRef, so a Server Component that passes `icon={SomeIcon}` hands
|
||||
* Flight a raw function and the render throws before the page ever reaches the
|
||||
* browser: /byra/kpi 500'd into the dashboard error boundary for exactly this
|
||||
* reason (digest 1621801304), and only for byråer with zero client companies,
|
||||
* so it slipped through every manual pass.
|
||||
*
|
||||
* The sanctioned shape is a prop-free preset in empty-state.tsx: a client
|
||||
* component reference IS serializable, and the icon never crosses a boundary.
|
||||
*/
|
||||
describe('EmptyState icon prop never crosses the RSC boundary', () => {
|
||||
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 /<EmptyState\b[^>]*\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(/<EmptyByraClients\s*\/>/)
|
||||
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'\)/)
|
||||
})
|
||||
})
|
||||
@@ -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 <EmptyState
|
||||
* icon={TrendingUp} />, 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 (
|
||||
<EmptyState
|
||||
icon={TrendingUp}
|
||||
title={tByra('kpi_empty_title')}
|
||||
description={tByra('kpi_empty_description')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user