Skip to content

Commit b846b32

Browse files
committed
improvement(settings): accelerate navigation and data loading
1 parent b19553e commit b846b32

87 files changed

Lines changed: 3806 additions & 1156 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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ server state with the consumer's shared React Query options. A short, cancelable
9999
avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling;
100100
let the actual unmodified click start the data request.
101101

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

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: 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+
})

apps/sim/app/api/users/me/profile/route.ts

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ import { user } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
6-
import { updateUserProfileContract } from '@/lib/api/contracts'
6+
import { getUserProfileContract, updateUserProfileContract } from '@/lib/api/contracts'
77
import { parseRequest } from '@/lib/api/server'
8+
import {
9+
defineInternalJsonRoute,
10+
internalOrchestrationErrorPolicy,
11+
internalRateLimits,
12+
internalSessionAuth,
13+
} from '@/lib/api/server/routes'
814
import { getSession } from '@/lib/auth'
915
import { generateRequestId } from '@/lib/core/utils/request'
1016
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11-
import { getUserProfile } from '@/lib/users/queries'
17+
import { userAccountOperations } from '@/lib/users/application/operations'
18+
import { getCurrentUserProfileUseCase } from '@/lib/users/application/read-current-user'
1219

1320
const logger = createLogger('UpdateUserProfileAPI')
1421

@@ -71,31 +78,15 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => {
7178
}
7279
})
7380

74-
// GET endpoint to fetch current user profile
75-
export const GET = withRouteHandler(async () => {
76-
const requestId = generateRequestId()
77-
78-
try {
79-
const session = await getSession()
80-
81-
if (!session?.user?.id) {
82-
logger.warn(`[${requestId}] Unauthorized profile fetch attempt`)
83-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
84-
}
85-
86-
const userId = session.user.id
87-
88-
const userRecord = await getUserProfile(userId)
89-
90-
if (!userRecord) {
91-
return NextResponse.json({ error: 'User not found' }, { status: 404 })
92-
}
93-
94-
return NextResponse.json({
95-
user: userRecord,
96-
})
97-
} catch (error: any) {
98-
logger.error(`[${requestId}] Profile fetch error`, error)
99-
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
100-
}
81+
export const GET = defineInternalJsonRoute({
82+
contract: getUserProfileContract,
83+
auth: internalSessionAuth,
84+
operation: userAccountOperations.readProfile,
85+
rateLimit: internalRateLimits.none({
86+
reason: 'Authenticated current-user profile read',
87+
}),
88+
errorPolicy: internalOrchestrationErrorPolicy,
89+
mapInput: () => ({}),
90+
useCase: getCurrentUserProfileUseCase,
91+
present: (userRecord) => ({ user: userRecord }),
10192
})

apps/sim/app/api/users/me/settings/route.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ vi.mock('@/lib/auth', () => ({
1313
getSession: mockGetSession,
1414
}))
1515

16-
import { PATCH } from '@/app/api/users/me/settings/route'
16+
import { GET, PATCH } from '@/app/api/users/me/settings/route'
1717

1818
describe('PATCH /api/users/me/settings', () => {
1919
beforeEach(() => {
@@ -46,3 +46,19 @@ describe('PATCH /api/users/me/settings', () => {
4646
expect(await response.json()).not.toMatchObject({ success: true })
4747
})
4848
})
49+
50+
describe('GET /api/users/me/settings', () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
mockGetSession.mockResolvedValue(null)
54+
})
55+
56+
it('preserves anonymous defaults without entering the protected current-user read', async () => {
57+
const response = await GET()
58+
59+
expect(response.status).toBe(200)
60+
await expect(response.json()).resolves.toMatchObject({
61+
data: { theme: 'system', autoConnect: true },
62+
})
63+
})
64+
})

apps/sim/app/api/users/me/settings/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,26 @@ import { generateShortId } from '@sim/utils/id'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { updateUserSettingsContract } from '@/lib/api/contracts'
77
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
8+
import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes'
89
import { getSession } from '@/lib/auth'
910
import { generateRequestId } from '@/lib/core/utils/request'
1011
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11-
import { defaultUserSettings, getUserSettings } from '@/lib/users/queries'
12+
import { getCurrentUserSettingsUseCase } from '@/lib/users/application/read-current-user'
13+
import { defaultUserSettings } from '@/lib/users/queries'
1214

1315
const logger = createLogger('UserSettingsAPI')
1416

1517
export const GET = withRouteHandler(async () => {
1618
const requestId = generateRequestId()
1719

1820
try {
19-
const session = await getSession()
20-
const data = await getUserSettings(session?.user?.id ?? null)
21+
const principal = await internalSessionAuth.authenticate()
22+
const data = await getCurrentUserSettingsUseCase.execute({ principal, input: {} })
2123
return NextResponse.json({ data }, { status: 200 })
2224
} catch (error: any) {
25+
if (error instanceof InternalUnauthenticatedError) {
26+
return NextResponse.json({ data: defaultUserSettings }, { status: 200 })
27+
}
2328
logger.error(`[${requestId}] Settings fetch error`, error)
2429
return NextResponse.json({ data: defaultUserSettings }, { status: 200 })
2530
}

0 commit comments

Comments
 (0)