Files
sim/CLAUDE.md
Waleed 2cb12de546 refactor(queries): comprehensive TanStack Query best practices audit (#3460)
* refactor: comprehensive TanStack Query best practices audit and migration

- Add AbortSignal forwarding to all 41 queryFn implementations for proper request cancellation
- Migrate manual fetch patterns to useMutation hooks (useResetPassword, useRedeemReferralCode, usePurchaseCredits, useImportWorkflow, useOpenBillingPortal, useAllowedMcpDomains)
- Migrate standalone hooks to TanStack Query (use-next-available-slot, use-mcp-server-test, use-webhook-management, use-referral-attribution)
- Fix query key factories: add missing `all` keys, replace inline keys with factory methods
- Fix optimistic mutations: use onSettled instead of onSuccess for cache reconciliation
- Replace overly broad cache invalidations with targeted key invalidation
- Remove keepPreviousData from static-key queries where it provides no benefit
- Add staleTime to queries missing explicit cache duration
- Fix `any` type in UpdateSettingParams with proper GeneralSettings typing
- Remove dead code: loadingWebhooks/checkedWebhooks from subblock store, unused helper functions
- Update settings components (general, debug, referral-code, credit-balance, subscription, mcp) to use mutation state instead of manual useState for loading/error/success

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unstable mutation object from useCallback deps

openBillingPortal mutation object is not referentially stable,
but .mutate() is stable in TanStack Query v5. Remove from deps
to prevent unnecessary handleBadgeClick recreations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add missing byWorkflows invalidation to useUpdateTemplate

The onSettled handler was missing the byWorkflows() invalidation
that was dropped during the onSuccess→onSettled migration. Without
this, the deploy modal (useTemplateByWorkflow) would show stale data
after a template update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add TanStack Query best practices to CLAUDE.md and cursor rules

Add comprehensive React Query best practices covering:
- Hierarchical query key factories with intermediate plural keys
- AbortSignal forwarding in all queryFn implementations
- Targeted cache invalidation over broad .all invalidation
- onSettled for optimistic mutation cache reconciliation
- keepPreviousData only on variable-key queries
- No manual fetch in components rule
- Stable mutation references in useCallback deps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback

- Fix syncedRef regression in use-webhook-management: only set
  syncedRef.current=true when webhook is found, so re-sync works
  after webhook creation (e.g., post-deploy)
- Remove redundant detail(id) invalidation from useUpdateTemplate
  onSettled since onSuccess already populates cache via setQueryData

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address second round of PR review feedback

- Reset syncedRef when blockId changes in use-webhook-management so
  component reuse with a different block syncs the new webhook
- Add response.ok check in postAttribution so non-2xx responses
  throw and trigger TanStack Query retry logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use lists() prefix invalidation in useCreateWorkspaceCredential

Use workspaceCredentialKeys.lists() instead of .list(workspaceId) so
filtered list queries are also invalidated on credential creation,
matching the pattern used by update and delete mutations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address third round of PR review feedback

- Add nullish coalescing fallback for bonusAmount in referral-code
  to prevent rendering "undefined" when server omits the field
- Reset syncedRef when queryEnabled becomes false so webhook data
  re-syncs when the query is re-enabled without component remount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address fourth round of PR review feedback

- Add AbortSignal to testMcpServerConnection for consistency
- Wrap handleTestConnection in try/catch for mutateAsync error handling
- Replace broad subscriptionKeys.all with targeted users()/usage() invalidation
- Add intermediate users() key to subscription key factory for prefix matching
- Add comment documenting syncedRef null-webhook behavior
- Fix api-keys.ts silent error swallowing on non-ok responses
- Move deployments.ts cache invalidation from onSuccess to onSettled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: achieve full TanStack Query best practices compliance

- Add intermediate plural keys to api-keys, deployments, and schedules
  key factories for prefix-based invalidation support
- Change copilot-keys from refetchQueries to invalidateQueries
- Add signal parameter to organization.ts fetch functions (better-auth
  client does not support AbortSignal, documented accordingly)
- Move useCreateMcpServer invalidation from onSuccess to onSettled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:15:10 -08:00

12 KiB

Sim Development Guidelines

You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.

Global Standards

  • Logging: Import createLogger from @sim/logger. Use logger.info, logger.warn, logger.error instead of console.log
  • Comments: Use TSDoc for documentation. No ==== separators. No non-TSDoc comments
  • Styling: Never update global styles. Keep all styling local to components
  • Package Manager: Use bun and bunx, not npm and npx

Architecture

Core Principles

  1. Single Responsibility: Each component, hook, store has one clear purpose
  2. Composition Over Complexity: Break down complex logic into smaller pieces
  3. Type Safety First: TypeScript interfaces for all props, state, return types
  4. Predictable State: Zustand for global state, useState for UI-only concerns

Root Structure

apps/sim/
├── app/           # Next.js app router (pages, API routes)
├── blocks/        # Block definitions and registry
├── components/    # Shared UI (emcn/, ui/)
├── executor/      # Workflow execution engine
├── hooks/         # Shared hooks (queries/, selectors/)
├── lib/           # App-wide utilities
├── providers/     # LLM provider integrations
├── stores/        # Zustand stores
├── tools/         # Tool definitions
└── triggers/      # Trigger definitions

Naming Conventions

  • Components: PascalCase (WorkflowList)
  • Hooks: use prefix (useWorkflowOperations)
  • Files: kebab-case (workflow-list.tsx)
  • Stores: stores/feature/store.ts
  • Constants: SCREAMING_SNAKE_CASE
  • Interfaces: PascalCase with suffix (WorkflowListProps)

Imports

Always use absolute imports. Never use relative imports.

// ✓ Good
import { useWorkflowStore } from '@/stores/workflows/store'

// ✗ Bad
import { useWorkflowStore } from '../../../stores/workflows/store'

Use barrel exports (index.ts) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source.

Import Order

  1. React/core libraries
  2. External libraries
  3. UI components (@/components/emcn, @/components/ui)
  4. Utilities (@/lib/...)
  5. Stores (@/stores/...)
  6. Feature imports
  7. CSS imports

Use import type { X } for type-only imports.

TypeScript

  1. No any - Use proper types or unknown with type guards
  2. Always define props interface for components
  3. as const for constant objects/arrays
  4. Explicit ref types: useRef<HTMLDivElement>(null)

Components

'use client' // Only if using hooks

const CONFIG = { SPACING: 8 } as const

interface ComponentProps {
  requiredProp: string
  optionalProp?: boolean
}

export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
  // Order: refs → external hooks → store hooks → custom hooks → state → useMemo → useCallback → useEffect → return
}

Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational.

