chore(studio): migrate cursor rules to claude skills + add CLAUDE.md (#44343)

Migrates all studio-related Cursor rules to Claude skills and adds a
top-level `.claude/CLAUDE.md` for project context. Docs rules left in
place.

**Decisions:**
- Only studio + testing rules migrated — docs rules intentionally left
in `.cursor/rules/docs/`
- Vitest skill already shared via symlink (`.claude/skills/vitest` →
`.agents/skills/vitest`) — nothing to migrate
- Grouped ~21 granular cursor rules into 5 new skills + 1 updated skill
by topic
- `studio-architecture` skill fully merged into `CLAUDE.md` and deleted
to avoid overlap
- Skills are self-contained (content inlined, not relying on sub-files)
since Claude reads SKILL.md first
- Skills cross-reference each other inline where relevant (e.g.
best-practices → testing, error-handling, queries)
- No `paths` frontmatter — would auto-inject full skill content on every
matching file. Current description-based matching is more selective and
token-efficient.

**Removed:**
- `.cursor/rules/studio/` (21 rule files covering architecture, best
practices, UI patterns, queries, styling, etc.)
- `.cursor/rules/testing/` (e2e-studio + unit-integration rules)
- `.cursor/rules/studio-useStaticEffectEvent.mdc`
- `.claude/skills/studio-architecture/` — fully merged into CLAUDE.md to
avoid duplication
- `.claude/skills/studio-testing/rules/` — orphaned sub-files after
inlining content into SKILL.md

**Added:**
- `.claude/CLAUDE.md` — concise monorepo overview with structure,
commands, and conventions. Absorbs studio-architecture content.
References `studio-*` skills for detail.
- `.claude/skills/studio-best-practices/` — boolean naming, component
structure, loading/error/success patterns, state management, hooks,
TypeScript conventions. Cross-references `vercel-composition-patterns`,
`studio-ui-patterns`, `studio-queries`, `studio-error-handling`, and
`studio-testing` inline where relevant.
- `.claude/skills/studio-ui-patterns/` — layout, forms, tables, charts,
empty states, navigation, cards, alerts, sheets. Grouped from ~10
separate cursor rules into one cohesive skill.
- `.claude/skills/studio-queries/` — React Query `queryOptions` pattern,
`keys.ts` structure, mutation hook template, imperative fetching.
- `.claude/skills/use-static-effect-event/` — the `useStaticEffectEvent`
hook: when to use, when not to, patterns, implementation.

**Changed:**
- `.claude/skills/studio-e2e-tests/` — renamed from `e2e-studio-tests`
for `studio-*` naming consistency. Merged race condition, waiting
strategy, test structure, assertion, and cleanup patterns from the
cursor e2e rule.
- `.claude/skills/studio-testing/` — inlined key content from sub-rule
files directly into SKILL.md so it's self-contained. Removed broken
`AGENTS.md` reference. Deleted orphaned `rules/` sub-files.
- `.claude/skills/vercel-composition-patterns/` — added note that Studio
uses React 18, so React 19 patterns should be skipped.
- `.gitignore` — added `!.claude/CLAUDE.md` exception so it's tracked.

## To test

- Open Claude Code in the repo, verify `.claude/CLAUDE.md` loads as
project context
- Ask Claude about Studio conventions and verify it references the right
skills
- Check that `studio-*` skills appear in the skill list

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alaister Young
2026-03-30 23:33:04 +08:00
committed by GitHub
parent d817cf9c2a
commit 4295e41e81
32 changed files with 940 additions and 1812 deletions
+38
View File
@@ -0,0 +1,38 @@
# Supabase Monorepo
pnpm 10 + Turborepo monorepo. Requires Node >= 22.
## Structure
| Directory | Purpose |
| ----------------- | ------------------------------------------------------------ |
| `apps/studio` | Supabase Studio/Dashboard — Next.js (pages router), React 18 |
| `apps/docs` | Documentation site |
| `apps/www` | Marketing website |
| `packages/ui` | Shared UI components (shadcn/ui based) |
| `packages/common` | Shared utilities and telemetry constants |
| `e2e/studio` | Playwright E2E tests for Studio |
## Common Commands
```bash
pnpm install # install dependencies
pnpm dev:studio # run Studio dev server
pnpm test:studio # run Studio unit tests (vitest)
pnpm --prefix e2e/studio run e2e # run Studio E2E tests (playwright)
pnpm build --filter=studio # build Studio
pnpm lint --filter=studio # lint Studio
pnpm typecheck # typecheck all packages
```
## Conventions
**UI** — import from `'ui'`, use `_Shadcn_` suffixed variants for form primitives. Check `packages/ui/index.tsx` before creating new primitives.
**Styling** — Tailwind only, semantic tokens (`bg-muted`, `text-foreground-light`), no hardcoded colors.
## Studio
Pages router. Co-locate sub-components with parent. Avoid barrel re-export files.
See studio-\* skills for detailed studio conventions.
@@ -0,0 +1,175 @@
---
name: studio-best-practices
description: React and TypeScript best practices for Supabase Studio. Use when writing
or reviewing Studio components — covers boolean naming, component structure, loading/error
states, state management, custom hooks, event handlers, conditional rendering,
performance, and TypeScript conventions.
---
# Studio Best Practices
Applies to `apps/studio/**/*.{ts,tsx}`.
## Boolean Naming
Use descriptive prefixes — derive from existing state rather than storing separately:
- `is` — state/identity: `isLoading`, `isPaused`, `isNewRecord`
- `has` — possession: `hasPermission`, `hasData`
- `can` — capability: `canUpdateColumns`, `canDelete`
- `should` — conditional behavior: `shouldFetch`, `shouldRender`
Extract complex conditions into named variables:
```tsx
// ❌ inline multi-condition
{
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading && <Button />
}
// ✅ named variable
const canShowAddButton =
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading
{
canShowAddButton && <Button />
}
```
Derive booleans — don't store them:
```tsx
// ❌ stored derived state
const [isFormValid, setIsFormValid] = useState(false)
useEffect(() => {
setIsFormValid(name.length > 0 && email.includes('@'))
}, [name, email])
// ✅ derived
const isFormValid = name.length > 0 && email.includes('@')
```
## Component Structure
See `vercel-composition-patterns` skill for compound component and composition patterns.
Keep components under 200300 lines. Split when you see:
- Multiple distinct UI sections
- Complex conditional rendering
- Multiple unrelated `useState` calls
- Hard to understand at a glance
Co-locate sub-components in the same directory as the parent. Avoid barrel re-export files.
Extract repeated JSX patterns into small components.
## Data Fetching
All data fetching uses TanStack Query (React Query). See `studio-queries` skill for query/mutation patterns and `studio-error-handling` skill for error display conventions.
### Loading / Error / Success Pattern
Top level:
```tsx
const { data, error, isLoading, isError, isSuccess } = useQuery(...)
if (isLoading) return <GenericSkeletonLoader />
if (isError) return <AlertError error={error} subject="Failed to load data" />
if (isSuccess && data.length === 0) return <EmptyState />
return <DataDisplay data={data} />
```
Use early returns — avoid deeply nested conditionals.
Inline:
```tsx
<div>
{isLoading && <InlineLoader />}
{isError && <InlineError error={error} />}
{isSuccess && data.length === 0 && <EmptyState />}
{isSuccess && data.length > 0 && <DataDisplay data={data} />}
</div>
```
## State Management
Keep state as local as possible; lift only when needed.
Group related form state with `react-hook-form` rather than multiple `useState` calls. See `studio-ui-patterns` skill for form layout and component conventions.
```tsx
// ❌ multiple related useState
const [name, setName] = useState('')
const [email, setEmail] = useState('')
// ✅ grouped with react-hook-form
const form = useForm<FormValues>({ defaultValues: { name: '', email: '' } })
```
## Custom Hooks
Extract complex or reusable logic into hooks. Return objects, not arrays:
```tsx
// ❌ array return (hard to extend)
return [value, toggle]
// ✅ object return
return { value, toggle, setTrue, setFalse }
```
## Event Handlers
- Prop callbacks: `on` prefix (`onClose`, `onSave`)
- Internal handlers: `handle` prefix (`handleSubmit`, `handleCancel`)
Use `useCallback` for handlers passed to memoized children; avoid unnecessary inline arrow functions.
## Conditional Rendering
```tsx
// Simple show/hide
<>{isVisible && <Component />}</>
// Binary choice
<>{isLoading ? <Spinner /> : <Content />}</>
// Multiple conditions — use early returns, not nested ternaries
if (isLoading) return <Spinner />
if (isError) return <Error />
return <Content />
```
## Performance
`useMemo` for genuinely expensive computations (measured, not assumed). Don't wrap everything — only optimize when you have a measured problem or are passing values to memoized children.
## TypeScript
Define prop interfaces explicitly. Use discriminated unions for complex state:
```tsx
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
```
Avoid `as any` / `as Type` casts. Validate at boundaries with zod:
```tsx
// ❌ type cast
const user = apiResponse as User
// ✅ zod parse
const user = userSchema.parse(apiResponse)
// or safe:
const result = userSchema.safeParse(apiResponse)
```
## Testing
Extract logic into `.utils.ts` pure functions and test exhaustively. See the `studio-testing` skill for the full testing strategy and decision tree.
@@ -1,6 +1,9 @@
---
name: e2e-studio-tests
description: Run e2e tests in the Studio app. Use when asked to run e2e tests, run studio tests, playwright tests, or test the feature.
name: studio-e2e-tests
description: Write and run Playwright E2E tests for Supabase Studio. Use when asked
to run e2e tests, write new E2E tests, or debug flaky tests. Covers running commands,
avoiding race conditions, waiting strategies, selectors, helper functions, and CI
vs local differences.
---
# E2E Studio Tests
@@ -70,17 +73,20 @@ test.describe.configure({ mode: 'serial' })
### Selector priority (best to worst)
1. **`getByRole` with accessible name** - Most robust, tests accessibility
```typescript
page.getByRole('button', { name: 'Save' })
page.getByRole('button', { name: 'Configure API privileges' })
```
2. **`getByTestId`** - Stable, explicit test hooks
```typescript
page.getByTestId('table-editor-side-panel')
```
3. **`getByText` with exact match** - Good for unique text
```typescript
page.getByText('Data API Access', { exact: true })
```
@@ -93,12 +99,14 @@ test.describe.configure({ mode: 'serial' })
### Patterns to avoid
- **XPath selectors** - Fragile to DOM changes
```typescript
// BAD
locator('xpath=ancestor::div[contains(@class, "space-y")]')
```
- **Parent traversal with `locator('..')`** - Breaks when structure changes
```typescript
// BAD
element.locator('..').getByRole('button')
@@ -123,6 +131,7 @@ When a component lacks a good accessible name, add one in the source code:
```
Then use it in tests:
```typescript
page.getByRole('button', { name: 'Configure API privileges' })
```
@@ -141,6 +150,76 @@ const popover = page.locator('[data-radix-popper-content-wrapper]')
const roleSection = popover.getByText('Anonymous (anon)', { exact: true })
```
## Avoiding Race Conditions
**Set up API waiters BEFORE triggering actions.** This is the most common source of flaky tests.
```ts
// ❌ Race condition — response may complete before waiter is set up
await page.getByRole('button', { name: 'Save' }).click()
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
// ✅ Waiter is ready before the action
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
await page.getByRole('button', { name: 'Save' }).click()
await apiPromise
```
Same rule applies before navigation:
```ts
const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
```
When an action triggers multiple API calls, wait for all of them:
```ts
const createTablePromise = waitForApiResponseWithTimeout(page, (r) =>
r.url().includes('query?key=table-create')
)
const tablesPromise = waitForApiResponseWithTimeout(page, (r) =>
r.url().includes('tables?include_columns=true')
)
await page.getByRole('button', { name: 'Save' }).click()
await Promise.all([createTablePromise, tablesPromise])
```
## Waiting Strategies
Playwright auto-waits for elements to be actionable — prefer this over manual timeouts.
Use `expect.poll` for dynamic state changes:
```ts
await expect.poll(async () => await page.getByLabel(`View ${tableName}`).count()).toBe(0)
```
Use `waitForSelector` with state for element lifecycle:
```ts
await page.waitForSelector('[data-testid="side-panel"]', { state: 'detached' })
```
Avoid `networkidle` — use specific API waits instead:
```ts
// ❌ Unreliable and slow
await page.waitForLoadState('networkidle')
// ✅ Specific API response
await waitForApiResponse(page, 'pg-meta', ref, 'tables')
```
Timeouts are acceptable only for client-side debounces:
```ts
await page.getByRole('textbox').fill('search term')
await page.waitForTimeout(300) // allow debounce
```
## Avoiding `waitForTimeout`
Never use `waitForTimeout` - always wait for something specific:
@@ -175,6 +254,129 @@ await expect(menuButton).toBeVisible()
await menuButton.click()
```
## Test Structure
Always import from the custom test utility:
```ts
import { test } from '../utils/test.js'
```
Use `withFileOnceSetup` for expensive setup that should run once per file:
```ts
test.beforeAll(async ({ browser, ref }) => {
await withFileOnceSetup(import.meta.url, async () => {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await deleteTestTables(page, ref)
})
})
test.afterAll(async () => {
await releaseFileOnceCleanup(import.meta.url)
})
```
Dismiss toasts before interacting — they can overlay buttons:
```ts
const dismissToastsIfAny = async (page: Page) => {
const closeButtons = page.getByRole('button', { name: 'Close toast' })
const count = await closeButtons.count()
for (let i = 0; i < count; i++) {
await closeButtons.nth(i).click()
}
}
await dismissToastsIfAny(page)
await page.getByRole('button', { name: 'New table' }).click()
```
## Assertions
Always include descriptive messages for easier debugging:
```ts
// ❌ No context on failure
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
// ✅ Clear message on failure
await expect(
page.getByRole('button', { name: 'Save' }),
'Save button should be visible after form is filled'
).toBeVisible()
```
Use explicit timeouts for slow operations:
```ts
await expect(
page.getByText(`Table ${tableName} is good to go!`),
'Success toast should be visible after table creation'
).toBeVisible({ timeout: 50000 })
```
## Helper Functions
Extract reusable operations into domain helpers (e.g. `e2e/studio/utils/storage-helpers.ts`).
Use the existing wait utilities:
```ts
import {
createApiResponseWaiter,
waitForApiResponse,
waitForGridDataToLoad,
waitForTableToLoad,
} from '../utils/wait-for-response.js'
```
Use `expectClipboardValue` instead of manual clipboard reads with hardcoded timeouts:
```ts
// ❌ Brittle
await page.evaluate(() => navigator.clipboard.readText())
await page.waitForTimeout(500)
// ✅ Uses Playwright auto-retries
await expectClipboardValue({ page, value: 'expectedValue' })
```
## API Mocking
```ts
await page.route('*/**/logs.all*', async (route) => {
await route.fulfill({ body: JSON.stringify(mockAPILogs) })
})
```
Use soft waits for optional API calls:
```ts
await waitForApiResponse(page, 'pg-meta', ref, 'optional-endpoint', {
soft: true,
fallbackWaitMs: 1000,
})
```
## Cleanup
Clean up test data in `beforeAll`/`beforeEach`. Check before deleting to handle existing state gracefully:
```ts
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
if ((await bucketRow.count()) === 0) return
// proceed with deletion
```
Reset local storage after tests that modify it:
```ts
import { resetLocalStorage } from '../utils/reset-local-storage.js'
await resetLocalStorage(page, ref)
```
## Debugging
### View trace
+148
View File
@@ -0,0 +1,148 @@
---
name: studio-queries
description: React Query conventions for data fetching in Supabase Studio. Use when
writing or reviewing query hooks, mutation hooks, or query keys in apps/studio/data/.
Covers queryOptions pattern, keys.ts structure, mutation hook template, and imperative
fetching.
---
# Studio Queries & Mutations (React Query)
Follow the patterns in `apps/studio/data/`. Reference examples:
- Query options: `apps/studio/data/table-editor/table-editor-query.ts`
- Mutation hook: `apps/studio/data/edge-functions/edge-functions-update-mutation.ts`
- Keys: `apps/studio/data/edge-functions/keys.ts`
## Query Keys
Define a `keys.ts` per domain. Export `*Keys` helpers using array keys with `as const`. Never inline query keys in components.
```ts
export const edgeFunctionsKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const,
detail: (projectRef: string | undefined, slug: string | undefined) =>
['projects', projectRef, 'edge-function', slug, 'detail'] as const,
}
```
## Query Options (preferred pattern)
Use `queryOptions` from `@tanstack/react-query`. This gives type safety and works with both `useQuery()` and `queryClient.fetchQuery()`.
Rules:
- Export `XVariables`, `XData`, and `XError` types (prefixed with the domain name)
- Implement a **private** `getX(variables, signal?)` function:
- Throws if required variables are missing
- Passes `signal` for cancellation
- Calls `handleError(error)` on failure (which throws); returns `data` on success
- Not exported — use `queryClient.fetchQuery(xQueryOptions(...))` for imperative fetching
- Export `xQueryOptions()` using `queryOptions`
- Gate with `enabled` so the query doesn't run until required variables exist
- Platform-only queries: include `IS_PLATFORM` from `lib/constants` in `enabled`
- Don't add extra params to `xQueryOptions` — callers override by destructuring: `{ ...xQueryOptions(vars), enabled: true }`
```ts
import { queryOptions } from '@tanstack/react-query'
import { xKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import { IS_PLATFORM } from '@/lib/constants'
import { ResponseError } from '@/types'
export type XVariables = { projectRef?: string }
export type XError = ResponseError
async function getX({ projectRef }: XVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await get('/v1/projects/{ref}/x', {
params: { path: { ref: projectRef } },
signal,
})
if (error) handleError(error)
return data
}
export type XData = Awaited<ReturnType<typeof getX>>
export const xQueryOptions = ({ projectRef }: XVariables) =>
queryOptions({
queryKey: xKeys.list(projectRef),
queryFn: ({ signal }) => getX({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
})
```
## Using Query Options in Components
```ts
import { useQuery } from '@tanstack/react-query'
import { xQueryOptions } from '@/data/x/x-query'
const { data, isPending, isError } = useQuery(xQueryOptions({ projectRef: project?.ref }))
```
## Imperative Fetching (outside React or in callbacks)
```ts
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const handleClick = useCallback(
async (id: number) => {
const data = await queryClient.fetchQuery(xQueryOptions({ id, projectRef: project?.ref }))
// use data...
},
[project?.ref, queryClient]
)
```
## Mutation Hook
- Export a `Variables` type with `projectRef`, identifiers, and `payload`
- Implement a private `updateX(vars)` function with required variable validation and `handleError`
- Wrap in `useXMutation()`:
- Accepts `UseMutationOptions` (omit `mutationFn`)
- Invalidates `list()` + `detail()` keys in `onSuccess` with `await Promise.all([...])`
- Defaults to `toast.error(...)` when `onError` isn't provided
```ts
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { xKeys } from './keys'
type XUpdateVariables = { projectRef: string; slug: string; payload: XPayload }
export const useXUpdateMutation = ({
onSuccess,
onError,
...options
}: UseMutationOptions<XData, XError, XUpdateVariables> = {}) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateX,
async onSuccess(data, variables, context) {
await Promise.all([
queryClient.invalidateQueries({
queryKey: xKeys.detail(variables.projectRef, variables.slug),
}),
queryClient.invalidateQueries({ queryKey: xKeys.list(variables.projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(error, variables, context) {
if (onError === undefined) toast.error(`Failed to update: ${error.message}`)
else onError(error, variables, context)
},
...options,
})
}
```
## Component Usage
- Use React Query v5 flags: `isPending` for initial load, `isFetching` for background refetches
- Render states explicitly in order: pending → error → success
+95 -23
View File
@@ -68,36 +68,108 @@ Is the logic a pure transformation (parse, format, validate, compute)?
NO -> Write a component test
```
## How to Use
## 1. Extract Logic Into Utility Files (CRITICAL)
Read individual rule files for detailed explanations and code examples:
Remove as much logic from components as possible. Put it in co-located
`.utils.ts` files as pure functions: arguments in, return value out.
```
rules/testing-extract-logic.md
rules/testing-exhaustive-permutations.md
**File naming:**
- Utility: `ComponentName.utils.ts` next to the component
- Test: `tests/components/.../ComponentName.utils.test.ts` mirroring the source path
```tsx
// ❌ Logic buried in component — hard to test without rendering
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
const handleSubmit = () => {
const taxId = TAX_IDS.find((t) => t.name === taxIdName)
let sanitized = taxIdValue
if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
sanitized = taxId.vatPrefix + taxIdValue
}
submitToApi(sanitized)
}
return <form onSubmit={handleSubmit}>...</form>
}
// ✅ Logic extracted to .utils.ts — trivially testable
// TaxID.utils.ts
export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
const taxId = TAX_IDS.find((t) => t.name === name)
if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
return taxId.vatPrefix + value
}
return value
}
// TaxIdForm.tsx — thin shell
const handleSubmit = () => {
const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
submitToApi(sanitized)
}
```
Each rule file contains:
## 2. Test Every Permutation (CRITICAL)
- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
- Real codebase references
Once logic is extracted, test exhaustively. Every code path needs a test:
## Full Compiled Document
- Valid inputs (happy path for each branch)
- Invalid / malformed inputs
- Empty values, null values, missing fields
- Edge cases (timestamps with colons, special characters, boundary values)
For the complete guide with all rules expanded: `AGENTS.md`
```ts
// ❌ Only happy path
test('parses a filter', () => {
expect(formatFilterURLParams('id:gte:20')).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
})
// ✅ Every permutation
test('parses valid filter', () => { ... })
test('handles timestamp with colons in value', () => { ... })
test('rejects malformed filter with missing parts', () => { ... })
test('rejects unrecognized operator', () => { ... })
test('allows empty filter value', () => { ... })
```
## 3. Component Tests for Complex UI Only (HIGH)
Only write component tests when there is complex UI interaction logic that
cannot be captured by testing utility functions alone.
**Valid reasons:** conditional rendering from user interaction sequences,
popover open/close with keyboard/mouse, multi-step form transitions.
**Not valid:** testing a calculation or transformation that happens to live
in a component — extract to `.utils.ts` and unit test instead.
```tsx
// Studio component test conventions
import { fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { customRender } from 'tests/lib/custom-render' // always use customRender, not raw render
import { addAPIMock } from 'tests/lib/msw' // API mocking in beforeEach
```
## 4. E2E Tests for Shared Features (HIGH)
If a feature exists in both self-hosted and platform, create an E2E test.
Cover mouse clicks AND keyboard shortcuts (Tab, Enter, Escape, Arrow keys).
Extract reusable interactions into `e2e/studio/utils/*-helpers.ts`. Use
try/finally for resource cleanup. For E2E execution details, see the
`studio-e2e-tests` skill.
## Codebase References
| What | Where |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Util test examples | `tests/components/Grid/Grid.utils.test.ts`, `tests/components/Billing/TaxID.utils.test.ts`, `tests/components/Editor/SpreadsheetImport.utils.test.ts` |
| Component test examples | `tests/features/logs/LogsFilterPopover.test.tsx`, `tests/components/CopyButton.test.tsx` |
| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
| Custom render | `tests/lib/custom-render.tsx` |
| MSW mock setup | `tests/lib/msw.ts` (`addAPIMock`) |
| Test README | `tests/README.md` |
| Vitest config | `vitest.config.ts` |
| Related skills | `e2e-studio-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
| What | Where |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Util test examples | `apps/studio/tests/components/Grid/Grid.utils.test.ts`, `apps/studio/tests/components/Billing/TaxID.utils.test.ts`, `apps/studio/tests/components/Editor/SpreadsheetImport.utils.test.ts` |
| Component test examples | `apps/studio/tests/features/logs/LogsFilterPopover.test.tsx`, `apps/studio/tests/components/CopyButton.test.tsx` |
| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
| Custom render | `apps/studio/tests/lib/custom-render.tsx` |
| MSW mock setup | `apps/studio/tests/lib/msw.ts` (`addAPIMock`) |
| Test README | `apps/studio/tests/README.md` |
| Vitest config | `apps/studio/vitest.config.ts` |
| Related skills | `studio-e2e-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
@@ -1,86 +0,0 @@
---
title: Component Tests Are for Complex UI Logic Only
impact: HIGH
impactDescription: prevents slow, brittle tests that should be unit tests
tags: testing, components, ui, react
---
## Component Tests Are for Complex UI Logic Only
Only write component tests (`.test.tsx`) when there is complex UI interaction
logic that cannot be captured by testing utility functions alone.
**Valid reasons for a component test:**
- Conditional rendering based on user interaction sequences
- Popover/dropdown open/close behavior with keyboard and mouse
- Form state transitions across multiple steps
- Components that coordinate multiple async operations visually
**Not a valid reason:** testing a calculation, transformation, parsing, or
validation that happens to live inside a component. Extract that logic into a
`.utils.ts` file and unit test it instead.
**Incorrect (rendering a component just to test logic):**
```tsx
test('formats the display value correctly', () => {
render(<PriceDisplay amount={1234} currency="USD" />)
expect(screen.getByText('$12.34')).toBeInTheDocument()
})
```
This is really testing a formatting function. Extract it:
```ts
// PriceDisplay.utils.ts
export function formatPrice(amount: number, currency: string): string { ... }
// PriceDisplay.utils.test.ts
test('formats USD cents to dollars', () => {
expect(formatPrice(1234, 'USD')).toBe('$12.34')
})
```
**Correct (component test for real UI interaction logic):**
```tsx
// Testing popover open/close, filter application, keyboard dismiss
describe('LogsFilterPopover', () => {
test('opens popover and shows filter options', async () => {
customRender(<LogsFilterPopover onFiltersChange={vi.fn()} />)
await userEvent.click(screen.getByRole('button'))
expect(screen.getByText('Apply')).toBeVisible()
})
test('applies selected filters on submit', async () => {
const onChange = vi.fn()
customRender(<LogsFilterPopover onFiltersChange={onChange} />)
// ... interact with UI ...
await userEvent.click(screen.getByText('Apply'))
expect(onChange).toHaveBeenCalledWith(expectedFilters)
})
test('closes on Escape key', async () => {
customRender(<LogsFilterPopover onFiltersChange={vi.fn()} />)
await userEvent.click(screen.getByRole('button'))
await userEvent.keyboard('{Escape}')
expect(screen.queryByText('Apply')).not.toBeInTheDocument()
})
})
```
**Studio component test conventions:**
```tsx
// Always use customRender, not raw render
import { fireEvent } from '@testing-library/react'
// Use userEvent for popovers, fireEvent for dropdowns
import userEvent from '@testing-library/user-event'
import { customRender } from 'tests/lib/custom-render'
// Use addAPIMock for API mocking in beforeEach
import { addAPIMock } from 'tests/lib/msw'
```
See `tests/README.md` for full conventions on custom render, MSW mocking,
and nuqs URL parameter testing.
@@ -1,98 +0,0 @@
---
title: E2E Tests for Self-Hosted and Platform Features
impact: HIGH
impactDescription: ensures critical shared features work across deployment targets
tags: testing, e2e, playwright, self-hosted, platform
---
## E2E Tests for Self-Hosted and Platform Features
If a feature exists in both self-hosted and the Supabase platform, create an
E2E test to cover it. E2E tests live in `e2e/studio/features/*.spec.ts`.
**What to cover in E2E tests:**
- Mouse/click interactions AND keyboard shortcuts (Tab, Enter, Escape, Arrow keys)
- Full user flows end-to-end
- Both adding and removing/clearing state
- Setup and teardown (create resources in `try`, clean up in `finally`)
**Incorrect (only tests mouse clicks):**
```ts
test('can add a filter', async ({ page }) => {
await page.getByRole('button', { name: 'Add filter' }).click()
await page.getByRole('option', { name: 'id' }).click()
// ... only click-based interactions
})
```
**Correct (covers clicks AND keyboard shortcuts):**
```ts
test.describe('Basic Filter Operations', () => {
test('can add a filter by clicking', async ({ page }) => {
await addFilter(page, ref, 'id', 'equals', '1')
await expect(page.getByTestId('filter-condition')).toBeVisible()
})
})
test.describe('Keyboard Navigation - Freeform Input', () => {
test('Enter selects column from suggestions', async ({ page }) => {
await getFilterBarInput(page).press('Enter')
await expect(page.getByTestId('operator-input')).toBeFocused()
})
test('Backspace on empty input highlights last condition', async ({ page }) => {
await addFilter(page, ref, 'id', 'equals', '1')
await getFilterBarInput(page).press('Backspace')
await expect(page.getByTestId('filter-condition')).toHaveAttribute('data-highlighted', 'true')
})
test('Escape clears highlight', async ({ page }) => {
// ...
await getFilterBarInput(page).press('Escape')
await expect(page.getByTestId('filter-condition')).toHaveAttribute('data-highlighted', 'false')
})
})
```
**E2E helper pattern:** Extract reusable interactions into helper files at
`e2e/studio/utils/*-helpers.ts`:
```ts
// e2e/studio/utils/filter-bar-helpers.ts
export async function addFilter(page, ref, column, operator, value) {
await selectColumnFilter(page, column)
await selectOperator(page, column, operator)
// ... fill value, wait for API response
}
export async function setupFilterBarPage(page, ref, editorUrl) {
await page.goto(editorUrl)
await enableFilterBar(page)
await page.reload()
}
```
This keeps spec files focused on assertions while helpers handle the
interaction mechanics.
**Always use try/finally for resource cleanup:**
```ts
test('filters the table', async ({ page, ref }) => {
const tableName = await createTable(page, ref)
try {
await setupFilterBarPage(page, ref, editorUrl)
await navigateToTable(page, ref, tableName)
await addFilter(page, ref, 'id', 'equals', '1')
// assertions...
} finally {
await dropTable(page, ref, tableName)
}
})
```
For E2E execution details (running tests, selectors, debugging), use the
`e2e-studio-tests` skill.
@@ -1,84 +0,0 @@
---
title: Test Every Permutation of Utility Functions
impact: CRITICAL
impactDescription: catches edge cases and regressions in business logic
tags: testing, utils, coverage, permutations
---
## Test Every Permutation of Utility Functions
Once logic is extracted into a pure function, test it exhaustively. Every code
path should have a test. Don't just test the happy path.
**What to cover:**
- Valid inputs (happy path for each branch)
- Invalid / malformed inputs
- Empty values, null values, missing fields
- Edge cases (timestamps with colons, special characters, boundary values)
- Security-sensitive inputs (XSS payloads, external URLs) where relevant
**Incorrect (only tests the happy path):**
```ts
describe('formatFilterURLParams', () => {
test('parses a filter', () => {
const result = formatFilterURLParams('id:gte:20')
expect(result).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
})
})
```
**Correct (tests every permutation):**
```ts
describe('formatFilterURLParams', () => {
test('parses valid filter', () => {
const result = formatFilterURLParams('id:gte:20')
expect(result).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
})
test('handles timestamp with colons in value', () => {
const result = formatFilterURLParams('created:gte:2024-01-01T00:00:00')
expect(result).toStrictEqual({
column: 'created',
operator: 'gte',
value: '2024-01-01T00:00:00',
})
})
test('rejects malformed filter with missing parts', () => {
const result = formatFilterURLParams('id')
expect(result).toBeUndefined()
})
test('rejects unrecognized operator', () => {
const result = formatFilterURLParams('id:nope:20')
expect(result).toBeUndefined()
})
test('allows empty filter value', () => {
const result = formatFilterURLParams('name:eq:')
expect(result).toStrictEqual({ column: 'name', operator: 'eq', value: '' })
})
})
```
**Another real example -- `inferColumnType` tests every data type:**
```ts
describe('inferColumnType', () => {
test('defaults to text for empty data', () => { ... })
test('defaults to text for missing column', () => { ... })
test('defaults to text for null values', () => { ... })
test('detects integer', () => { ... }) // "42" -> int8
test('detects float', () => { ... }) // "161.72" -> float8
test('detects boolean', () => { ... }) // "true"/"false" -> bool
test('detects boolean with nulls', () => { ... })
test('detects JSON object', () => { ... }) // "{}" -> jsonb
test('detects timestamp', () => { ... }) // multiple formats -> timestamptz
})
```
The goal: if someone changes the function, at least one test should break for
any behavioral change.
@@ -1,94 +0,0 @@
---
title: Extract Logic Into Utility Files
impact: CRITICAL
impactDescription: makes business logic trivially testable without rendering components
tags: testing, utils, extraction, pure-functions
---
## Extract Logic Into Utility Files
Remove as much logic from components as possible. Put it in co-located
`.utils.ts` files as pure functions: arguments in, return value out. No React
hooks, no context, no side effects.
**File naming convention:**
- Utility file: `ComponentName.utils.ts` next to the component
- Test file: `tests/components/.../ComponentName.utils.test.ts` mirroring the source path
- Or under `tests/unit/` for non-component utilities
**Incorrect (logic buried inside a component):**
```tsx
// components/Billing/TaxIdForm.tsx
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
const handleSubmit = () => {
// Logic buried in the component -- hard to test without rendering
const taxId = TAX_IDS.find((t) => t.name === taxIdName)
let sanitized = taxIdValue
if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
sanitized = taxId.vatPrefix + taxIdValue
}
submitToApi(sanitized)
}
return <form onSubmit={handleSubmit}>...</form>
}
```
**Correct (logic extracted to a utility file):**
```ts
// components/Billing/TaxID.utils.ts
import { TAX_IDS } from './TaxID.constants'
// Pure function: args in, return out
export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
const taxId = TAX_IDS.find((t) => t.name === name)
if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
return taxId.vatPrefix + value
}
return value
}
```
```tsx
// components/Billing/TaxIdForm.tsx
import { sanitizeTaxIdValue } from './TaxID.utils'
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
const handleSubmit = () => {
const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
submitToApi(sanitized)
}
return <form onSubmit={handleSubmit}>...</form>
}
```
```ts
// tests/components/Billing/TaxID.utils.test.ts
import { sanitizeTaxIdValue } from 'components/.../TaxID.utils'
describe('sanitizeTaxIdValue', () => {
test('prefixes unprefixed EU tax ID', () => {
expect(sanitizeTaxIdValue({ value: '12345678', name: 'AT VAT' })).toBe('ATU12345678')
})
test('passes through already-prefixed EU tax ID', () => {
expect(sanitizeTaxIdValue({ value: 'ATU12345678', name: 'AT VAT' })).toBe('ATU12345678')
})
test('passes through non-EU tax ID unchanged', () => {
expect(sanitizeTaxIdValue({ value: '12-3456789', name: 'US EIN' })).toBe('12-3456789')
})
})
```
The component becomes a thin shell that calls the utility. All business logic
is testable without rendering anything.
**Real codebase examples:**
- `components/grid/SupabaseGrid.utils.ts` -- URL param parsing, used by 15+ components
- `components/.../SpreadsheetImport/SpreadsheetImport.utils.tsx` -- CSV parsing, column type inference
- `components/.../BillingCustomerData/TaxID.utils.ts` -- tax ID sanitization and comparison
+127
View File
@@ -0,0 +1,127 @@
---
name: studio-ui-patterns
description: Design system UI patterns for Supabase Studio. Use when building or updating
pages, forms, tables, charts, empty states, navigation, cards, alerts, or side panels
(sheets). Covers layout selection, component choice, and placement conventions.
---
# Studio UI Patterns
The Design System docs and demos are the source of truth. Always check the relevant
demo file before composing new UI.
## Layout
Docs: `apps/design-system/content/docs/ui-patterns/layout.mdx`
Build pages with `PageContainer`, `PageHeader`, and `PageSection`.
| Content type | `size` |
| ----------------- | ----------- |
| Settings / config | `"default"` |
| Lists / tables | `"large"` |
| Full-screen views | `"full"` |
- If filters/search exist on a list page, align table actions with the filters (don't use `PageHeaderAside`/`PageSectionAside` for those actions)
- If no filters, actions can go in `PageHeaderAside` or `PageSectionAside`
Demos: `page-layout-settings.tsx`, `page-layout-list.tsx`, `page-layout-list-simple.tsx`, `page-layout-detail.tsx`
(all in `apps/design-system/registry/default/example/`)
## Forms
Docs: `apps/design-system/content/docs/ui-patterns/forms.mdx`
- Use `react-hook-form` + `zod`
- Use `FormItemLayout` instead of manually composing `FormItem`/`FormLabel`/`FormMessage`/`FormDescription`
- Wrap inputs with `FormControl_Shadcn_`; use `_Shadcn_` imports from `ui` for primitives
Layout selection:
| Context | Layout | Container |
| ------------------------------------------ | ------------------------------------------ | ---------------------------------------------------------- |
| Page (settings/config) | `FormItemLayout layout="flex-row-reverse"` | `Card` (`CardContent` per field; `CardFooter` for actions) |
| Side panel — wide | `FormItemLayout layout="horizontal"` | `SheetSection` |
| Side panel — narrow (`size="sm"` or below) | `FormItemLayout layout="vertical"` | `SheetSection` |
Dirty state / submit:
- Destructure `isDirty` from `form.formState` to show Cancel and disable Save
- Show loading on submit button via `loading` prop
- If submit button is outside `<form>`, set a stable `formId` and use `form` prop on the button
Demos: `form-patterns-pagelayout.tsx`, `form-patterns-sidepanel.tsx`
## Tables
Docs: `apps/design-system/content/docs/ui-patterns/tables.mdx`
| Pattern | Use when |
| ---------- | ----------------------------------------------------------------------- |
| `Table` | Simple, static, semantic display |
| Data Table | TanStack-powered; sorting, filtering, pagination; composed per use-case |
| Data Grid | Virtualization, column resizing, or complex cell editing |
- Actions: above the table, aligned right
- Search/filters: above the table, aligned left
- If table is primary content with no filters, actions can live in the page's primary/secondary actions area
Demos: `table-demo.tsx`, `data-table-demo.tsx`, `data-grid-demo.tsx`
## Charts
Docs: `apps/design-system/content/docs/ui-patterns/charts.mdx`
- Use provided chart building blocks; avoid passing raw Recharts components to `ChartContent`
- Use `useChart` context flags for loading/disabled states
- Keep composition straightforward — avoid over-abstraction
Demos (in `apps/design-system/__registry__/default/block/`): `chart-composed-demo.tsx`, `chart-composed-basic.tsx`, `chart-composed-states.tsx`, `chart-composed-metrics.tsx`, `chart-composed-actions.tsx`, `chart-composed-table.tsx`
## Empty States
Docs: `apps/design-system/content/docs/ui-patterns/empty-states.mdx`
| Scenario | Pattern |
| ------------------------ | ------------------------------------------------------------------- |
| Initial / onboarding | Presentational empty state with value prop + clear next action |
| Data-heavy lists | Informational empty state matching the list/table layout |
| Zero results from search | Keep layout consistent with data state to avoid jarring transitions |
| Missing route | Centered `Admonition` |
Demos: `empty-state-presentational-icon.tsx`, `empty-state-initial-state-informational.tsx`, `empty-state-zero-items-table.tsx`, `data-grid-empty-state.tsx`, `empty-state-missing-route.tsx`
## Navigation
Docs: `apps/design-system/content/docs/ui-patterns/navigation.mdx`
- Use `NavMenu` for a horizontal list of related views within a consistent page layout
- Activating an item must trigger a **URL change** — no local-only tab state
## Cards
- Group related information in cards
- `CardContent` for sections, `CardFooter` for actions
- Only use `CardHeader`/`CardTitle` when context isn't already provided by surrounding content
- Use headers/titles when multiple cards represent distinct groups (e.g. multiple settings sections)
## Alerts
- Use `Admonition` to call out important actions, restrictions, or critical context
- Place at the **top of a page's content** (below page title) or **top of the relevant section** (below section title)
- Use sparingly
## Sheets (Side Panels)
Use a `Sheet` when switching pages would be disruptive and the user needs to maintain context (e.g. selecting a row from a list to edit).
Structure:
- `SheetContent` with `size="lg"` for forms needing horizontal layout
- Use `SheetHeader`, `SheetTitle`, `SheetSection`, `SheetFooter`
- Submit/cancel actions go in `SheetFooter`
Forms in sheets:
- `layout="horizontal"` for wider sheets
- `layout="vertical"` for narrow sheets (`size="sm"` or below)
@@ -0,0 +1,141 @@
---
name: use-static-effect-event
description: useStaticEffectEvent hook in Supabase Studio — a userland polyfill for
React's useEffectEvent. Use when you need to read latest state/props inside a useEffect
without re-triggering it, or when stale closures in Effects are causing bugs.
---
# useStaticEffectEvent
Located at `apps/studio/hooks/useStaticEffectEvent.ts`.
A userland polyfill for React's `useEffectEvent` (stable in React 19.2). It solves the stale closure problem: gives you a **stable callback** that always reads the latest props/state without those values triggering Effect re-runs.
## The Problem It Solves
Without it, you face two bad options inside `useEffect`:
1. **Add values to dependencies** → unnecessary Effect re-runs (teardown/reconnect)
2. **Omit from dependencies** → stale closure bugs (outdated values)
```tsx
// Problem: re-runs every time `theme` changes, even though we only
// want to reconnect when `roomId` changes
useEffect(() => {
const connection = createConnection(roomId)
connection.on('connected', () => {
showNotification('Connected!', theme) // theme causes unwanted reconnects
})
return () => connection.disconnect()
}, [roomId, theme])
```
## When to Use
1. Read latest state/props inside an Effect without re-triggering it
2. Create stable callbacks that always use current values
3. Avoid stale closures in event handlers used within Effects
### Pattern 1: Sync data without re-running on every change
```tsx
const syncApiPrivileges = useStaticEffectEvent(() => {
if (hasLoadedInitialData.current) return
if (!apiAccessStatus.isSuccess) return
if (!privilegesForTable) return
hasLoadedInitialData.current = true
setPrivileges(privilegesForTable.privileges)
})
useEffect(() => {
syncApiPrivileges()
}, [apiAccessStatus.status, syncApiPrivileges])
```
### Pattern 2: Stable callbacks for async operations
```tsx
const exportInternal = useStaticEffectEvent(
async ({ bypassConfirmation }: { bypassConfirmation: boolean }) => {
if (!params.enabled) return
const { projectRef, connectionString, entity, totalRows } = params
// complex async logic using latest params
}
)
// Stable reference — safe to use in useCallback
const exportInDesiredFormat = useCallback(
() => exportInternal({ bypassConfirmation: false }),
[exportInternal]
)
```
### Pattern 3: Infinite scroll / pagination triggers
```tsx
const fetchNext = useStaticEffectEvent(() => {
if (lastItem && lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
})
useEffect(fetchNext, [lastItem, fetchNext])
```
## When NOT to Use
**Don't use it to hide legitimate dependencies:**
```tsx
// ❌ Bad — roomId IS a legitimate dependency; this hides a bug
const connect = useStaticEffectEvent(() => {
const connection = createConnection(roomId)
connection.connect()
})
useEffect(() => {
connect()
}, [connect]) // Won't reconnect when roomId changes!
// ✅ roomId belongs in deps
useEffect(() => {
const connection = createConnection(roomId)
connection.connect()
return () => connection.disconnect()
}, [roomId])
```
**Don't use it for simple event handlers outside Effects:**
```tsx
// ❌ Unnecessary — not used inside an Effect
const handleClick = useStaticEffectEvent(() => console.log(count))
// ✅ Regular function is fine
const handleClick = () => console.log(count)
```
## Rules
1. Only call the returned function **inside Effects** (`useEffect`, `useLayoutEffect`)
2. Don't pass it to other components or hooks as a callback prop
3. Use for **non-reactive logic only** — reads values but shouldn't trigger re-runs
4. **Include it in dependency arrays** when used in `useEffect` (it's stable, won't cause re-runs)
## How It Works
```tsx
export const useStaticEffectEvent = <Callback extends Function>(callback: Callback) => {
const callbackRef = useRef(callback)
useLayoutEffect(() => {
callbackRef.current = callback // always latest
})
const eventFn = useCallback((...args: any) => {
return callbackRef.current(...args)
}, []) // stable reference
return eventFn as unknown as Callback
}
```
@@ -1,7 +1,6 @@
---
name: vercel-composition-patterns
description:
React composition patterns that scale. Use when refactoring components with
description: React composition patterns that scale. Use when refactoring components with
boolean prop proliferation, building flexible component libraries, or
designing reusable APIs. Triggers on tasks involving compound components,
render props, context providers, or component architecture. Includes React 19
@@ -64,7 +63,7 @@ Reference these guidelines when:
### 4. React 19 APIs (MEDIUM)
> **⚠️ React 19+ only.** Skip this section if using React 18 or earlier.
> **⚠️ React 19+ only.** Supabase Studio currently uses React 18 — skip these patterns in Studio code.
- `react19-no-forwardref` - Don't use `forwardRef`; use `use()` instead of `useContext()`
@@ -1,161 +0,0 @@
---
description: Guidelines for using the useStaticEffectEvent hook in Studio - a polyfill for React's useEffectEvent pattern
alwaysApply: false
---
# useStaticEffectEvent Hook
The `useStaticEffectEvent` hook (located at `apps/studio/hooks/useStaticEffectEvent.ts`) is a userland implementation of React's `useEffectEvent` pattern. It solves the stale closure problem by providing a stable callback reference that always accesses the latest props and state values.
## What Problem Does It Solve?
When using `useEffect`, you often need to access props or state inside your Effect, but you don't want changes to those values to re-run the Effect. Without `useStaticEffectEvent`, you'd face two bad options:
1. **Add them to dependencies** - causes unnecessary Effect re-runs (teardown/reconnect cycles)
2. **Omit from dependencies** - causes stale closure bugs where your callback uses outdated values
```tsx
// Problem: This Effect re-runs every time `theme` changes, even though
// we only want to reconnect when `roomId` changes
useEffect(() => {
const connection = createConnection(roomId)
connection.on('connected', () => {
showNotification('Connected!', theme) // `theme` causes unwanted re-runs
})
return () => connection.disconnect()
}, [roomId, theme]) // Adding theme causes unnecessary reconnections
```
## When to Use useStaticEffectEvent
Use `useStaticEffectEvent` when you need to:
1. **Read latest state/props inside an Effect without re-triggering it**
2. **Create stable callbacks that always use current values**
3. **Avoid stale closure bugs in event handlers used within Effects**
### Pattern 1: Syncing data without re-running on every change
```tsx
// ✅ Good - sync data when status changes, but always read latest state
const syncApiPrivileges = useStaticEffectEvent(() => {
if (hasLoadedInitialData.current) return
if (!apiAccessStatus.isSuccess) return
if (!privilegesForTable) return
hasLoadedInitialData.current = true
setPrivileges(privilegesForTable.privileges)
})
useEffect(() => {
syncApiPrivileges()
}, [apiAccessStatus.status, syncApiPrivileges])
```
### Pattern 2: Stable callbacks for async operations
```tsx
// ✅ Good - wrap complex async logic that reads many values
const exportInternal = useStaticEffectEvent(
async ({ bypassConfirmation }: { bypassConfirmation: boolean }): Promise<void> => {
if (!params.enabled) return
const { projectRef, connectionString, entity, totalRows } = params
// ... complex async logic using latest params
}
)
// This callback is stable and can be safely used in useCallback
const exportInDesiredFormat = useCallback(
() => exportInternal({ bypassConfirmation: false }),
[exportInternal]
)
```
### Pattern 3: Infinite scroll / pagination triggers
```tsx
// ✅ Good - always read latest pagination state when scrolling triggers fetch
const fetchNext = useStaticEffectEvent(() => {
if (lastItem && lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
})
useEffect(fetchNext, [lastItem, fetchNext])
```
## When NOT to Use useStaticEffectEvent
### Don't use it to avoid specifying legitimate dependencies
```tsx
// ❌ Bad - hiding the fact that this should re-run when roomId changes
const connect = useStaticEffectEvent(() => {
const connection = createConnection(roomId)
connection.connect()
})
useEffect(() => {
connect() // BUG: Won't reconnect when roomId changes!
}, [connect])
// ✅ Good - roomId is a legitimate dependency
useEffect(() => {
const connection = createConnection(roomId)
connection.connect()
return () => connection.disconnect()
}, [roomId])
```
### Don't use it for simple event handlers outside Effects
```tsx
// ❌ Unnecessary - not used inside an Effect
const handleClick = useStaticEffectEvent(() => {
console.log(count)
})
// ✅ Good - regular function or useCallback is fine
const handleClick = () => {
console.log(count)
}
```
## How It Works
The hook uses a ref to store the latest callback and returns a stable wrapper function:
```tsx
export const useStaticEffectEvent = <Callback extends Function>(callback: Callback) => {
const callbackRef = useRef(callback)
// Update the ref on every render with the latest callback
useLayoutEffect(() => {
callbackRef.current = callback
})
// Return a stable function that calls the latest callback
const eventFn = useCallback((...args: any) => {
return callbackRef.current(...args)
}, [])
return eventFn as unknown as Callback
}
```
## Relationship to React's useEffectEvent
This hook is a polyfill for React's experimental `useEffectEvent` (now stable in React 19.2). The core concept is identical:
- Extract non-reactive logic into a stable function
- Always access the latest props/state without adding them as Effect dependencies
- Should only be called from within Effects
When React's `useEffectEvent` becomes widely available, this hook can be replaced with the official API.
## Rules
1. **Only call the returned function inside Effects** (useEffect, useLayoutEffect)
2. **Don't pass the function to other components or hooks** as a callback prop
3. **Use for non-reactive logic only** - logic that reads values but shouldn't trigger re-runs
4. **Include it in dependency arrays** when used in useEffect (the function is stable, so it won't cause re-runs)
-33
View File
@@ -1,33 +0,0 @@
---
description: 'Studio: index rule for architecture, style, and UI composition patterns'
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio
Use the nested rules in this folder for focused guidance while working in `apps/studio/`.
## Architecture and style
- `studio/project-structure`
- `studio/component-system`
- `studio/styling`
- `studio/best-practices`
## UI composition (Design System patterns)
- `studio/layout`
- `studio/forms`
- `studio/tables`
- `studio/charts`
- `studio/empty-states`
- `studio/navigation`
## Common UI building blocks
- `studio/sheets`
- `studio/cards`
- `studio/alerts`
- `studio/react-query`
-13
View File
@@ -1,13 +0,0 @@
---
description: "Studio: alert/admonition usage and placement"
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio alerts
- Use `Admonition` to call out important actions, restrictions, or critical context.
- Place at the top of a pages content (below the page title) or at the top of the relevant section (below the section title).
- Use sparingly.
-433
View File
@@ -1,433 +0,0 @@
---
description: "Studio: React and TypeScript best practices for maintainable Studio code"
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio Best Practices
## Boolean Handling
### Assign complex conditions to descriptive variables
When you have multiple conditions in a single expression, extract them into well-named boolean variables. This improves readability and makes the code self-documenting.
```tsx
// ❌ Bad - complex inline condition
{
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading && (
<Button onClick={onAddColumn}>New column</Button>
)
}
// ✅ Good - extract to descriptive variables
const isTableEntity = isTableLike(selectedTable)
const canShowAddButton = !isSchemaLocked && isTableEntity && canUpdateColumns && !isLoading
{
canShowAddButton && <Button onClick={onAddColumn}>New column</Button>
}
```
### Use consistent naming conventions for booleans
- Use `is` prefix for state/identity: `isLoading`, `isPaused`, `isNewRecord`, `isError`
- Use `has` prefix for possession: `hasPermission`, `hasShownModal`, `hasData`
- Use `can` prefix for capability/permission: `canUpdateColumns`, `canDelete`, `canEdit`
- Use `should` prefix for conditional behavior: `shouldFetch`, `shouldRender`, `shouldValidate`
```tsx
// ✅ Good examples from codebase
const isNewRecord = column === undefined
const isPaused = project?.status === PROJECT_STATUS.INACTIVE
const isMatureProject = dayjs(project?.inserted_at).isBefore(dayjs().subtract(10, 'day'))
const { can: canUpdateColumns } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'columns'
)
```
### Derive boolean state instead of storing it
When a boolean can be computed from existing state, derive it rather than storing it separately.
```tsx
// ❌ Bad - storing derived state
const [isFormValid, setIsFormValid] = useState(false)
useEffect(() => {
setIsFormValid(name.length > 0 && email.includes('@'))
}, [name, email])
// ✅ Good - derive from existing state
const isFormValid = name.length > 0 && email.includes('@')
```
## Component Structure
### Break down large components
Components should ideally be under 200-300 lines. If a component grows larger, consider splitting it.
**Signs a component should be split:**
- Multiple distinct UI sections
- Complex conditional rendering logic
- Multiple useState hooks for unrelated state
- Difficult to understand at a glance
```tsx
// ❌ Bad - monolithic component with everything inline
const UserDashboard = () => {
// 50 lines of hooks and state
// 100 lines of handlers
// 300 lines of JSX with nested conditions
}
// ✅ Good - split into focused sub-components
const UserDashboard = () => {
return (
<div>
<UserHeader />
<UserStats />
<UserActivitySection />
<UserSettingsPanel />
</div>
)
}
```
### Co-locate related components
Place sub-components in the same directory as the parent component. Avoid using barrel files (files that do nothing but re-export things from other files) for imports.
```
components/interfaces/Auth/Users/
├── UserPanel.tsx
├── UserOverview.tsx
├── UserLogs.tsx
├── Users.constants.ts
└── index.ts
```
### Extract repeated JSX patterns
If you find yourself copying similar JSX blocks, extract them into a component.
```tsx
// ❌ Bad - repeated pattern
<TabsTrigger_Shadcn_ value="overview" className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none">
Overview
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="logs" className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none">
Logs
</TabsTrigger_Shadcn_>
// ✅ Good - extract to component
const PanelTab = ({ value, children }: { value: string; children: ReactNode }) => (
<TabsTrigger_Shadcn_
value={value}
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none"
>
{children}
</TabsTrigger_Shadcn_>
)
```
## Loading and Error States
### Use consistent loading/error/success pattern
Follow a consistent pattern for handling async states:
```tsx
const { data, error, isLoading, isError, isSuccess } = useQuery()
// Handle loading state first
if (isLoading) {
return <GenericSkeletonLoader />
}
// Handle error state
if (isError) {
return <AlertError error={error} subject="Failed to load data" />
}
// Handle empty state if needed
if (isSuccess && data.length === 0) {
return <EmptyState />
}
// Render success state
return <DataDisplay data={data} />
```
### Use early returns for guard clauses
Prefer early returns over deeply nested conditionals:
```tsx
// ❌ Bad - deeply nested
const Component = () => {
if (data) {
if (!isError) {
if (hasPermission) {
return <ActualContent />
}
}
}
return null
}
// ✅ Good - early returns
const Component = () => {
if (!data) return null
if (isError) return <ErrorDisplay />
if (!hasPermission) return <PermissionDenied />
return <ActualContent />
}
```
## State Management
### Keep state as local as possible
Start with local state and lift up only when needed.
```tsx
// ✅ Good - state lives where it's used
const SearchableList = () => {
const [filterString, setFilterString] = useState('')
const filteredItems = items.filter((item) => item.name.includes(filterString))
return (
<div>
<Input value={filterString} onChange={(e) => setFilterString(e.target.value)} />
<List items={filteredItems} />
</div>
)
}
```
### Group related state with objects or reducers
When you have multiple related pieces of state, consider grouping them:
```tsx
// ❌ Bad - multiple related useState calls
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
// ✅ Good - grouped state for forms (use react-hook-form)
const form = useForm<FormValues>({
defaultValues: { name: '', email: '', phone: '' },
})
```
## Custom Hooks
### Extract complex logic into custom hooks
When logic becomes reusable or complex, extract it:
```tsx
// ✅ Good - extracted to custom hook
export function useAsyncCheckPermissions(action: string, resource: string) {
const { permissions, isLoading, isSuccess } = useGetProjectPermissions()
const can = useMemo(() => {
if (!IS_PLATFORM) return true
if (!isSuccess || !permissions) return false
return doPermissionsCheck(permissions, action, resource)
}, [isSuccess, permissions, action, resource])
return { isLoading, isSuccess, can }
}
// Usage
const { can: canUpdateColumns } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'columns'
)
```
### Return objects from hooks for better extensibility
```tsx
// ❌ Bad - returning array (hard to extend)
const useToggle = () => {
const [value, setValue] = useState(false)
return [value, () => setValue((v) => !v)]
}
// ✅ Good - returning object (easy to extend)
const useToggle = (initial = false) => {
const [value, setValue] = useState(initial)
return {
value,
toggle: () => setValue((v) => !v),
setTrue: () => setValue(true),
setFalse: () => setValue(false),
}
}
```
## Event Handlers
### Name handlers consistently
Use `on` prefix for prop callbacks and `handle` prefix for internal handlers:
```tsx
interface Props {
onClose: () => void // Callback prop
onSave: (data: Data) => void
}
const Component = ({ onClose, onSave }: Props) => {
const handleSubmit = () => {
// Internal handler
// process data
onSave(data)
}
const handleCancel = () => {
// cleanup
onClose()
}
}
```
### Avoid inline arrow functions for expensive operations
```tsx
// ❌ Bad - creates new function every render
<ExpensiveList items={items} onItemClick={(item) => handleItemClick(item)} />
// ✅ Good - stable reference with useCallback
const handleItemClick = useCallback(
(item: Item) => {
// handle click
},
[dependencies]
)
<ExpensiveList items={items} onItemClick={handleItemClick} />
```
## Conditional Rendering
### Use appropriate patterns for different scenarios
```tsx
// Simple show/hide - use &&
{
isVisible && <Component />
}
// Binary choice - use ternary
{
isLoading ? <Spinner /> : <Content />
}
// Multiple conditions - use early returns or extracted component
const StatusDisplay = ({ status }: { status: Status }) => {
if (status === 'loading') return <Spinner />
if (status === 'error') return <ErrorMessage />
if (status === 'empty') return <EmptyState />
return <DataDisplay />
}
```
### Avoid nested ternaries
```tsx
// ❌ Bad - nested ternary
{
isLoading ? <Spinner /> : isError ? <Error /> : <Content />
}
// ✅ Good - separate conditions or early returns
if (isLoading) return <Spinner />
if (isError) return <Error />
return <Content />
```
## Performance
### Use useMemo for expensive computations
```tsx
// ✅ Good - memoize expensive filtering
const filteredItems = useMemo(
() => items.filter((item) => item.name.toLowerCase().includes(searchQuery.toLowerCase())),
[items, searchQuery]
)
```
### Avoid premature optimization
Don't wrap everything in useMemo/useCallback. Only optimize when:
- You have measured a performance problem
- The computation is genuinely expensive
- The value is passed to memoized children
## TypeScript
### Define prop interfaces explicitly
```tsx
interface UserCardProps {
user: User
onEdit: (user: User) => void
onDelete: (userId: string) => void
isEditable?: boolean
}
export const UserCard = ({ user, onEdit, onDelete, isEditable = true }: UserCardProps) => {
// ...
}
```
### Use discriminated unions for complex state
```tsx
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
```
### Avoid type casting, prefer validation with zod
Never use type casting (e.g., `as any`, `as Type`). Instead, validate values at runtime using zod schemas. This ensures type safety and catches runtime errors.
```tsx
// ❌ Bad - type casting bypasses type checking
const user = apiResponse as User
const data = unknownValue as string
// ✅ Good - validate with zod schema
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
const user = userSchema.parse(apiResponse)
const data = z.string().parse(unknownValue)
// ✅ Good - safe parsing with error handling
const result = userSchema.safeParse(apiResponse)
if (result.success) {
const user = result.data
} else {
// handle validation errors
}
```
-14
View File
@@ -1,14 +0,0 @@
---
description: "Studio: Card usage for grouping related content and actions"
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio cards
- Use cards to group related pieces of information.
- Use `CardContent` for sections and `CardFooter` for actions.
- Only use `CardHeader`/`CardTitle` when the card content is not already described by surrounding content (page title, section title, etc).
- Prefer headers/titles when multiple cards represent distinct groups (e.g. multiple settings groups).
-26
View File
@@ -1,26 +0,0 @@
---
description: "Studio: composable chart patterns built on Recharts and our chart presentational components"
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio charts
Use the Design System UI pattern docs as the source of truth:
- Documentation: `apps/design-system/content/docs/ui-patterns/charts.mdx`
- Demos:
- `apps/design-system/__registry__/default/block/chart-composed-demo.tsx`
- `apps/design-system/__registry__/default/block/chart-composed-basic.tsx`
- `apps/design-system/__registry__/default/block/chart-composed-states.tsx`
- `apps/design-system/__registry__/default/block/chart-composed-metrics.tsx`
- `apps/design-system/__registry__/default/block/chart-composed-actions.tsx`
- `apps/design-system/__registry__/default/block/chart-composed-table.tsx`
## Best practices
- Prefer provided chart building blocks over passing raw Recharts components to `ChartContent`.
- Use `useChart` context flags for consistent loading/disabled handling.
- Keep chart composition straightforward; avoid over-abstraction.
@@ -1,16 +0,0 @@
---
description: 'Studio: UI component system (packages/ui + shadcn primitives)'
globs:
- apps/studio/**/*.{ts,tsx}
- packages/ui/**/*.{ts,tsx}
alwaysApply: false
---
# Studio component system
Our primitive component system lives in `packages/ui` and is based on shadcn/ui patterns.
- Prefer using components exported from `ui` (e.g. `import { Button } from 'ui'`).
- Prefer `_Shadcn_`-suffixed components for form components e.g. `Input_Shadcn_`.
- Avoid introducing new primitives unless explicitly requested.
- Browse available exports in `packages/ui/index.tsx` before composing new UI.
-25
View File
@@ -1,25 +0,0 @@
---
description: 'Studio: empty state patterns (presentational vs informational vs zero-results vs missing route)'
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio empty states
Use the Design System UI pattern docs as the source of truth:
- Documentation: `apps/design-system/content/docs/ui-patterns/empty-states.mdx`
- Demos:
- `apps/design-system/registry/default/example/empty-state-presentational-icon.tsx`
- `apps/design-system/registry/default/example/empty-state-initial-state-informational.tsx`
- `apps/design-system/registry/default/example/empty-state-zero-items-table.tsx`
- `apps/design-system/registry/default/example/data-grid-empty-state.tsx`
- `apps/design-system/registry/default/example/empty-state-missing-route.tsx`
## Quick guidance
- Initial states: use presentational empty states when onboarding/value prop + a clear next action helps.
- Data-heavy lists: prefer informational empty states that match the list/table layout.
- Zero results: keep the UI consistent with the data state to avoid jarring transitions.
- Missing routes: prefer a centered `Admonition` pattern.
-34
View File
@@ -1,34 +0,0 @@
---
description: 'Studio: form patterns (page layouts + side panels) and react-hook-form conventions'
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio forms
Use the Design System UI pattern docs as the source of truth:
- Documentation: `apps/design-system/content/docs/ui-patterns/forms.mdx`
- Demos:
- `apps/design-system/registry/default/example/form-patterns-pagelayout.tsx`
- `apps/design-system/registry/default/example/form-patterns-sidepanel.tsx`
## Requirements
- Build forms with `react-hook-form` + `zod`.
- Use `FormItemLayout` instead of manually composing `FormItem`/`FormLabel`/`FormMessage`/`FormDescription`.
- Wrap inputs with `FormControl_Shadcn_`.
- Use `_Shadcn_` imports from `ui` for form primitives where available.
## Layout selection
- Page layouts: `FormItemLayout layout="flex-row-reverse"` inside `Card` (`CardContent` per field; `CardFooter` for actions).
- Side panels (wide): `FormItemLayout layout="horizontal"` inside `SheetSection`.
- Side panels (narrow, `size="sm"` or below): `FormItemLayout layout="vertical"`.
## Actions and state
- Handle dirty state by destructuring `isDirty` from `formState` (`const { isDirty } = form.formState`) then use it to show Cancel and to disable Save.
- Show loading on submit buttons via `loading`.
- When submit button is outside the `<form>`, set a stable `formId` and use the buttons `form` prop.
-28
View File
@@ -1,28 +0,0 @@
---
description: 'Studio: page layout patterns (PageContainer/PageHeader/PageSection) and sizing guidance. Use to learn how to create or update existing pages in Studio.'
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio layout
Use the Design System UI pattern docs as the source of truth:
- Documentation: `apps/design-system/content/docs/ui-patterns/layout.mdx`
- Demos:
- `apps/design-system/registry/default/example/page-layout-settings.tsx`
- `apps/design-system/registry/default/example/page-layout-list.tsx`
- `apps/design-system/registry/default/example/page-layout-list-simple.tsx`
- `apps/design-system/registry/default/example/page-layout-detail.tsx`
## Guidelines
- Build pages using `PageContainer`, `PageHeader`, and `PageSection` for consistent spacing and max-widths.
- Choose `size` based on content:
- Settings/config: `size="default"`
- List/table-heavy: `size="large"`
- Full-screen experiences: `size="full"`
- For list pages:
- If filters/search exist, align table actions with filters (avoid `PageHeaderAside`/`PageSectionAside` for those actions).
- If no filters/search, actions can go in `PageHeaderAside` or `PageSectionAside` depending on context.
-19
View File
@@ -1,19 +0,0 @@
---
description: "Studio: navigation patterns (page-level NavMenu + URL-driven navigation)"
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio navigation
Use the Design System UI pattern docs as the source of truth:
- Documentation: `apps/design-system/content/docs/ui-patterns/navigation.mdx`
## NavMenu
- Use `NavMenu` for a horizontal list of related views within a consistent page layout.
- Activating an item should trigger a URL change (no local-only tab state).
- See: `apps/design-system/content/docs/components/nav-menu.mdx`
@@ -1,19 +0,0 @@
---
description: "Studio: project structure and where code lives"
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio project structure
- Studio is a Next.js app using the pages router.
- Pages live in `apps/studio/pages`.
- Project pages: `apps/studio/pages/projects/[ref]`
- Org pages: `apps/studio/pages/org/[slug]`
- Studio components live in `apps/studio/components`.
- Studio UI helpers: `apps/studio/components/ui`
- Interface/page components: `apps/studio/components/interfaces` (e.g. `apps/studio/components/interfaces/Auth`)
- Shared hooks: `apps/studio/hooks`
- Shared helpers: `apps/studio/lib`
-185
View File
@@ -1,185 +0,0 @@
---
description: 'Studio: data fetching conventions for queries/mutations (React Query hooks)'
globs:
- apps/studio/data/**/*.{ts,tsx}
- apps/studio/pages/**/*.{ts,tsx}
- apps/studio/components/**/*.{ts,tsx}
alwaysApply: false
---
# Studio queries & mutations (React Query)
Follow the `apps/studio/data/` patterns:
- Query options: `apps/studio/data/table-editor/table-editor-query.ts`
- Mutation hook: `apps/studio/data/edge-functions/edge-functions-update-mutation.ts`
- Keys: `apps/studio/data/edge-functions/keys.ts`
- Page usage: `apps/studio/pages/project/[ref]/database/tables/[id].tsx`
## Organize query keys
- Define a `keys.ts` per domain and export `*Keys` helpers (use array keys with `as const`).
- Do not inline query keys in components.
Example:
```ts
export const edgeFunctionsKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const,
detail: (projectRef: string | undefined, slug: string | undefined) =>
['projects', projectRef, 'edge-function', slug, 'detail'] as const,
}
```
## Write query options (preferred pattern)
Use `queryOptions` from `@tanstack/react-query` to define reusable query configurations. This pattern:
- Provides type safety for query keys and data
- Can be used with `useQuery()` in components
- Can be used with `queryClient.fetchQuery()` for imperative fetching
Guidelines:
- Export `XVariables`, `XData`, and `XError` types from the file (prefixed with the domain name).
- Implement a private `getX(variables, signal?)` function that:
- throws if required variables are missing
- passes the `signal` through to the fetcher for cancellation
- calls `handleError(error)` on failure (which throws) — the function returns `data` on success
- this function should NOT be exported. For imperative fetching, use `queryClient.fetchQuery(xQueryOptions(...))`
- Export `xQueryOptions()` using `queryOptions` from `@tanstack/react-query`.
- Gate with `enabled` so the query doesn't run until required variables exist (and platform-only queries should include `IS_PLATFORM` from `lib/constants`).
- When migrating away from exporting `useQuery`, move all options into the `xQueryOptions` as default values.
- No extra options should be added as params, if the user wants to overwrite the options, they can do by destructuring the query options. For example, `{ ...xQueryOptions(vars), enabled: true }`.
Template:
```ts
import { queryOptions } from '@tanstack/react-query'
import { xKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import { IS_PLATFORM } from '@/lib/constants'
import { ResponseError } from '@/types'
export type XVariables = { projectRef?: string }
export type XError = ResponseError
async function getX({ projectRef }: XVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await get('/v1/projects/{ref}/x', {
params: { path: { ref: projectRef } },
signal,
})
if (error) handleError(error)
return data
}
export type XData = Awaited<ReturnType<typeof getX>>
export const xQueryOptions = ({ projectRef }: XVariables) => {
return queryOptions({
queryKey: xKeys.list(projectRef),
queryFn: ({ signal }) => getX({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
})
}
```
## Using query options in components
Use `useQuery` directly with the query options:
```ts
import { useQuery } from '@tanstack/react-query'
import { xQueryOptions } from '@/data/x/x-query'
// In component:
const { data, isPending, isError } = useQuery(
xQueryOptions({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
)
```
## Imperative fetching (outside React or in callbacks)
Use `queryClient.fetchQuery()` with the query options:
```ts
import { useQueryClient } from '@tanstack/react-query'
import { xQueryOptions } from '@/data/x/x-query'
// In component:
const queryClient = useQueryClient()
const handleClick = useCallback(
async (id: number) => {
const data = await queryClient.fetchQuery(
xQueryOptions({
id,
projectRef,
connectionString: project?.connectionString,
})
)
// use data...
},
[project?.connectionString, projectRef, queryClient]
)
```
## Write a mutation hook
- Export a `Variables` type that includes `projectRef`, identifiers (e.g. `slug`), and `payload`.
- Implement an `updateX(vars)` function that validates required variables and uses `handleError`.
- Prefer a `useXMutation()` wrapper that:
- accepts `UseCustomMutationOptions` (omit `mutationFn`)
- invalidates the relevant `list()` + `detail()` keys in `onSuccess` and `await`s them via `Promise.all`
- defaults to a `toast.error(...)` when `onError` isn't provided
Template:
```ts
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { xKeys } from './keys'
import type { UseCustomMutationOptions } from '@/data/custom-mutation'
type XUpdateVariables = { projectRef: string; slug: string; payload: XPayload }
export const useXUpdateMutation = ({
onSuccess,
onError,
...options
}: UseMutationOptions<XData, XError, XUpdateVariables> = {}) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateX,
async onSuccess(data, variables, context) {
await Promise.all([
queryClient.invalidateQueries({
queryKey: xKeys.detail(variables.projectRef, variables.slug),
}),
queryClient.invalidateQueries({ queryKey: xKeys.list(variables.projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(error, variables, context) {
if (onError === undefined) toast.error(`Failed to update: ${error.message}`)
else onError(error, variables, context)
},
...options,
})
}
```
## Component usage
- Prefer React Query's v5 flags:
- `isPending` for initial load (often aliased to `isLoading`)
- `isFetching` for background refetches
- Render states explicitly (pending → error → success), like `apps/studio/pages/project/[ref]/database/tables/[id].tsx`.
-23
View File
@@ -1,23 +0,0 @@
---
description: "Studio: side panels (Sheet) for context-preserving workflows"
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio sheets
Use a `Sheet` when switching to a new page would be disruptive and the user should keep context (e.g. selecting an item from a list to edit details).
## Structure
- Prefer `SheetContent` with `size="lg"` for forms that need horizontal layout.
- Use `SheetHeader`, `SheetTitle`, `SheetSection`, and `SheetFooter` for consistent structure.
- Place submit/cancel actions in `SheetFooter`.
## Forms in sheets
- Prefer `FormItemLayout`:
- `layout="horizontal"` for wider sheets
- `layout="vertical"` for narrow sheets (`size="sm"` or below)
- See `@studio/forms` for the canonical patterns and demos.
-16
View File
@@ -1,16 +0,0 @@
---
description: "Studio: styling rules (Tailwind + semantic tokens + typography/focus utilities)"
globs:
- apps/studio/**/*.{ts,tsx,scss}
alwaysApply: false
---
# Studio styling
- Use Tailwind.
- Do not hardcode Tailwind color tokens; use our semantic classes:
- backgrounds: `bg`, `bg-muted`, `bg-warning`, `bg-destructive`
- text: `text-foreground`, `text-foreground-light`, `text-foreground-lighter`, `text-warning`, `text-destructive`
- Use existing typography utilities from `apps/studio/styles/typography.scss` instead of recreating styles.
- Use existing focus utilities from `apps/studio/styles/focus.scss` for consistent keyboard focus styling.
-29
View File
@@ -1,29 +0,0 @@
---
description: "Studio: table patterns (Table vs Data Table vs Data Grid) and placement of actions/filters"
globs:
- apps/studio/**/*.{ts,tsx}
alwaysApply: false
---
# Studio tables
Use the Design System UI pattern docs as the source of truth:
- Documentation: `apps/design-system/content/docs/ui-patterns/tables.mdx`
- Demos:
- `apps/design-system/registry/default/example/table-demo.tsx`
- `apps/design-system/registry/default/example/data-table-demo.tsx`
- `apps/design-system/registry/default/example/data-grid-demo.tsx`
## Choose the right pattern
- `Table`: simple, static, semantic table display.
- Data Table: TanStack-powered pattern for sorting/filtering/pagination; composed per use case.
- Data Grid: only when you need virtualization, column resizing, or complex cell editing.
## Actions and filters placement
- Actions: above the table, aligned right.
- Search/filters: above the table, aligned left.
- If the table is the primary page content and has no filters/search, actions can live in the pages primary/secondary actions area.
-336
View File
@@ -1,336 +0,0 @@
---
description: "Testing: Playwright E2E best practices for Studio tests (avoid flake + race conditions)"
globs:
- e2e/studio/**/*.ts
- e2e/studio/**/*.spec.ts
alwaysApply: false
---
# E2E Testing Best Practices
## Getting Context
Before writing or modifying tests, use the Playwright MCP to understand:
- Available page elements and their roles/locators
- Current page state and network activity
- Existing test patterns in the codebase
Avoid extensive code reading - let Playwright's inspection tools guide your understanding of the UI.
## Avoiding Race Conditions
### Set up API waiters BEFORE triggering actions
The most common source of flaky tests is race conditions between UI actions and API calls. Always create response waiters before clicking buttons or navigating.
```ts
// ❌ Bad - race condition: response might complete before waiter is set up
await page.getByRole('button', { name: 'Save' }).click()
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
// ✅ Good - waiter is ready before action
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
await page.getByRole('button', { name: 'Save' }).click()
await apiPromise
```
### Use `createApiResponseWaiter` for pre-navigation waits
When you need to wait for a response that happens during navigation:
```ts
// ✅ Good - waiter created before navigation
const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
```
### Wait for multiple related API calls with Promise.all
When an action triggers multiple API calls, wait for all of them:
```ts
// ✅ Good - wait for all related API calls
const createTablePromise = waitForApiResponseWithTimeout(page, (response) =>
response.url().includes('query?key=table-create')
)
const tablesPromise = waitForApiResponseWithTimeout(page, (response) =>
response.url().includes('tables?include_columns=true')
)
const entitiesPromise = waitForApiResponseWithTimeout(page, (response) =>
response.url().includes('query?key=entity-types-')
)
await page.getByRole('button', { name: 'Save' }).click()
await Promise.all([createTablePromise, tablesPromise, entitiesPromise])
```
## Waiting Strategies
### Prefer Playwright's built-in auto-waiting
Playwright automatically waits for elements to be actionable. Use this instead of manual timeouts:
```ts
// ❌ Bad - arbitrary timeout
await page.waitForTimeout(2000)
await page.getByRole('button', { name: 'Submit' }).click()
// ✅ Good - auto-waits for element to be visible and enabled
await page.getByRole('button', { name: 'Submit' }).click()
```
### Use `expect.poll` for dynamic assertions
When waiting for state to change:
```ts
// ✅ Good - polls until condition is met
await expect
.poll(async () => {
return await page.getByLabel(`View ${tableName}`).count()
})
.toBe(0)
```
### Use `waitForSelector` with state for element lifecycle
```ts
// ✅ Good - wait for panel to close
await page.waitForSelector('[data-testid="side-panel"]', { state: 'detached' })
```
### Avoid `networkidle` - use specific API waits instead
```ts
// ❌ Bad - unreliable and slow
await page.waitForLoadState('networkidle')
// ✅ Good - wait for specific API response
await waitForApiResponse(page, 'pg-meta', ref, 'tables')
```
### Use timeouts sparingly and only for non-API waits
```ts
// ✅ Acceptable - waiting for client-side debounce
await page.getByRole('textbox').fill('search term')
await page.waitForTimeout(300) // Allow debounce to complete
```
## Test Structure
### Use the custom test utility
Always import from the custom test utility for consistent fixtures:
```ts
import { test } from '../utils/test.js'
```
### Use `withFileOnceSetup` for expensive setup
When setup is expensive (cleanup, seeding), run it once per file:
```ts
test.beforeAll(async ({ browser, ref }) => {
await withFileOnceSetup(import.meta.url, async () => {
const ctx = await browser.newContext()
const page = await ctx.newPage()
// Expensive setup logic (e.g., cleanup old test data)
await deleteTestTables(page, ref)
})
})
test.afterAll(async () => {
await releaseFileOnceCleanup(import.meta.url)
})
```
### Dismiss toasts before interacting with UI
Toasts can overlay buttons and block interactions:
```ts
const dismissToastsIfAny = async (page: Page) => {
const closeButtons = page.getByRole('button', { name: 'Close toast' })
const count = await closeButtons.count()
for (let i = 0; i < count; i++) {
await closeButtons.nth(i).click()
}
}
// ✅ Good - dismiss toasts before clicking
await dismissToastsIfAny(page)
await page.getByRole('button', { name: 'New table' }).click()
```
## Assertions
### Always include descriptive messages
```ts
// ❌ Bad - no context on failure
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
// ✅ Good - clear message on failure
await expect(
page.getByRole('button', { name: 'Save' }),
'Save button should be visible after form is filled'
).toBeVisible()
```
### Use appropriate timeouts for slow operations
```ts
// ✅ Good - explicit timeout for slow operations
await expect(
page.getByText(`Table ${tableName} is good to go!`),
'Success toast should be visible after table creation'
).toBeVisible({ timeout: 50000 })
```
## Locators
### Prefer role-based locators
```ts
// ✅ Good - semantic and resilient
page.getByRole('button', { name: 'Save' })
page.getByRole('textbox', { name: 'Username' })
page.getByRole('menuitem', { name: 'Delete' })
// ❌ Avoid - brittle CSS selectors
page.locator('.btn-primary')
page.locator('#submit-button')
```
### Use test IDs for complex elements
```ts
// ✅ Good - stable identifier for complex elements
page.getByTestId('table-editor-side-panel')
page.getByTestId('action-bar-save-row')
```
### Use `filter` for finding elements in context
```ts
// ✅ Good - find button within specific row
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
await bucketRow.getByRole('button').click()
```
## Helper Functions
### Extract reusable operations into helpers
Create helper functions for common operations:
```ts
// e2e/studio/utils/storage-helpers.ts
export const createBucket = async (
page: Page,
ref: string,
bucketName: string,
isPublic: boolean = false
) => {
await navigateToStorageFiles(page, ref)
// Check if already exists
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
if ((await bucketRow.count()) > 0) return
await dismissToastsIfAny(page)
// Create bucket with proper waits
const apiPromise = waitForApiResponse(page, 'storage', ref, 'bucket', { method: 'POST' })
await page.getByRole('button', { name: 'New bucket' }).click()
await page.getByRole('textbox', { name: 'Bucket name' }).fill(bucketName)
await page.getByRole('button', { name: 'Create' }).click()
await apiPromise
await expect(
page.getByRole('row').filter({ hasText: bucketName }),
`Bucket ${bucketName} should be visible`
).toBeVisible()
}
```
### Use the existing wait utilities
```ts
import {
createApiResponseWaiter,
waitForApiResponse,
waitForGridDataToLoad,
waitForTableToLoad,
} from '../utils/wait-for-response.js'
```
### Use the existing assertions utilities
#### Clipboard assertions
```ts
// ❌ Avoid - brittle hard coded timeout
await page.evaluate(() => navigator.clipboard.readText())
await page.waitForTimeout(500)
// ✅ Good - this utility function uses Playwright auto-retries mechanisms
await expectClipboardValue({
page,
value: 'expectedValue'
})
```
## API Mocking
### Mock APIs for isolated testing
```ts
// ✅ Good - mock API response
await page.route('*/**/logs.all*', async (route) => {
await route.fulfill({ body: JSON.stringify(mockAPILogs) })
})
```
### Use soft waits for optional API calls
```ts
// ✅ Good - don't fail if API doesn't respond
await waitForApiResponse(page, 'pg-meta', ref, 'optional-endpoint', {
soft: true,
fallbackWaitMs: 1000,
})
```
## Cleanup
### Clean up test data in beforeAll/beforeEach
```ts
test.beforeEach(async ({ page, ref }) => {
await deleteAllBuckets(page, ref)
})
```
### Handle existing state gracefully
```ts
// ✅ Good - check before trying to delete
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
if ((await bucketRow.count()) === 0) return
// proceed with deletion
```
### Reset local storage when needed
```ts
import { resetLocalStorage } from '../utils/reset-local-storage.js'
// Clean up after tests that modify local storage
await resetLocalStorage(page, ref)
```
@@ -1,10 +0,0 @@
---
description: "Testing: unit/integration conventions for Studio test files"
globs:
- apps/studio/**/*.test.ts
- apps/studio/**/*.test.tsx
alwaysApply: false
---
Follow the guidelines in `apps/studio/tests/README.md` when writing tests for Studio.
@@ -1,5 +1,5 @@
---
applyTo: "e2e/studio/**,apps/studio/**"
applyTo: 'e2e/studio/**,apps/studio/**'
---
# Studio E2E Test Review Rules
@@ -9,16 +9,19 @@ All comments are **advisory**.
## Selector Priority (best to worst)
1. **`getByRole` with accessible name** — most robust, tests accessibility
```typescript
page.getByRole('button', { name: 'Save' })
```
2. **`getByTestId`** — stable, explicit test hooks
```typescript
page.getByTestId('table-editor-side-panel')
```
3. **`getByText` with exact match** — good for unique text
```typescript
page.getByText('Data API Access', { exact: true })
```
@@ -31,18 +34,21 @@ All comments are **advisory**.
## Patterns to Flag
- **XPath selectors** — fragile to DOM changes
```typescript
// BAD
locator('xpath=ancestor::div[contains(@class, "space-y")]')
```
- **Parent traversal with `locator('..')`** — breaks when structure changes
```typescript
// BAD
element.locator('..').getByRole('button')
```
- **`waitForTimeout`** — never use; wait for something specific instead
```typescript
// BAD
await page.waitForTimeout(1000)
@@ -57,6 +63,7 @@ All comments are **advisory**.
```
- **`force: true` on clicks** — make elements visible first instead
```typescript
// BAD
await menuButton.click({ force: true })
@@ -76,4 +83,4 @@ All comments are **advisory**.
- Use `test.describe.configure({ mode: 'serial' })` for tests sharing database state
- Add messages to expects: `await expect(locator, 'why').toBeVisible({ timeout: 30000 })`
Canonical standard: `.claude/skills/e2e-studio-tests/SKILL.md`
Canonical standard: `.claude/skills/studio-e2e-tests/SKILL.md`
+1
View File
@@ -123,6 +123,7 @@ next-env.d.ts
!.claude/skills/
.claude/skills/me-*
CLAUDE.md
!.claude/CLAUDE.md
#include template .env file for docker-compose
!docker/.env