fix(entitlements): stop passing a component across the RSC boundary on the extension upsell page (#962)

The paywall branch in app/(dashboard)/e/[sector]/[slug]/page.tsx resolved
the extension icon server-side and passed the resulting forwardRef component
into the 'use client' EmptyState. React cannot serialize a component across
the server-to-client boundary, so non-payers opening a gated extension
(e.g. /e/general/invoice-inbox) got a 500 error page instead of the
upgrade CTA (digest 1621801304, 2026-07-08).

Fix: new client wrapper components/extensions/ExtensionUpsellState.tsx
accepts only plain string props (iconName, title, description, ctaLabel,
ctaHref) and resolves the icon client-side via resolveIcon, the same
pattern DashboardNav and the command palette already use. The server page
now passes definition.icon as a string. The two other resolveIcon call
sites in server pages render the icon inside the server component, which
is valid RSC, and are left untouched.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-10 11:03:46 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a8801430f4
commit 5b505bb4a7
2 changed files with 50 additions and 12 deletions
+8 -12
View File
@@ -1,15 +1,12 @@
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import Link from 'next/link'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspaceLoader'
import { getActiveCompanyId } from '@/lib/company/context'
import { hasCapability } from '@/lib/entitlements/has-capability'
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { EmptyState } from '@/components/ui/empty-state'
import { Button } from '@/components/ui/button'
import { ExtensionUpsellState } from '@/components/extensions/ExtensionUpsellState'
export default async function ExtensionWorkspacePage({
params,
@@ -38,17 +35,16 @@ export default async function ExtensionWorkspacePage({
: false
if (!allowed) {
const t = await getTranslations('extensions')
const Icon = resolveIcon(definition.icon)
// Only plain strings cross into the client component here: passing a
// resolved icon component would crash RSC serialization (500 page).
return (
<EmptyState
icon={Icon}
<ExtensionUpsellState
iconName={definition.icon}
title={t('upsell_title', { name: definition.name })}
description={t('upsell_description')}
>
<Link href="/settings/billing">
<Button>{t('upsell_cta')}</Button>
</Link>
</EmptyState>
ctaLabel={t('upsell_cta')}
ctaHref="/settings/billing"
/>
)
}
}
@@ -0,0 +1,42 @@
'use client'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
interface ExtensionUpsellStateProps {
iconName?: string
title: string
description: string
ctaLabel: string
ctaHref: string
}
/**
* Paywall state for a gated extension workspace, rendered when the active
* company lacks the required capability.
*
* This is a client component on purpose: the server page cannot pass a
* resolved icon component into the 'use client' EmptyState (React cannot
* serialize a component across the RSC boundary; doing so 500s the page).
* The page passes the icon NAME as a plain string and this wrapper resolves
* it client-side, same as DashboardNav and the command palette do.
* Every prop here must stay plain-serializable (strings only).
*/
export function ExtensionUpsellState({
iconName,
title,
description,
ctaLabel,
ctaHref,
}: ExtensionUpsellStateProps) {
const Icon = iconName ? resolveIcon(iconName) : undefined
return (
<EmptyState icon={Icon} title={title} description={description}>
<Link href={ctaHref}>
<Button>{ctaLabel}</Button>
</Link>
</EmptyState>
)
}