Hooks

interface UseFeatureProps { id: string }

export function useFeature({ id }: UseFeatureProps) {
  const idRef = useRef(id)
  const [data, setData] = useState<Data | null>(null)
  
  useEffect(() => { idRef.current = id }, [id])
  
  const fetchData = useCallback(async () => { ... }, []) // Empty deps when using refs
  
  return { data, fetchData }
}

Zustand Stores

Stores live in stores/. Complex stores split into store.ts + types.ts.

import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

const initialState = { items: [] as Item[] }

export const useFeatureStore = create<FeatureState>()(
  devtools(
    (set, get) => ({
      ...initialState,
      setItems: (items) => set({ items }),
      reset: () => set(initialState),
    }),
    { name: 'feature-store' }
  )
)

Use devtools middleware. Use persist only when data should survive reload with partialize to persist only necessary state.

React Query

All React Query hooks live in hooks/queries/. All server state must go through React Query — never use useState + fetch in components for data fetching or mutations.

Query Key Factory

Every file must have a hierarchical key factory with an all root key and intermediate plural keys for prefix invalidation:

export const entityKeys = {
  all: ['entity'] as const,
  lists: () => [...entityKeys.all, 'list'] as const,
  list: (workspaceId?: string) => [...entityKeys.lists(), workspaceId ?? ''] as const,
  details: () => [...entityKeys.all, 'detail'] as const,
  detail: (id?: string) => [...entityKeys.details(), id ?? ''] as const,
}

Query Hooks

  • Every queryFn must forward signal for request cancellation
  • Every query must have an explicit staleTime
  • Use keepPreviousData only on variable-key queries (where params change), never on static keys
export function useEntityList(workspaceId?: string) {
  return useQuery({
    queryKey: entityKeys.list(workspaceId),
    queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
    enabled: Boolean(workspaceId),
    staleTime: 60 * 1000,
    placeholderData: keepPreviousData, // OK: workspaceId varies
  })
}

Mutation Hooks

  • Use targeted invalidation (entityKeys.lists()) not broad (entityKeys.all) when possible
  • For optimistic updates: use onSettled (not onSuccess) for cache reconciliation — onSettled fires on both success and error
  • Don't include mutation objects in useCallback deps — .mutate() is stable in TanStack Query v5
export function useUpdateEntity() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async (variables) => { /* ... */ },
    onMutate: async (variables) => {
      await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) })
      const previous = queryClient.getQueryData(entityKeys.detail(variables.id))
      queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic */)
      return { previous }
    },
    onError: (_err, variables, context) => {
      queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous)
    },
    onSettled: (_data, _error, variables) => {
      queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
      queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) })
    },
  })
}

Styling

Use Tailwind only, no inline styles. Use cn() from @/lib/utils for conditional classes.

<div className={cn('base-classes', isActive && 'active-classes')} />

EMCN Components

Import from @/components/emcn, never from subpaths (except CSS files). Use CVA when 2+ variants exist.

Testing

Use Vitest. Test files: feature.tsfeature.test.ts. See .cursor/rules/sim-testing.mdc for full details.

Global Mocks (vitest.setup.ts)

@sim/db, drizzle-orm, @sim/logger, @/blocks/registry, @trigger.dev/sdk, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior.

Standard Test Pattern

