Skip to content

Commit 6260eda

Browse files
authored
fix(ssr): harden credential query-key factory + fetchers against the 'use client' stub bug (#5206)
* fix(ssr): move credential query-key factory + fetchers to non-client modules Preventively closes the same 'use client' SSR client-reference-stub class that crashed the tables page. Server-evaluated modules (the credential block def, the workflow-comparison helpers) imported workspaceCredentialKeys / fetchWorkspaceCredentialList / fetchCredentialSetById from 'use client' hook modules, where they resolve to client-reference stubs on the server (a future server call path would throw 'X is not a function'). Extract them into non-client hooks/queries/utils/{credential-keys, fetch-workspace-credentials,fetch-credential-set}.ts (mirroring folder-keys.ts / fetch-workflow-envelope.ts) and import from there. No behavior change — these values were only ever called from browser paths. * docs+ci: codify the 'use client' server-import rule + add check:client-boundary Document the Next.js rule that server code can only render a 'use client' export as a component, never call it (server imports resolve to client-reference stubs that throw — the tables-page crash). Add the rule to .claude/rules/sim-queries.md + a cross-ref in sim-architecture.md. Add scripts/check-client-boundary-imports.ts (wired into CI as check:client-boundary) that flags any value import from a 'use client' module in a server-evaluated, non-JSX surface (prefetch / route handler / trigger / block definition), so this class can't silently recur. Escape hatch: // client-boundary-allow: <reason>.
1 parent cff7a49 commit 6260eda

16 files changed

Lines changed: 328 additions & 55 deletions

File tree

.claude/rules/sim-architecture.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ packages/ # @sim/* — audit, auth, db, logger, realtime-protocol
3838
- `apps/* → packages/*` only. Packages never import from `apps/*`.
3939
- `apps/realtime` avoids Next.js, React, the block/tool registry, provider SDKs, and the executor; never add `@/lib/webhooks/providers/*`, `@/executor/*`, `@/blocks/*`, or `@/tools/*` imports to any package it consumes. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`.
4040

41+
## The `'use client'` server boundary
42+
43+
Every export of a `'use client'` module becomes a *client reference* on the server — server-evaluated code (RSC pages/layouts, `prefetch.ts`, route handlers, block definitions, triggers) can only *render* it as a component or pass it as a prop, never *call* it (doing so throws at runtime, e.g. `tableKeys.list is not a function`; `next build` does not catch it). Keep server-importable query primitives (key factories, fetchers, mappers, constants) in non-`'use client'` modules — see `.claude/rules/sim-queries.md`. Enforced by `scripts/check-client-boundary-imports.ts`.
44+
4145
## Feature Organization
4246

4347
Features live under `app/workspace/[workspaceId]/`:

.claude/rules/sim-queries.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ Never use inline query keys — always use the factory.
2727

2828
**Every identifier the `queryFn` forwards into the fetch MUST appear in the `queryKey`.** (Query-machinery identifiers — `signal`, `pageParam` — are exempt; they aren't fetch-scoping args.) If the fetch is scoped by `workspaceId`, `cursor`, `limit`, an org id, etc., those values must be part of the key — otherwise distinct fetch args share one cache entry (a cross-tenant / per-param cache collision). The lone exception is a globally-unique id used as the key while a second fetch arg is only an authz scope that cannot collide; annotate those with `// rq-lint-allow: <reason>`. Enforced by the `key-fetch-arg-drift` check in `scripts/check-react-query-patterns.ts`.
2929

30+
## Server-importable query primitives must NOT live in a `'use client'` module
31+
32+
Next.js rewrites **every** export of a `'use client'` module into a *client reference* in the server bundle. Server-evaluated code — RSC `page.tsx`/`layout.tsx`, `prefetch.ts`, route handlers, **block definitions**, triggers/workers — can only *render* such an export as a component or pass it as a prop; **calling** one throws at runtime (`Attempted to call X from the server but X is on the client` — for an object export it surfaces as `X.list is not a function`). `next build` does **not** catch this — only SSR/runtime does.
33+
34+
So any **query-key factory, standalone `requestJson` fetcher, mapper, or constant** that a server module imports must live in a **non-`'use client'`** module:
35+
36+
- key factories → `hooks/queries/utils/<entity>-keys.ts` (see `folder-keys.ts`, `table-keys.ts`, `credential-keys.ts`)
37+
- standalone fetchers/mappers → `hooks/queries/utils/fetch-*.ts` / `*-list-query.ts` (see `fetch-workflow-envelope.ts`, `fetch-credential-set.ts`)
38+
39+
The `'use client'` hook module then imports these back for its hooks. **Never** define a server-imported factory/fetcher directly in a `'use client'` hooks file — it crashes SSR (this caused the tables-page crash). Enforced for prefetch/route/trigger/block files by `scripts/check-client-boundary-imports.ts` (`bun run check:client-boundary`, run in CI). Escape hatch for a genuinely browser-only path: `// client-boundary-allow: <reason>` on the line above the import.
40+
3041
## File Structure
3142

3243
```typescript

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ jobs:
122122
- name: React Query pattern audit
123123
run: bun run check:react-query
124124

125+
- name: Client boundary import audit
126+
run: bun run check:client-boundary
127+
125128
- name: Verify realtime prune graph
126129
run: bun run check:realtime-prune
127130

apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,15 @@ import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
1717
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
1818
import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field'
1919
import { isValidEnvVarName } from '@/executor/constants'
20-
import {
21-
useWorkspaceCredentials,
22-
type WorkspaceCredential,
23-
workspaceCredentialKeys,
24-
} from '@/hooks/queries/credentials'
20+
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
2521
import {
2622
usePersonalEnvironment,
2723
useRemoveWorkspaceEnvironment,
2824
useSavePersonalEnvironment,
2925
useUpsertWorkspaceEnvironment,
3026
useWorkspaceEnvironment,
3127
} from '@/hooks/queries/environment'
28+
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
3229
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
3330
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
3431

apps/sim/blocks/blocks/credential.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { CredentialIcon } from '@/components/icons'
22
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
33
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
44
import type { BlockConfig } from '@/blocks/types'
5-
import { fetchWorkspaceCredentialList, workspaceCredentialKeys } from '@/hooks/queries/credentials'
5+
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
6+
import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials'
67
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
78

89
interface CredentialBlockOutput {

apps/sim/hooks/queries/credential-sets.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
createCredentialSetContract,
2020
createCredentialSetInvitationContract,
2121
deleteCredentialSetContract,
22-
getCredentialSetContract,
2322
leaveCredentialSetContract,
2423
listCredentialSetInvitationDetailsContract,
2524
listCredentialSetInvitationsContract,
@@ -29,6 +28,7 @@ import {
2928
removeCredentialSetMemberContract,
3029
resendCredentialSetInvitationContract,
3130
} from '@/lib/api/contracts'
31+
import { fetchCredentialSetById } from '@/hooks/queries/utils/fetch-credential-set'
3232

3333
export type {
3434
CreateCredentialSetData,
@@ -76,18 +76,6 @@ export function useCredentialSets(organizationId?: string, enabled = true) {
7676
})
7777
}
7878

79-
export async function fetchCredentialSetById(
80-
id: string,
81-
signal?: AbortSignal
82-
): Promise<CredentialSet | null> {
83-
if (!id) return null
84-
const data = await requestJson(getCredentialSetContract, {
85-
params: { id },
86-
signal,
87-
})
88-
return data.credentialSet ?? null
89-
}
90-
9179
export function useCredentialSetDetail(id?: string, enabled = true) {
9280
return useQuery<CredentialSet | null>({
9381
queryKey: credentialSetKeys.detail(id),

apps/sim/hooks/queries/credentials.ts

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
type WorkspaceCredentialType,
2121
} from '@/lib/api/contracts'
2222
import { environmentKeys } from '@/hooks/queries/environment'
23+
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
24+
import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials'
2325

2426
/**
2527
* Key prefix for OAuth credential queries.
@@ -34,38 +36,6 @@ export type {
3436
WorkspaceCredentialType,
3537
}
3638

37-
export const workspaceCredentialKeys = {
38-
all: ['workspaceCredentials'] as const,
39-
lists: () => [...workspaceCredentialKeys.all, 'list'] as const,
40-
list: (workspaceId?: string, type?: string, providerId?: string) =>
41-
[
42-
...workspaceCredentialKeys.lists(),
43-
workspaceId ?? 'none',
44-
type ?? 'all',
45-
providerId ?? 'all',
46-
] as const,
47-
details: () => [...workspaceCredentialKeys.all, 'detail'] as const,
48-
detail: (credentialId?: string) =>
49-
[...workspaceCredentialKeys.details(), credentialId ?? 'none'] as const,
50-
members: (credentialId?: string) =>
51-
[...workspaceCredentialKeys.detail(credentialId), 'members'] as const,
52-
}
53-
54-
/**
55-
* Fetch workspace credential list from API.
56-
* Used by the prefetch function for hover-based cache warming.
57-
*/
58-
export async function fetchWorkspaceCredentialList(
59-
workspaceId: string,
60-
signal?: AbortSignal
61-
): Promise<WorkspaceCredential[]> {
62-
const data = await requestJson(listWorkspaceCredentialsContract, {
63-
query: { workspaceId },
64-
signal,
65-
})
66-
return data.credentials ?? []
67-
}
68-
6939
/**
7040
* Prefetch workspace credentials into a QueryClient cache.
7141
* Use on hover to warm data before navigation.

apps/sim/hooks/queries/invitations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {
1111
resendInvitationContract,
1212
} from '@/lib/api/contracts/invitations'
1313
import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces'
14-
import { workspaceCredentialKeys } from '@/hooks/queries/credentials'
1514
import { organizationKeys } from '@/hooks/queries/organization'
15+
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
1616
import { workspaceKeys } from '@/hooks/queries/workspace'
1717

1818
export const invitationKeys = {

apps/sim/hooks/queries/organization.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ import {
4242
import { client } from '@/lib/auth/auth-client'
4343
import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers'
4444
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
45-
import { workspaceCredentialKeys } from '@/hooks/queries/credentials'
4645
import { subscriptionKeys } from '@/hooks/queries/subscription'
46+
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
4747
import { workspaceKeys } from '@/hooks/queries/workspace'
4848

4949
const logger = createLogger('OrganizationQueries')
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* React Query key factory for workspace credentials.
3+
*
4+
* Lives in this standalone (non-`'use client'`) module — like
5+
* {@link file://./folder-keys.ts} — so server-evaluated code (block
6+
* definitions, server prefetch) can import it without pulling client-reference
7+
* stubs from the `'use client'` `@/hooks/queries/credentials` module.
8+
*/
9+
export const workspaceCredentialKeys = {
10+
all: ['workspaceCredentials'] as const,
11+
lists: () => [...workspaceCredentialKeys.all, 'list'] as const,
12+
list: (workspaceId?: string, type?: string, providerId?: string) =>
13+
[
14+
...workspaceCredentialKeys.lists(),
15+
workspaceId ?? 'none',
16+
type ?? 'all',
17+
providerId ?? 'all',
18+
] as const,
19+
details: () => [...workspaceCredentialKeys.all, 'detail'] as const,
20+
detail: (credentialId?: string) =>
21+
[...workspaceCredentialKeys.details(), credentialId ?? 'none'] as const,
22+
members: (credentialId?: string) =>
23+
[...workspaceCredentialKeys.detail(credentialId), 'members'] as const,
24+
}

0 commit comments

Comments
 (0)