From 5b505bb4a7d5cdda7142eba2159d4da631c035a3 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:03:46 +0200 Subject: [PATCH] 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 --- app/(dashboard)/e/[sector]/[slug]/page.tsx | 20 ++++----- .../extensions/ExtensionUpsellState.tsx | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 components/extensions/ExtensionUpsellState.tsx diff --git a/app/(dashboard)/e/[sector]/[slug]/page.tsx b/app/(dashboard)/e/[sector]/[slug]/page.tsx index 1e260015..bc347264 100644 --- a/app/(dashboard)/e/[sector]/[slug]/page.tsx +++ b/app/(dashboard)/e/[sector]/[slug]/page.tsx @@ -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 ( - - - - - + ctaLabel={t('upsell_cta')} + ctaHref="/settings/billing" + /> ) } } diff --git a/components/extensions/ExtensionUpsellState.tsx b/components/extensions/ExtensionUpsellState.tsx new file mode 100644 index 00000000..b89119fd --- /dev/null +++ b/components/extensions/ExtensionUpsellState.tsx @@ -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 ( + + + + + + ) +}