/**
 * @vitest-environment node
 */
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetSession } = vi.hoisted(() => ({
  mockGetSession: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({
  auth: { api: { getSession: vi.fn() } },
  getSession: mockGetSession,
}))

import { GET } from '@/app/api/my-route/route'

describe('my route', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
  })
  it('returns data', async () => { ... })
})

Performance Rules

  • NEVER use vi.resetModules() + vi.doMock() + await import() — use vi.hoisted() + vi.mock() + static imports
  • NEVER use vi.importActual() — mock everything explicitly
  • NEVER use mockAuth(), mockConsoleLogger(), setupCommonApiMocks() from @sim/testing — they use vi.doMock() internally
  • Mock heavy deps (@/blocks, @/tools/registry, @/triggers) in tests that don't need them
  • Use @vitest-environment node unless DOM APIs are needed (window, document, FormData)
  • Avoid real timers — use 1ms delays or vi.useFakeTimers()

Use @sim/testing mocks/factories over local test data.

Utils Rules

  • Never create utils.ts for single consumer - inline it
  • Create utils.ts when 2+ files need the same helper
  • Check existing sources in lib/ before duplicating

Adding Integrations

New integrations require: ToolsBlockIcon → (optional) Trigger

Always look up the service's API docs first.

1. Tools (tools/{service}/)

tools/{service}/
├── index.ts      # Barrel export
├── types.ts      # Params/response types
└── {action}.ts   # Tool implementation

Tool structure:

export const serviceTool: ToolConfig<Params, Response> = {
  id: 'service_action',
  name: 'Service Action',
  description: '...',
  version: '1.0.0',
  oauth: { required: true, provider: 'service' },
  params: { /* ... */ },
  request: { url: '/api/tools/service/action', method: 'POST', ... },
  transformResponse: async (response) => { /* ... */ },
  outputs: { /* ... */ },
}

Register in tools/registry.ts.

2. Block (blocks/blocks/{service}.ts)

export const ServiceBlock: BlockConfig = {
  type: 'service',
  name: 'Service',
  description: '...',
  category: 'tools',
  bgColor: '#hexcolor',
  icon: ServiceIcon,
  subBlocks: [ /* see SubBlock Properties */ ],
  tools: { access: ['service_action'], config: { tool: (p) => `service_${p.operation}`, params: (p) => ({ /* type coercions here */ }) } },
  inputs: { /* ... */ },
  outputs: { /* ... */ },
}

Register in blocks/registry.ts (alphabetically).

Important: tools.config.tool runs during serialization (before variable resolution). Never do Number() or other type coercions there — dynamic references like <Block.output> will be destroyed. Use tools.config.params for type coercions (it runs during execution, after variables are resolved).

SubBlock Properties:

{
  id: 'field', title: 'Label', type: 'short-input', placeholder: '...',
  required: true,                    // or condition object
  condition: { field: 'op', value: 'send' },  // show/hide
  dependsOn: ['credential'],         // clear when dep changes
  mode: 'basic',                     // 'basic' | 'advanced' | 'both' | 'trigger'
}

condition examples:

  • { field: 'op', value: 'send' } - show when op === 'send'
  • { field: 'op', value: ['a','b'] } - show when op is 'a' OR 'b'
  • { field: 'op', value: 'x', not: true } - show when op !== 'x'
  • { field: 'op', value: 'x', not: true, and: { field: 'type', value: 'dm', not: true } } - complex

dependsOn: ['field'] or { all: ['a'], any: ['b', 'c'] }

File Input Pattern (basic/advanced mode):

// Basic: file-upload UI
{ id: 'uploadFile', type: 'file-upload', canonicalParamId: 'file', mode: 'basic' },
// Advanced: reference from other blocks
{ id: 'fileRef', type: 'short-input', canonicalParamId: 'file', mode: 'advanced' },

In tools.config.tool, normalize with:

import { normalizeFileInput } from '@/blocks/utils'
const file = normalizeFileInput(params.uploadFile || params.fileRef, { single: true })
if (file) params.file = file

For file uploads, create an internal API route (/api/tools/{service}/upload) that uses downloadFileFromStorage to get file content from UserFile objects.

3. Icon (components/icons.tsx)

export function ServiceIcon(props: SVGProps<SVGSVGElement>) {
  return <svg {...props}>/* SVG from brand assets */</svg>
}

4. Trigger (triggers/{service}/) - Optional

triggers/{service}/
├── index.ts      # Barrel export
├── webhook.ts    # Webhook handler
└── {event}.ts    # Event-specific handlers

Register in triggers/registry.ts.

Integration Checklist

  • Look up API docs
  • Create tools/{service}/ with types and tools
  • Register tools in tools/registry.ts
  • Add icon to components/icons.tsx
  • Create block in blocks/blocks/{service}.ts
  • Register block in blocks/registry.ts
  • (Optional) Create and register triggers
  • (If file uploads) Create internal API route with downloadFileFromStorage
  • (If file uploads) Use normalizeFileInput in block config