Skip to content

Commit 465bdbd

Browse files
authored
fix(settings): keep billing header stable (#7010)
1 parent d831c09 commit 465bdbd

6 files changed

Lines changed: 137 additions & 56 deletions

File tree

.claude/rules/sim-settings-pages.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
1313
`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
1414
action chips), a scroll region, and a centered `max-w-[48rem]` content column led
1515
by a **title + description from navigation metadata**. The chrome stays mounted
16-
across section navigation (it never re-renders or re-lays-out). Each section
17-
renders through the **`SettingsPanel`** registrar
16+
across section navigation. Its routed title and description are available before
17+
the section body resolves. Each section renders through the **`SettingsPanel`**
18+
registrar
1819
(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
1920
the shell its header data and renders only the section body. Sections supply
2021
**data**, never chrome.
@@ -82,6 +83,9 @@ return (
8283
`children` instead and omit the prop.
8384
- `title?` / `description?` — overrides for the nav-driven defaults. **Only** for a
8485
detail sub-view that needs a different heading; normal pages never pass these.
86+
A top-level page's header identity must remain stable while its data loads:
87+
never replace navigation metadata with client-fetched copy after first paint.
88+
Put data-dependent context in the page body instead.
8589
- `scrollContainerRef?: React.Ref<HTMLDivElement>` — forwards a ref to the scroll
8690
region (e.g. programmatic scroll-to-bottom).
8791

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ export function SettingsPage({ section }: SettingsPageProps) {
177177
<Billing
178178
scope={organizationId ? 'organization' : 'account'}
179179
organizationId={organizationId ?? undefined}
180-
governingWorkspaceName={hostContext.workspace.name}
181180
creditUsageHref={`/workspace/${hostContext.workspace.id}/settings/billing/credit-usage`}
182181
/>
183182
)}

apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx

Lines changed: 94 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@ vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () =
174174
),
175175
}))
176176

177+
vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({
178+
SettingsEmptyState: ({ children, tone }: { children: ReactNode; tone?: 'muted' | 'error' }) => (
179+
<div data-testid='settings-empty-state' data-tone={tone ?? 'muted'}>
180+
{children}
181+
</div>
182+
),
183+
}))
184+
177185
vi.mock(
178186
'@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section',
179187
() => ({
@@ -269,13 +277,7 @@ describe('Billing payer scope', () => {
269277

270278
it('uses the target organization DTO for annual, canceled, credit, cap, and link state', async () => {
271279
await act(async () => {
272-
root.render(
273-
<Billing
274-
scope='organization'
275-
organizationId='org-target'
276-
governingWorkspaceName='Production'
277-
/>
278-
)
280+
root.render(<Billing scope='organization' organizationId='org-target' />)
279281
})
280282

281283
expect(mockUseSubscriptionData).toHaveBeenCalledWith(
@@ -290,9 +292,7 @@ describe('Billing payer scope', () => {
290292
container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
291293
).toBe('Explore organization plans')
292294
expect(container.textContent).toContain('Organization Max for Teams plan')
293-
expect(container.textContent).toContain(
294-
'Target organization’s subscription governs Production.'
295-
)
295+
expect(container.querySelector('main > p')).toBeNull()
296296
expect(container.textContent).toContain('billed annually')
297297
expect(container.textContent).toContain('Access until')
298298
expect(container.textContent).toContain('Subscription canceled')
@@ -316,19 +316,45 @@ describe('Billing payer scope', () => {
316316

317317
it('uses a guaranteed personal payer workspace for account upgrades', async () => {
318318
await act(async () => {
319-
root.render(<Billing scope='account' governingWorkspaceName='Personal workspace' />)
319+
root.render(<Billing scope='account' />)
320320
})
321321

322322
expect(
323323
container.querySelector('a[href="/workspace/personal-workspace/upgrade"]')?.textContent
324324
).toBe('Explore personal plans')
325325
expect(container.textContent).toContain('Personal Pro plan')
326-
expect(container.textContent).toContain(
327-
'Your personal subscription governs Personal workspace.'
328-
)
329326
})
330327

331-
it('does not show a governing subscription description for a free personal workspace', async () => {
328+
it('does not override the route-owned header while billing transitions from loading to success', async () => {
329+
mockPersonalQuery.current = {
330+
data: undefined,
331+
error: null,
332+
isLoading: true,
333+
refetch: vi.fn(),
334+
}
335+
336+
await act(async () => {
337+
root.render(<Billing scope='account' />)
338+
})
339+
340+
expect(container.innerHTML).toBe('')
341+
342+
mockPersonalQuery.current = {
343+
data: { success: true, context: 'user', data: PERSONAL_DATA },
344+
error: null,
345+
isLoading: false,
346+
refetch: vi.fn(),
347+
}
348+
349+
await act(async () => {
350+
root.render(<Billing scope='account' />)
351+
})
352+
353+
expect(container.textContent).toContain('Personal Pro plan')
354+
expect(container.querySelector('main > p')).toBeNull()
355+
})
356+
357+
it('does not add a dynamic header description for a free personal workspace', async () => {
332358
mockPersonalQuery.current = {
333359
data: {
334360
success: true,
@@ -340,7 +366,7 @@ describe('Billing payer scope', () => {
340366
}
341367

342368
await act(async () => {
343-
root.render(<Billing scope='account' governingWorkspaceName='Free workspace' />)
369+
root.render(<Billing scope='account' />)
344370
})
345371

346372
expect(container.textContent).toContain('Personal Free plan')
@@ -368,13 +394,7 @@ describe('Billing payer scope', () => {
368394
}
369395

370396
await act(async () => {
371-
root.render(
372-
<Billing
373-
scope='organization'
374-
organizationId='org-target'
375-
governingWorkspaceName='Free organization workspace'
376-
/>
377-
)
397+
root.render(<Billing scope='organization' organizationId='org-target' />)
378398
})
379399

380400
expect(container.textContent).toContain('Organization Free plan')
@@ -398,13 +418,7 @@ describe('Billing payer scope', () => {
398418
}
399419

400420
await act(async () => {
401-
root.render(
402-
<Billing
403-
scope='organization'
404-
organizationId='org-target'
405-
governingWorkspaceName='Lapsed organization workspace'
406-
/>
407-
)
421+
root.render(<Billing scope='organization' organizationId='org-target' />)
408422
})
409423

410424
expect(container.textContent).toContain('Organization Max for Teams plan ended')
@@ -415,4 +429,54 @@ describe('Billing payer scope', () => {
415429
container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
416430
).toBe('Explore organization plans')
417431
})
432+
433+
it('renders the canonical error state when the active billing query fails', async () => {
434+
mockPersonalQuery.current = {
435+
data: undefined,
436+
error: new Error('Billing temporarily unavailable'),
437+
isLoading: false,
438+
refetch: vi.fn(),
439+
}
440+
441+
await act(async () => {
442+
root.render(<Billing scope='account' />)
443+
})
444+
445+
const errorState = container.querySelector('[data-testid="settings-empty-state"]')
446+
expect(errorState).toHaveAttribute('data-tone', 'error')
447+
expect(errorState?.textContent).toBe('Billing temporarily unavailable')
448+
})
449+
450+
it('keeps cached billing content visible when a background refresh fails', async () => {
451+
mockPersonalQuery.current = {
452+
data: { success: true, context: 'user', data: PERSONAL_DATA },
453+
error: new Error('Background refresh failed'),
454+
isLoading: false,
455+
refetch: vi.fn(),
456+
}
457+
458+
await act(async () => {
459+
root.render(<Billing scope='account' />)
460+
})
461+
462+
expect(container.textContent).toContain('Personal Pro plan')
463+
expect(container.querySelector('[data-testid="settings-empty-state"]')).toBeNull()
464+
})
465+
466+
it('renders the canonical fallback error when billing completes without data', async () => {
467+
mockOrganizationQuery.current = {
468+
data: undefined,
469+
error: null,
470+
isLoading: false,
471+
refetch: vi.fn(),
472+
}
473+
474+
await act(async () => {
475+
root.render(<Billing scope='organization' organizationId='org-target' />)
476+
})
477+
478+
const errorState = container.querySelector('[data-testid="settings-empty-state"]')
479+
expect(errorState).toHaveAttribute('data-tone', 'error')
480+
expect(errorState?.textContent).toBe('Failed to load billing information')
481+
})
418482
})

apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
4646
import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section'
4747
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
4848
import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions'
49+
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
4950
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
5051
import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
5152
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -103,20 +104,15 @@ interface BillingProps {
103104
scope: 'account' | 'organization'
104105
organizationId?: string
105106
creditUsageHref?: string
106-
governingWorkspaceName?: string
107107
}
108108

109-
export function Billing({
110-
scope,
111-
organizationId,
112-
creditUsageHref,
113-
governingWorkspaceName,
114-
}: BillingProps) {
109+
export function Billing({ scope, organizationId, creditUsageHref }: BillingProps) {
115110
const router = useRouter()
116111
const isOrganizationScope = scope === 'organization'
117112

118113
const {
119114
data: subscriptionData,
115+
error: subscriptionError,
120116
isLoading: isSubscriptionLoading,
121117
refetch: refetchSubscription,
122118
} = useSubscriptionData({
@@ -127,6 +123,7 @@ export function Billing({
127123

128124
const {
129125
data: organizationBillingData,
126+
error: organizationBillingError,
130127
isLoading: isOrgBillingLoading,
131128
refetch: refetchOrganizationBilling,
132129
} = useOrganizationBilling(billingOrganizationId || '', { enabled: isOrganizationScope })
@@ -157,6 +154,7 @@ export function Billing({
157154
? (organizationBilling?.subscriptionStatus ?? 'inactive')
158155
: (subscriptionData?.data?.status ?? 'inactive')
159156
const isLoading = isOrganizationScope ? isOrgBillingLoading : isSubscriptionLoading
157+
const billingError = isOrganizationScope ? organizationBillingError : subscriptionError
160158

161159
const subscription = {
162160
isFree: isFree(plan),
@@ -403,7 +401,15 @@ export function Billing({
403401
}
404402

405403
if (isLoading) return null
406-
if (isOrganizationScope ? !organizationBilling : !subscriptionData?.data) return null
404+
if (isOrganizationScope ? !organizationBilling : !subscriptionData?.data) {
405+
return (
406+
<SettingsPanel>
407+
<SettingsEmptyState tone='error'>
408+
{getErrorMessage(billingError, 'Failed to load billing information')}
409+
</SettingsEmptyState>
410+
</SettingsPanel>
411+
)
412+
}
407413

408414
const planName = getDisplayPlanName(subscription.plan)
409415
const billingInterval = isOrganizationScope
@@ -458,16 +464,9 @@ export function Billing({
458464
const explorePlansLabel = isOrganizationScope
459465
? 'Explore organization plans'
460466
: 'Explore personal plans'
461-
const subscriptionOwner = isOrganizationScope
462-
? `${organizationBilling?.organizationName ?? 'The organization'}’s subscription`
463-
: 'Your personal subscription'
464-
const settingsDescription =
465-
governingWorkspaceName && subscription.isPaid
466-
? `${subscriptionOwner} governs ${governingWorkspaceName}.`
467-
: undefined
468467

469468
return (
470-
<SettingsPanel description={settingsDescription}>
469+
<SettingsPanel>
471470
<div className='flex items-center justify-between gap-3'>
472471
<div className='flex items-center gap-2.5'>
473472
<div className='size-9 flex-shrink-0'>

apps/sim/components/settings/settings-header-shell.test.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ function renderHeader(actions: SettingsAction[]) {
4141
root.render(
4242
<SettingsHeaderProvider>
4343
<SettingsHeaderShell>
44-
<SettingsPanel title='Thing' actions={actions}>
44+
<SettingsPanel back={{ text: 'Back', onSelect: vi.fn() }} title='Thing' actions={actions}>
4545
<div />
4646
</SettingsPanel>
4747
</SettingsHeaderShell>
@@ -152,7 +152,11 @@ describe('SettingsHeaderShell static meta', () => {
152152

153153
it('yields to a body that registers its own header', () => {
154154
renderWithMeta(
155-
<SettingsPanel title='Add secret' description='One value.'>
155+
<SettingsPanel
156+
back={{ text: 'Secrets', onSelect: vi.fn() }}
157+
title='Add secret'
158+
description='One value.'
159+
>
156160
<div />
157161
</SettingsPanel>
158162
)
@@ -192,7 +196,7 @@ describe('SettingsHeaderShell static meta', () => {
192196

193197
it('falls back to the meta title when the body unmounts mid-navigation', () => {
194198
renderWithMeta(
195-
<SettingsPanel title='Add secret'>
199+
<SettingsPanel back={{ text: 'Secrets', onSelect: vi.fn() }} title='Add secret'>
196200
<div />
197201
</SettingsPanel>
198202
)

apps/sim/components/settings/settings-panel.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,28 @@ export function SettingsSectionProvider({
3838
)
3939
}
4040

41-
interface SettingsPanelProps {
41+
interface SettingsPanelBaseProps {
4242
children?: ReactNode
4343
actions?: SettingsAction[]
44-
back?: SettingsBackAction
4544
search?: SettingsHeaderSearch
46-
title?: string
47-
description?: string
4845
docsLink?: string
4946
scrollContainerRef?: Ref<HTMLDivElement>
5047
}
5148

49+
type SettingsPanelProps = SettingsPanelBaseProps &
50+
(
51+
| {
52+
back: SettingsBackAction
53+
title?: string
54+
description?: string
55+
}
56+
| {
57+
back?: undefined
58+
title?: never
59+
description?: never
60+
}
61+
)
62+
5263
export function SettingsPanel({
5364
children,
5465
actions,

0 commit comments

Comments
 (0)