Skip to content

Commit 70783dc

Browse files
authored
improvement(settings): accelerate navigation and data loading (#7299)
* improvement(settings): accelerate navigation and data loading * fix(settings): harden loading and session boundaries * fix(settings): tighten navigation and auth coverage * fix(settings): handle unavailable pathname * docs(settings): document data warming pattern * fix(settings): isolate identity lifecycle state * fix(settings): harden async lifecycle consistency * fix(settings): release superseded reconnect ownership * fix(settings): preserve debug lifecycle ownership * fix(settings): tighten intent and execution boundaries * refactor(settings): remove orphan sandbox query module
1 parent f9c22fd commit 70783dc

123 files changed

Lines changed: 5709 additions & 1449 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/sim-react-performance.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ const [{ id }, { kbName }] = await Promise.all([params, searchParams])
9090

9191
Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read).
9292

93+
## Carry exact lifecycle ownership across async boundaries
94+
95+
When asynchronous work can outlive an execution, session, or resource instance, capture its
96+
opaque ownership token before the first `await` and pass that exact token through completion and
97+
error cleanup. Never re-adopt the current owner from delayed cleanup: a replacement may now own
98+
the same scope. End the lifecycle by exact-token match, and clear shared state only when that end
99+
succeeds. Current-owner adoption is reserved for synchronous user actions that explicitly stop
100+
the current lifecycle.
101+
93102
## Prefetch dynamic destination lists on intent
94103

95104
For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume
@@ -99,6 +108,12 @@ server state with the consumer's shared React Query options. A short, cancelable
99108
avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling;
100109
let the actual unmodified click start the data request.
101110

111+
A speculative failure must not poison a later visit when the app default disables
112+
`retryOnMount`: remove only that exact failed query while it is inactive, keep failures visible
113+
to mounted consumers, and set the shared options to `retryOnMount: true` so a quick-click failure
114+
can recover after the user leaves and returns. Never carry placeholder data between protected
115+
resource keys (for example, workspace A to workspace B); an explicit loading state is truthful.
116+
102117
If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains
103118
mounted until its peer is ready, the intent path must warm both the full route and its critical
104119
data. Otherwise keep the loading boundary so dynamic navigation remains responsive.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ Adding a new settings page:
104104
2. Render the component inside the shell's `effectiveSection` switch in
105105
`settings/[section]/settings.tsx`.
106106
3. Build the component body inside `<SettingsPanel>` — no shell, no title block.
107+
4. When a real second consumer or server boundary needs it, extract client-safe React Query options;
108+
otherwise keep them with the hook. Approved intent warmers reuse those exact options and must keep
109+
`check-tool-registry-boundary` green. Warm only authorized destinations, preserve the current
110+
section during the transition, and follow `sim-react-performance.md` recovery rules; never render
111+
temporary default data that will be replaced after load.
107112

108113
## Text-scale tokens (no literal pixel sizes)
109114

apps/sim/app/account/settings/[section]/page.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Suspense } from 'react'
2+
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
23
import type { Metadata } from 'next'
34
import { notFound, redirect } from 'next/navigation'
45
import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer'
@@ -9,9 +10,11 @@ import {
910
getSettingsSectionMeta,
1011
parseSettingsPathSection,
1112
} from '@/components/settings/navigation'
13+
import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standalone-general'
1214
import { getSession } from '@/lib/auth'
1315
import { isBillingEnabled } from '@/lib/core/config/env-flags'
1416
import { isPlatformAdmin } from '@/lib/permissions/super-user'
17+
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1518

