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:
co-authored by
Claude Fable 5
parent
a8801430f4
commit
5b505bb4a7
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user