1619
interface AccountSettingsSectionPageProps {
1720
params: Promise<{ section: string }>
@@ -52,14 +55,21 @@ export default async function AccountSettingsSectionPage({
5255
}
5356

5457
/**
55-
* Sections read URL query params via nuqs (which uses `useSearchParams`
56-
* internally), so the renderer must sit under a Suspense boundary. The
57-
* `null` fallback matches the existing visual behavior — the sections are
58-
* `next/dynamic` components that render nothing while their chunk loads.
58+
* Sections read URL query params via nuqs, so the renderer must sit under a
59+
* Suspense boundary. The null fallback preserves the existing chunk-loading UI.
5960
*/
60-
return (
61+
const content = (
6162
<Suspense fallback={null}>
6263
<AccountSettingsRenderer section={parsed} />
6364
</Suspense>
6465
)
66+
67+
if (parsed === 'general') {
68+
const queryClient = getQueryClient()
69+
await prefetchStandaloneGeneral(queryClient)
70+
71+
return <HydrationBoundary state={dehydrate(queryClient)}>{content}</HydrationBoundary>
72+
}
73+
74+
return content
6575
}

apps/sim/app/api/billing/route.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,8 @@ function mockOrganizationDbRows({
150150
.mockResolvedValueOnce([{ role }])
151151
.mockResolvedValueOnce([{ id: 'org-target', name: 'Target organization' }])
152152
.mockResolvedValueOnce(latestSubscription ? [latestSubscription] : [])
153-
.mockResolvedValueOnce([{ userId: ownerId }])
153+
.mockResolvedValueOnce([{ userId: ownerId, billingBlocked, billingBlockedReason }])
154154
.mockResolvedValueOnce(upgradeWorkspaceId ? [{ id: upgradeWorkspaceId }] : [])
155-
.mockResolvedValueOnce([{ billingBlocked, billingBlockedReason }])
156155
}
157156

158157
describe('GET /api/billing', () => {

apps/sim/app/api/billing/route.ts

Lines changed: 5 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,95 +3,27 @@ import {
33
member,
44
organization as organizationTable,
55
subscription as subscriptionTable,
6-
userStats,
7-
workspace as workspaceTable,
86
} from '@sim/db/schema'
97
import { createLogger } from '@sim/logger'
108
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
11-
import { and, asc, desc, eq, isNull } from 'drizzle-orm'
9+
import { and, desc, eq } from 'drizzle-orm'
1210
import { type NextRequest, NextResponse } from 'next/server'
1311
import { getBillingContract } from '@/lib/api/contracts/subscription'
1412
import { parseRequest } from '@/lib/api/server'
1513
import { getSession } from '@/lib/auth'
1614
import { getOrganizationSubscription, getPersonalBillingSummary } from '@/lib/billing/core/billing'
1715
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
16+
import {
17+
getOrganizationBillingBlockState,
18+
getUpgradeWorkspaceId,
19+
} from '@/lib/billing/core/payer-context'
1820
import { resolveBillingInterval } from '@/lib/billing/core/subscription'
1921
import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
2022
import { isPaid } from '@/lib/billing/plan-helpers'
2123
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2224

2325
const logger = createLogger('UnifiedBillingAPI')
2426

25-
interface BillingBlockState {
26-
billingBlocked: boolean
27-
billingBlockedReason: 'payment_failed' | 'dispute' | null
28-
blockedByOrgOwner: boolean
29-
}
30-
31-
/**
32-
* Finds an active workspace whose host billing identity is the requested payer.
33-
*/
34-
async function getUpgradeWorkspaceId(
35-
target: { type: 'user'; id: string } | { type: 'organization'; id: string }
36-
): Promise<string | null> {
37-
const targetPredicate =
38-
target.type === 'organization'
39-
? eq(workspaceTable.organizationId, target.id)
40-
: and(
41-
eq(workspaceTable.ownerId, target.id),
42-
eq(workspaceTable.billedAccountUserId, target.id),
43-
isNull(workspaceTable.organizationId)
44-
)
45-
46-
const [workspace] = await dbReplica
47-
.select({ id: workspaceTable.id })
48-
.from(workspaceTable)
49-
.where(and(targetPredicate, isNull(workspaceTable.archivedAt)))
50-
.orderBy(asc(workspaceTable.createdAt), asc(workspaceTable.id))
51-
.limit(1)
52-
53-
return workspace?.id ?? null
54-
}
55-
56-
/**
57-
* Reads the exact organization's payer block from its owner, without allowing
58-
* the viewer's personal status or another organization membership to leak in.
59-
*/
60-
async function getOrganizationBillingBlockState(
61-
organizationId: string,
62-
viewerUserId: string
63-
): Promise<BillingBlockState> {
64-
const [owner] = await dbReplica
65-
.select({ userId: member.userId })
66-
.from(member)
67-
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
68-
.limit(1)
69-
70-
if (!owner) {
71-
return {
72-
billingBlocked: false,
73-
billingBlockedReason: null,
74-
blockedByOrgOwner: false,
75-
}
76-
}
77-
78-
const [stats] = await dbReplica
79-
.select({
80-
billingBlocked: userStats.billingBlocked,
81-
billingBlockedReason: userStats.billingBlockedReason,
82-
})
83-
.from(userStats)
84-
.where(eq(userStats.userId, owner.userId))
85-
.limit(1)
86-
87-
const billingBlocked = Boolean(stats?.billingBlocked)
88-
return {
89-
billingBlocked,
90-
billingBlockedReason: billingBlocked ? (stats?.billingBlockedReason ?? null) : null,
91-
blockedByOrgOwner: billingBlocked && owner.userId !== viewerUserId,
92-
}
93-
}
94-
9527
/**
9628
* Unified Billing Endpoint
9729
*/
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { ForbiddenOperationError } from '@/lib/core/application'
7+
8+
const { mockReadBillingSummary } = vi.hoisted(() => ({
9+
mockReadBillingSummary: vi.fn(),
10+
}))
11+
12+
vi.mock(
13+
'@/lib/billing/application/organization-billing-summary/get-organization-billing-summary',
14+
() => ({
15+
getOrganizationBillingSummary: {
16+
operation: { id: 'organization_billing.summary.read' },
17+
execute: mockReadBillingSummary,
18+
},
19+
})
20+
)
21+
22+
import { GET } from '@/app/api/organizations/[id]/billing-summary/route'
23+
24+
const routeContext = { params: Promise.resolve({ id: 'organization-1' }) }
25+
const summary = {
26+
organizationId: 'organization-1',
27+
subscriptionState: 'active' as const,
28+
subscriptionPlan: 'team',
29+
subscriptionStatus: 'active',
30+
creditBalance: 10,
31+
billingInterval: 'month' as const,
32+
cancelAtPeriodEnd: false,
33+
totalSeats: 3,
34+
totalCurrentUsage: 25,
35+
totalUsageLimit: 100,
36+
minimumBillingAmount: 60,
37+
billingPeriodEnd: '2026-09-30T00:00:00.000Z',
38+
billingBlocked: false,
39+
billingBlockedReason: null,
40+
blockedByOrgOwner: false,
41+
upgradeWorkspaceId: 'workspace-1',
42+
userRole: 'admin' as const,
43+
}
44+
45+
describe('GET /api/organizations/[id]/billing-summary', () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
authMockFns.mockGetSession.mockResolvedValue({
49+
user: { id: 'user-1' },
50+
session: { id: 'session-1' },
51+
})
52+
mockReadBillingSummary.mockResolvedValue(summary)
53+
})
54+
55+
it('rejects an unauthenticated request before the protected read runs', async () => {
56+
authMockFns.mockGetSession.mockResolvedValue(null)
57+
58+
const response = await GET(createMockRequest('GET'), routeContext)
59+
60+
expect(response.status).toBe(401)
61+
expect(mockReadBillingSummary).not.toHaveBeenCalled()
62+
})
63+
64+
it('projects an authorization refusal without exposing billing data', async () => {
65+
mockReadBillingSummary.mockRejectedValue(
66+
new ForbiddenOperationError(
67+
'ORGANIZATION_ADMIN_REQUIRED',
68+
'Organization admin or owner authority is required to read billing information'
69+
)
70+
)
71+
72+
const response = await GET(createMockRequest('GET'), routeContext)
73+
74+
expect(response.status).toBe(403)
75+
const body = await response.json()
76+
expect(body).toEqual({
77+
error: 'Organization admin or owner authority is required to read billing information',
78+
})
79+
expect(body).not.toHaveProperty('data')
80+
})
81+
82+
it('maps the authenticated viewer and route organization into the semantic read', async () => {
83+
const response = await GET(createMockRequest('GET'), routeContext)
84+
85+
expect(response.status).toBe(200)
86+
await expect(response.json()).resolves.toEqual({ success: true, data: summary })
87+
expect(mockReadBillingSummary).toHaveBeenCalledWith(
88+
expect.objectContaining({
89+
principal: {
90+
kind: 'session',
91+
userId: 'user-1',
92+
sessionId: 'session-1',
93+
},
94+
input: { organizationId: 'organization-1' },
95+
})
96+
)
97+
})
98+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { getOrganizationBillingSummaryContract } from '@/lib/api/contracts/organization'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary'
9+
import { organizationBillingSummaryOperations } from '@/lib/billing/application/organization-billing-summary/operations'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
export const GET = defineInternalJsonRoute({
14+
contract: getOrganizationBillingSummaryContract,
15+
auth: internalSessionAuth,
16+
operation: organizationBillingSummaryOperations.read,
17+
rateLimit: internalRateLimits.none({
18+
reason: 'Authenticated organization billing read, restricted to organization admins and owners',
19+
}),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
mapInput: ({ params }) => ({ organizationId: params.id }),
22+
useCase: getOrganizationBillingSummary,
23+
present: (data) => ({ success: true, data }),
24+
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockReadProfile } = vi.hoisted(() => ({
8+
mockReadProfile: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/users/application/read-current-user', () => ({
12+
getCurrentUserProfileUseCase: {
13+
operation: { id: 'users.account.profile.read', principalKinds: ['session'] },
14+
execute: mockReadProfile,
15+
},
16+
}))
17+
18+
import { GET } from '@/app/api/users/me/profile/route'
19+
20+
describe('GET /api/users/me/profile', () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks()
23+
authMockFns.mockGetSession.mockResolvedValue({
24+
user: { id: 'user-1' },
25+
session: { id: 'session-1' },
26+
})
27+
mockReadProfile.mockResolvedValue({
28+
id: 'user-1',
29+
name: 'User',
30+
email: 'user@example.com',
31+
image: null,
32+
})
33+
})
34+
35+
it('reads the authenticated account through the semantic use case', async () => {
36+
const response = await GET(createMockRequest('GET'))
37+
38+
expect(response.status).toBe(200)
39+
await expect(response.json()).resolves.toEqual({
40+
user: {
41+
id: 'user-1',
42+
name: 'User',
43+
email: 'user@example.com',
44+
image: null,
45+
},
46+
})
47+
expect(mockReadProfile).toHaveBeenCalledWith(
48+
expect.objectContaining({
49+
principal: {
50+
kind: 'session',
51+
userId: 'user-1',
52+
sessionId: 'session-1',
53+
},
54+
input: {},
55+
})
56+
)
57+
})
58+
59+
it('rejects an unauthenticated request before the use case runs', async () => {
60+
authMockFns.mockGetSession.mockResolvedValue(null)
61+
62+
const response = await GET(createMockRequest('GET'))
63+
64+
expect(response.status).toBe(401)
65+
expect(mockReadProfile).not.toHaveBeenCalled()
66+
})
67+
})

0 commit comments

Comments
 (0)