Merge branch 'master' into feat/storage-file-selector

This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2026-03-31 11:22:18 +02:00
commit c95e40dc31
571 files changed
+67321 -33688

No files matched your search

+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 200–300 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 page’s 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.
-35
View File
@@ -1,35 +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 (`form.formState.isDirty`) 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 button’s `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 page’s 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.
+42 -9
View File
@@ -1,5 +1,41 @@
# Copilot Code Review Instructions
## Review Policy — Read This First
You are a code reviewer for a large TypeScript/Next.js/React monorepo. Your reviews must be **low-noise and high-signal**. The team acts on fewer than 20% of default Copilot suggestions, so every comment you leave must earn its place.
### Confidence Threshold
Only comment when you are **>85% confident** the issue is a real bug, security vulnerability, or logic error. If you are unsure, do not comment. Silence is better than noise.
### What NOT to Comment On
Our CI pipeline already validates the following. **Never comment on these topics:**
- **Formatting or whitespace** — Prettier runs on every PR
- **Linting issues** — ESLint with auto-fix runs on every PR
- **Type errors** — TypeScript strict-mode typecheck runs on every PR
- **Typos or spelling** — Automated typo detection runs on every PR
- **Missing tests for trivial changes** — Handled by topic-specific test instructions
- **Import ordering or grouping** — Handled by linter
- **Naming style preferences** (camelCase vs snake_case debates) — Follow existing file conventions
- **Accessibility attributes on shadcn/Radix UI components** — See `studio-shadcn-components.instructions.md` for details
### What TO Comment On (Priority Order)
1. **Logic errors and bugs** — Off-by-one, null derefs, wrong conditional, unreachable code, incorrect early returns
2. **Security vulnerabilities** — XSS, SQL injection, auth bypass, secrets in code, unsafe `dangerouslySetInnerHTML`
3. **Race conditions and async bugs** — Missing `await`, unhandled promise rejections, stale closures, effect cleanup issues
4. **Data loss risks** — Destructive operations without confirmation, missing error handling on writes
5. **API contract violations** — Wrong HTTP method, missing auth headers, incorrect request/response shapes
### Comment Style
- **Be advisory, not prescriptive.** Use "Consider..." or "This may..." — never demand changes.
- **One comment per distinct issue.** Do not leave multiple comments about the same underlying problem.
- **No self-contradictions.** If you suggest a change, do not then flag a problem with your own suggestion.
- **Do not comment on individual commits.** Review the final state of the PR diff only.
## Repo Context
This is a TypeScript/Next.js/React monorepo:
@@ -11,16 +47,13 @@ This is a TypeScript/Next.js/React monorepo:
## Topic-Specific Guidelines
Detailed review rules are in path-specific instruction files under `.github/instructions/`:
Path-specific rules in `.github/instructions/`:
- **Telemetry**: `studio-telemetry.instructions.md` — event naming, property conventions, feature flag measurement
- **Testing**: `studio-testing.instructions.md` — test strategy, extraction patterns, coverage expectations
- **Error Handling**: `studio-error-handling.instructions.md` — error classification, `ErrorMatcher` usage
- **E2E Tests**: `studio-e2e-tests.instructions.md` — selector priority, anti-patterns (`waitForTimeout`, `force: true`)
- **Composition Patterns**: `studio-composition-patterns.instructions.md` — avoid boolean props, use compound components
- **shadcn/Radix Components**: `studio-shadcn-components.instructions.md` — accessibility handled by primitives, do not flag
These files are scoped to `apps/studio/` and applied automatically by Copilot during reviews.
## References
For the full, authoritative versions of these standards:
- Telemetry: `.claude/skills/telemetry-standards/SKILL.md`
- Testing: `.claude/skills/studio-testing/SKILL.md`
These files are scoped to `apps/studio/` and applied automatically during reviews.
@@ -0,0 +1,93 @@
---
applyTo: "apps/studio/**"
---
# React Composition Patterns Review Rules
All comments are **advisory**.
## Core Principle
Avoid boolean prop proliferation. Use composition (compound components, explicit variants, children) instead of boolean flags to customize behavior.
## When to Flag
### 1. Boolean Prop Proliferation (HIGH)
Flag components accumulating boolean props like `isThread`, `isEditing`, `showAttachments`. Each boolean doubles the state space.
```tsx
// BAD — unclear intent, combinatorial explosion
<Composer isThread isDMThread isEditing isForwarding={false} />
// GOOD — self-documenting variants
<ThreadComposer channelId="abc" />
<EditMessageComposer messageId="xyz" />
```
### 2. Render Props Instead of Children (MEDIUM)
Flag `renderX` callback props when `children` composition would work.
```tsx
// BAD — render prop for structure
<Composer renderFooter={() => <F />} />
// GOOD — compound component
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
</Composer.Footer>
```
### 3. UI Coupled to State Implementation (MEDIUM)
Flag UI components calling specific state hooks like `useGlobalChannelState()` directly. The provider should own the state implementation; UI should only use a generic context interface.
```tsx
// BAD — UI knows HOW state is managed
const state = useGlobalChannelState(channelId)
// GOOD — provider owns implementation, UI uses context
<ChannelProvider channelId={channelId}>
<Composer /> {/* reads from context */}
</ChannelProvider>
```
### 4. State Trapped in Child Components (MEDIUM)
Flag state that siblings or dialogs need but can't access without prop drilling or refs. Lift it into a provider.
```tsx
// BAD — sibling can't access state
function ForwardComposer() {
const [state, setState] = useState(init)
}
// ForwardButton is a sibling and can't reach state
// GOOD — provider at shared ancestor
<ForwardMessageProvider>
<Composer /> {/* can access state */}
<ForwardButton /> {/* can also access state */}
</ForwardMessageProvider>
```
### 5. React 19 API Updates
Flag `forwardRef` and `useContext` in new code — use `ref` as a regular prop and `use()` instead.
```tsx
// BAD
const Input = forwardRef((props, ref) => <input ref={ref} />)
const value = useContext(MyContext)
// GOOD
function Input({ ref, ...props }) { return <input ref={ref} /> }
const value = use(MyContext)
```
## Key Principle
Lift state → Compose UI → Inject via generic context → No boolean prop proliferation.
Canonical standard: `.claude/skills/vercel-composition-patterns/SKILL.md`
@@ -0,0 +1,86 @@
---
applyTo: 'e2e/studio/**,apps/studio/**'
---
# Studio E2E Test Review Rules
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 })
```
4. **`locator` with CSS** — use sparingly, more fragile
```typescript
page.locator('[data-state="open"]')
```
## 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)
// GOOD — wait for UI element
await expect(page.getByText('Success')).toBeVisible()
// GOOD — wait for API response
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
await saveButton.click()
await apiPromise
```
- **`force: true` on clicks** — make elements visible first instead
```typescript
// BAD
await menuButton.click({ force: true })
// GOOD — hover to reveal, then click
await tableRow.hover()
await expect(menuButton).toBeVisible()
await menuButton.click()
```
- **Broad `filter({ hasText })` on generic elements** — may match multiple elements; scope to specific containers instead
## Good Practices to Encourage
- Scope selectors to containers: `page.getByTestId('side-panel').getByRole('switch')`
- Add `aria-label` to icon-only buttons in source code for better test selectors
- 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/studio-e2e-tests/SKILL.md`
@@ -0,0 +1,43 @@
---
applyTo: "apps/studio/**"
---
# Studio Error Handling Review Rules
All comments are **advisory**.
## Architecture
Errors flow: `handleError()` → throws typed subclass → React Query catches → `ErrorMatcher` reads `errorType` → renders troubleshooting. The component does an O(1) lookup — it never does regex matching.
## When to Flag
- PR passes `error.message` instead of the full `error` object to `ErrorMatcher` — the class type is lost
- PR puts regex patterns in `error-mappings.tsx` — they belong in `data/error-patterns.ts`
- PR uses `Object.assign` to stamp `errorType` on an error — should throw a proper subclass instead
- PR passes a raw URL string for support links — should use `supportFormParams={{ projectRef }}`
- PR puts the page title inside the error mapping — it belongs on the `<ErrorMatcher>` caller
- PR adds callback props (`onDebugWithAI`, `onRestartProject`) to troubleshooting components — use hooks inside them instead
## Correct Usage
```tsx
{isError && (
<ErrorMatcher
title="Failed to load tables"
error={error}
supportFormParams={{ projectRef }}
/>
)}
```
## Key Files
| File | Purpose |
|------|---------|
| `data/error-patterns.ts` | `{ pattern, ErrorClass }` array — regex lives here |
| `types/api-errors.ts` | Error classes, `KnownErrorType` union |
| `ErrorMatcher.tsx` | Reads `errorType`, looks up mapping, renders |
| `error-mappings.tsx` | `Record<KnownErrorType, { id, Troubleshooting }>` |
Canonical standard: `.claude/skills/studio-error-handling/SKILL.md`
@@ -0,0 +1,53 @@
---
applyTo: "apps/studio/**"
---
# shadcn/Radix UI Component Review Rules
All comments are **advisory**.
## Core Principle
This project uses **shadcn/ui** components built on **Radix UI** primitives (from `packages/ui/`). These components provide comprehensive accessibility out-of-the-box. **Do not flag missing accessibility attributes that are already handled by the underlying Radix primitives.**
## Components with Built-In Accessibility — Do NOT Flag
The following components (imported from `ui`) already handle ARIA roles, keyboard navigation, focus management, and screen reader support automatically via Radix UI primitives:
| Component | What Radix Handles |
|-----------|-------------------|
| `Dialog`, `AlertDialog` | `role="dialog"`, `aria-modal`, focus trapping, ESC to close |
| `DropdownMenu`, `ContextMenu` | `role="menu"` / `role="menuitem"`, arrow key navigation |
| `Select` | `role="combobox"`, `aria-expanded`, keyboard selection |
| `Tabs` | `role="tablist"` / `role="tab"` / `role="tabpanel"`, `aria-selected`, arrow keys |
| `Checkbox` | `role="checkbox"`, `aria-checked`, Space to toggle |
| `RadioGroup` | `role="radio"`, `aria-checked`, arrow key navigation |
| `Switch` | `role="switch"`, `aria-checked`, keyboard toggle |
| `Tooltip` | Trigger/content association, show/hide timing |
| `Accordion`, `Collapsible` | `aria-expanded`, Enter/Space to toggle |
| `Popover`, `HoverCard` | Focus management, dismiss on ESC |
| `Slider` | `role="slider"`, `aria-valuemin/max/now`, arrow keys |
| `Toggle`, `ToggleGroup` | `aria-pressed`, keyboard support |
| `ScrollArea` | Accessible scrollbar replacement |
| `NavigationMenu` | `role="navigation"`, keyboard navigation |
### Specifically, Never Flag These
- Missing `role` on `Dialog`, `AlertDialog`, `DropdownMenu`, `Select`, `Tabs`, `RadioGroup`, or other Radix-based components — roles are set by the primitive
- Missing `aria-modal` on `Dialog` or `AlertDialog` — set automatically
- Missing `aria-expanded` on `Accordion`, `Collapsible`, `Select`, or `DropdownMenu` triggers — managed by Radix state
- Missing `aria-selected` on `Tabs` — managed by `TabsPrimitive`
- Missing `aria-checked` on `Checkbox`, `RadioGroup`, or `Switch` — managed by Radix state
- Missing keyboard event handlers (`onKeyDown`, `onKeyUp`) on interactive Radix components — keyboard support is built-in
- Missing focus management in `Dialog` or `AlertDialog` — focus trapping is automatic
- Missing `aria-label` on `DialogClose` or `AlertDialogCancel` — these render a visible `<span className="sr-only">Close</span>`
## What TO Flag
Only flag accessibility issues for:
1. **Custom interactive elements** not using Radix primitives (e.g., a `<div onClick>` that should be a `<button>`)
2. **Icon-only buttons** missing an accessible label — `<Button>` alone does not add one; use `aria-label` or `<span className="sr-only">`
3. **Missing `Label` association** — form inputs should be paired with `<Label htmlFor="...">` or wrapped in a `<Field>` component
4. **Images missing `alt` text** — not handled by any component library
5. **Color-only state indicators** — state changes should not rely solely on color
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- name: Generate token
id: app-token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.GH_AUTOFIX_APP_ID }}
private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }}
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
run: cd apps/studio && pnpm evals:setup
- name: Run Evals
uses: braintrustdata/eval-action@v1
uses: braintrustdata/eval-action@c0dd75b29984a0cc63a827d6e8da2f23f2752be4 # v1.0.16
with:
api_key: ${{ secrets.BRAINTRUST_API_KEY }}
runtime: node
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
- name: cache cargo
id: cache-cargo
if: steps.filter.outputs.docs == 'true'
uses: actions/cache@v4
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with:
path: |
~/.cargo/bin/
+34 -1
View File
@@ -59,6 +59,33 @@ jobs:
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Fetch release notes
env:
VERSION: ${{ github.event.inputs.version }}
GH_TOKEN: ${{ github.token }}
run: |
CURRENT_FULL=$(git show HEAD:pnpm-workspace.yaml | grep -oP "(?<='@supabase/supabase-js': )[\d]+\.[\d]+\.[\d]+(-[\w.]+)?")
CURRENT_BASE=$(echo "$CURRENT_FULL" | grep -oP '[\d]+\.[\d]+\.[\d]+')
[[ "$CURRENT_FULL" == *"-"* ]] && INCLUDE_CURRENT=true || INCLUDE_CURRENT=false
[[ "$VERSION" == *"-"* ]] && STABLE_ONLY=false || STABLE_ONLY=true
RELEASES=$(gh api "repos/supabase/supabase-js/releases?per_page=100")
RELEASE_NOTES=$(echo "$RELEASES" | jq -r \
--arg current "v${CURRENT_BASE}" \
--arg new "v${VERSION}" \
--argjson stable_only "$STABLE_ONLY" \
--argjson include_current "$INCLUDE_CURRENT" \
'[.[] | select(.draft == false) | select(if $stable_only then .prerelease == false else true end)] |
(map(.tag_name) | index($new)) as $start |
(map(.tag_name) | index($current)) as $end |
($end | if . != null and $include_current then . + 1 else . end) as $end_adj |
if $start == null then ["Target version not found in last 100 releases."]
elif $end_adj != null and $start >= $end_adj then ["Downgrade — no release notes available."]
else [.[$start:$end_adj][] | "## " + .tag_name + "\n\n" + (.body // "No release notes.")]
end | .[]')
echo "RELEASE_NOTES<<EOF" >> "$GITHUB_ENV"
echo "$RELEASE_NOTES" >> "$GITHUB_ENV"
echo "EOF" >> "$GITHUB_ENV"
- name: Generate token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
@@ -84,6 +111,12 @@ jobs:
- Updated @supabase/postgest-js to ${{ github.event.inputs.version }}
- Refreshed pnpm-lock.yaml
---
## Release Notes
${{ env.RELEASE_NOTES }}
This PR was created automatically.
branch: 'gha/auto-update-js-libs-v${{ github.event.inputs.version }}'
base: 'master'
base: 'master'
+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
+22
View File
@@ -731,6 +731,28 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"data-input-with-reveal-copy-editable": {
name: "data-input-with-reveal-copy-editable",
type: "components:example",
registryDependencies: ["data-input"],
component: React.lazy(() => import("@/registry/default/example/data-input-with-reveal-copy-editable")),
source: "",
files: ["registry/default/example/data-input-with-reveal-copy-editable.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"data-input-with-reveal-copy-editable-empty": {
name: "data-input-with-reveal-copy-editable-empty",
type: "components:example",
registryDependencies: ["data-input"],
component: React.lazy(() => import("@/registry/default/example/data-input-with-reveal-copy-editable-empty")),
source: "",
files: ["registry/default/example/data-input-with-reveal-copy-editable-empty.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"date-picker-demo": {
name: "date-picker-demo",
type: "components:example",
-10
View File
@@ -1,10 +0,0 @@
'use client'
import { SonnerToaster as Toaster } from 'ui'
import { useConfig } from '@/hooks/use-config'
export function SonnerToaster() {
const [config] = useConfig()
return <Toaster position={config.sonnerPosition} expand={config.sonnerExpand} />
}
+2 -2
View File
@@ -5,7 +5,7 @@ import type { Metadata, Viewport } from 'next'
import { customFont, sourceCodePro } from './fonts'
import { ThemeProvider } from './Providers'
import { SonnerToaster } from './SonnerToast'
import { Toaster } from './toaster'
const className = `${customFont.variable} ${sourceCodePro.variable}`
@@ -139,7 +139,7 @@ export default async function Layout({ children }: RootLayoutProps) {
<div vaul-drawer-wrapper="">
<div className="relative flex min-h-screen flex-col bg-background">{children}</div>
</div>
<SonnerToaster />
<Toaster />
</ThemeProvider>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
'use client'
import { useTheme } from 'next-themes'
import { SonnerToaster } from 'ui'
import { useConfig } from '@/hooks/use-config'
export function Toaster() {
const [config] = useConfig()
const { theme } = useTheme()
return (
<SonnerToaster
position={config.sonnerPosition}
expand={config.sonnerExpand}
theme={theme as 'light' | 'dark' | 'system'}
/>
)
}
@@ -144,6 +144,23 @@ The component displays:
<ComponentPreview name="table-sort" />
For TanStack tables, prefer the shared adapter instead of reimplementing the `TableHeadSort` bridge in each table.
```tsx
import { TanStackTableHeadSort } from 'ui-patterns/Table'
```
```tsx showLineNumbers
const columns: ColumnDef<Row>[] = [
{
accessorKey: 'name',
header: ({ column }) => <TanStackTableHeadSort column={column}>Name</TanStackTableHeadSort>,
},
]
```
This keeps TanStack tables aligned with the same `TableHeadSort` visual treatment and sorting cycle used by manual tables.
### Row icons
When adding icon columns to your table, use [Accessibility](../accessibility) markup by including a screen reader-only label in the corresponding Table Head using the `sr-only` class. This ensures that assistive technologies can properly identify the column's purpose. Remove these icon cells when loading or displaying zero results to maintain a clean and consistent table structure.
@@ -22,6 +22,10 @@ Inputs with sensitive values can be both revealed _and_ copied, but only in succ
<ComponentPreview name="data-input-with-reveal-copy" peekCode wide />
Inputs can be edited without revealing their value.
<ComponentPreview name="data-input-with-reveal-copy-editable" peekCode wide />
You can also partially truncate the value by overriding the placeholder value.
Consider if the value needs to be revealed in the first place, as only copying is sufficient in most cases.
@@ -49,7 +49,7 @@ Use the shared [Key/Value Field Array](../fragments/key-value-field-array) fragm
4. **Use Cards for grouping**: Wrap form sections in `Card` components with `CardContent` and `CardFooter` for actions.
5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`.
5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. Make sure you destructure `isDirty` from `form.formState` (see https://react-hook-form.com/docs/useform/formstate)
6. **Error handling**: Always use mutations with `onSuccess` and `onError` callbacks that show toast notifications.
@@ -104,8 +104,13 @@ Studio implementation (preferred in Studio code):
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
const form = useForm(...)
// Always destructure formState values otherwise they won't be updated
// See https://react-hook-form.com/docs/useform/formstate
const { isDirty } = form.formState
const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
checkIsDirty: () => form.formState.isDirty,
checkIsDirty: () => isDirty,
onClose,
})
+1 -1
View File
@@ -8,7 +8,7 @@
"dev": "next dev --turbopack --port 3003",
"dev:full": "concurrently \"pnpm dev\" \"pnpm content:dev\"",
"build": "pnpm run content:build && pnpm run build:registry && next build --turbopack",
"build:registry": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache",
"build:registry": "tsx ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache",
"start": "next start",
"lint": "eslint .",
"content:dev": "contentlayer2 dev",
@@ -0,0 +1,5 @@
import { Input } from 'ui-patterns/DataInputs/Input'
export default function DataInputWithRevealCopy() {
return <Input containerClassName="w-full max-w-sm" reveal copy />
}
@@ -0,0 +1,5 @@
import { Input } from 'ui-patterns/DataInputs/Input'
export default function DataInputWithRevealCopy() {
return <Input containerClassName="w-full max-w-sm" reveal copy defaultValue="1234567890" />
}
@@ -30,9 +30,9 @@ import {
TableCell,
TableHead,
TableHeader,
TableHeadSort,
TableRow,
} from 'ui'
import { TanStackTableHeadSort } from 'ui-patterns/Table'
const data: Payment[] = [
{
@@ -99,19 +99,23 @@ export const columns: ColumnDef<Payment>[] = [
},
{
accessorKey: 'status',
header: 'Status',
header: ({ column }) => <TanStackTableHeadSort column={column}>Status</TanStackTableHeadSort>,
enableSorting: true,
cell: ({ row }) => <div className="capitalize">{row.getValue('status')}</div>,
},
{
accessorKey: 'email',
header: 'Email',
header: ({ column }) => <TanStackTableHeadSort column={column}>Email</TanStackTableHeadSort>,
enableSorting: true,
cell: ({ row }) => <div className="lowercase">{row.getValue('email')}</div>,
},
{
accessorKey: 'amount',
header: () => <div className="text-right">Amount</div>,
header: ({ column }) => (
<TanStackTableHeadSort column={column} className="justify-end">
Amount
</TanStackTableHeadSort>
),
enableSorting: true,
cell: ({ row }) => {
const amount = parseFloat(row.getValue('amount'))
@@ -164,33 +168,6 @@ export default function DataTableDemo() {
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
// Convert TanStack Table's SortingState to the string format expected by TableHeadSort
const getSortString = React.useMemo(() => {
if (sorting.length === 0) return ''
const sort = sorting[0]
return `${sort.id}:${sort.desc ? 'desc' : 'asc'}`
}, [sorting])
// Handle sort changes from TableHeadSort and convert to TanStack Table's SortingState
const handleSortChange = React.useCallback(
(column: string) => {
const currentSort = sorting.find((s) => s.id === column)
if (currentSort) {
if (currentSort.desc) {
// Cycle: desc -> remove sort
setSorting([])
} else {
// Cycle: asc -> desc
setSorting([{ id: column, desc: true }])
}
} else {
// New column, start with asc
setSorting([{ id: column, desc: false }])
}
},
[sorting]
)
const table = useReactTable({
data,
columns,
@@ -254,11 +231,20 @@ export default function DataTableDemo() {
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnId = header.column.id
const canSort = header.column.getCanSort()
const sort = header.column.getIsSorted()
return (
<TableHead
key={header.id}
aria-sort={
header.column.getCanSort()
? sort === 'asc'
? 'ascending'
: sort === 'desc'
? 'descending'
: 'none'
: undefined
}
className={
columnId === 'amount'
? 'text-right'
@@ -267,18 +253,9 @@ export default function DataTableDemo() {
: undefined
}
>
{header.isPlaceholder ? null : canSort ? (
<TableHeadSort
column={columnId}
currentSort={getSortString}
onSortChange={handleSortChange}
className={columnId === 'amount' ? 'justify-end' : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHeadSort>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
@@ -1,4 +1,4 @@
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay/ErrorDisplay'
export default function ErrorDisplayDemo() {
return (
@@ -1,4 +1,4 @@
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay/ErrorDisplay'
export default function ErrorDisplayWithChildren() {
return (
@@ -14,10 +14,14 @@ import {
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
PrePostTab,
RadioGroupStacked,
RadioGroupStackedItem,
Select_Shadcn_,
@@ -219,9 +223,12 @@ export default function FormPatternsPageLayout() {
description="Input with additional unit label"
>
<FormControl_Shadcn_>
<PrePostTab postTab="MB" className="w-full">
<Input_Shadcn_ {...field} type="number" min={5} max={30} />
</PrePostTab>
<InputGroup>
<InputGroupInput {...field} type="number" min={5} max={30} />
<InputGroupAddon align="inline-end">
<InputGroupText className="font-mono">MB</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl_Shadcn_>
</FormItemLayout>
)}
@@ -252,6 +259,35 @@ export default function FormPatternsPageLayout() {
/>
</CardContent>
{/* Textarea with addon */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="description"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Textarea"
description="Multi-line text input for longer content with addon"
>
<FormControl_Shadcn_>
<InputGroup>
<InputGroupTextarea
{...field}
rows={4}
placeholder="Enter multi-line text"
className="resize-none"
/>
<InputGroupAddon align="block-end">
<InputGroupText>120 characters left</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
{/* Icon Upload */}
<CardContent>
<FormField_Shadcn_
@@ -622,7 +658,7 @@ export default function FormPatternsPageLayout() {
<PopoverTrigger_Shadcn_ asChild>
<Button
type="outline"
className="w-full justify-start text-left font-normal px-3 py-4"
className="bg-control w-full justify-start text-left font-normal px-3 py-4"
icon={<CalendarIcon className="h-4 w-4" />}
>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
@@ -11,10 +11,13 @@ import {
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
PrePostTab,
RadioGroupStacked,
RadioGroupStackedItem,
Select_Shadcn_,
@@ -233,9 +236,12 @@ export default function FormPatternsSidePanel() {
description="Input with additional unit label"
>
<FormControl_Shadcn_ className="col-span-6">
<PrePostTab postTab="MB" className="w-full">
<Input_Shadcn_ {...field} type="number" min={5} max={30} />
</PrePostTab>
<InputGroup>
<InputGroupInput {...field} type="number" min={5} max={30} />
<InputGroupAddon align="inline-end">
<InputGroupText>MB</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl_Shadcn_>
</FormItemLayout>
)}
@@ -653,7 +659,7 @@ export default function FormPatternsSidePanel() {
<PopoverTrigger_Shadcn_ asChild>
<Button
type="outline"
className="w-full justify-start text-left font-normal px-3 py-4"
className="bg-control w-full justify-start text-left font-normal px-3 py-4"
icon={<CalendarIcon className="h-4 w-4" />}
>
{field.value ? format(field.value, 'PPP') : 'Pick a date'}
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidBasic() {
return (
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidDemo() {
return (
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidERDiagram() {
return (
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidErSimple() {
return (
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidFlowchartSubgraph() {
return (
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidFlowchart() {
return (
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidOAuthFlow() {
return (
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidSequenceApi() {
return (
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidSequenceAsync() {
return (
@@ -1,4 +1,4 @@
import { Mermaid } from 'ui'
import { Mermaid } from 'ui-patterns/Mermaid'
export default function MermaidSequenceSync() {
return (
@@ -8,8 +8,10 @@ import {
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
PrePostTab,
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
@@ -125,9 +127,12 @@ export default function PageLayoutSettings() {
description="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
>
<FormControl_Shadcn_>
<PrePostTab postTab="seconds">
<Input_Shadcn_ type="number" min={0} {...field} />
</PrePostTab>
<InputGroup>
<InputGroupAddon align="inline-end">
<InputGroupText>seconds</InputGroupText>
</InputGroupAddon>
<InputGroupInput type="number" min={0} {...field} />
</InputGroup>
</FormControl_Shadcn_>
</FormItemLayout>
)}
@@ -196,9 +201,14 @@ export default function PageLayoutSettings() {
>
<div className="flex items-center">
<FormControl_Shadcn_>
<PrePostTab postTab={<HoursOrNeverText value={field.value || 0} />}>
<Input_Shadcn_ type="number" min={0} {...field} />
</PrePostTab>
<InputGroup>
<InputGroupAddon align="inline-end">
<InputGroupText>
<HoursOrNeverText value={field.value || 0} />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput type="number" min={0} {...field} />
</InputGroup>
</FormControl_Shadcn_>
</div>
</FormItemLayout>
@@ -218,9 +228,14 @@ export default function PageLayoutSettings() {
>
<div className="flex items-center">
<FormControl_Shadcn_>
<PrePostTab postTab={<HoursOrNeverText value={field.value || 0} />}>
<Input_Shadcn_ type="number" {...field} />
</PrePostTab>
<InputGroup>
<InputGroupAddon align="inline-end">
<InputGroupText>
<HoursOrNeverText value={field.value || 0} />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput type="number" {...field} />
</InputGroup>
</FormControl_Shadcn_>
</div>
</FormItemLayout>
+12
View File
@@ -439,6 +439,18 @@ export const examples: Registry = [
registryDependencies: ['data-input'],
files: ['example/data-input-with-reveal-copy.tsx'],
},
{
name: 'data-input-with-reveal-copy-editable',
type: 'components:example',
registryDependencies: ['data-input'],
files: ['example/data-input-with-reveal-copy-editable.tsx'],
},
{
name: 'data-input-with-reveal-copy-editable-empty',
type: 'components:example',
registryDependencies: ['data-input'],
files: ['example/data-input-with-reveal-copy-editable-empty.tsx'],
},
{
name: 'date-picker-demo',
type: 'components:example',
-20
View File
@@ -1,20 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true
},
"exclude": ["node_modules"]
}
+4 -21
View File
@@ -1,40 +1,23 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "tsconfig/base.json",
"extends": "tsconfig/nextjs.json",
"compilerOptions": {
"target": "es5",
"allowJs": false,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"paths": {
"@/*": ["./*"],
"@ui/*": ["./../../packages/ui/src/*"], // handle ui package paths
"contentlayer/generated": ["./.contentlayer/generated"],
"icons/*": ["./../../packages/icons/*"]
},
"plugins": [
{
"name": "next"
}
]
"plugins": [{ "name": "next" }]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".contentlayer/generated",
"./../../packages/ui/src/**/*.d.ts"
".contentlayer/generated"
],
"exclude": ["node_modules", "./scripts/build-registry.mts"]
}
-13
View File
@@ -1,13 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"target": "es6",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"isolatedModules": false
},
"include": [".contentlayer/generated", "scripts/**/*.ts"],
"exclude": ["node_modules"]
}
+9
View File
@@ -0,0 +1,9 @@
declare module '*.css' {
export const styles: Record<string, string>
export default styles
}
declare module '*.scss' {
export const styles: Record<string, string>
export default styles
}
+1
View File
@@ -32,6 +32,7 @@ public/llms.txt
public/llms/
# Generated guide markdown files
public/docs/
public/docs.tar.gz
# Copied examples folder
/examples/
@@ -16,14 +16,13 @@ import {
removeRedundantH1,
} from '~/features/docs/GuidesMdx.utils'
import { newEditLink } from '~/features/helpers.edit-link'
import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
import { Guide, GuideArticle, GuideFooter, GuideHeader, GuideMdxContent } from '~/features/ui/guide'
import { GUIDES_DIRECTORY, isValidGuideFrontmatter } from '~/lib/docs'
import { linkTransform, type UrlTransformFunction } from '~/lib/mdx/plugins/rehypeLinkTransform'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import { octokit } from '~/lib/octokit'
import { getGitHubFileContents, octokit } from '~/lib/octokit'
import type { SerializeOptions } from '~/types/next-mdx-remote-serialize'
// We fetch these docs at build time from an external repo
@@ -85,13 +84,6 @@ async function getLatestRelease(after: string | null = null) {
owner: org,
name: repo,
after,
request: {
fetch: (url: RequestInfo | URL, options?: RequestInit) =>
fetch(url, {
...options,
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
}),
},
})
return (
@@ -357,26 +349,14 @@ const getContent = async (params: Params) => {
throw new Error('No latest release found for federated wrappers pages')
}
const repoPath = `${org}/${repo}/${tag}/${docsDir}/${remoteFile}`
editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
let response: Response
try {
response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, {
cache: 'force-cache',
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
})
} catch (err) {
throw new Error(`Failed to fetch wrappers docs from GitHub (network error): ${err}`)
}
if (!response.ok) {
throw new Error(
`Failed to fetch wrappers docs from GitHub: ${response.status} ${response.statusText}`
)
}
const rawContent = await response.text()
let rawContent = await getGitHubFileContents({
org,
repo,
path: `${docsDir}/${remoteFile}`,
branch: tag,
})
assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/`
@@ -2,25 +2,19 @@ import { codeBlock } from 'common-tags'
import { Check, PlusCircle } from 'lucide-react'
import Link from 'next/link'
import ReactMarkdown from 'react-markdown'
import { Heading, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import {
CodeBlock,
Heading,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import { getGitHubFileContents } from '~/lib/octokit'
import { TabPanel, Tabs } from '~/features/ui/Tabs'
import {
terraformDocsBranch,
terraformDocsDocsDir,
terraformDocsOrg,
terraformDocsRepo,
} from '../terraformConstants'
import { GuideTemplate, newEditLink } from '@/features/docs/GuidesMdx.template'
import { genGuideMeta } from '@/features/docs/GuidesMdx.utils'
import { TabPanel, Tabs } from '@/features/ui/Tabs'
import { getGitHubFileContents } from '@/lib/octokit'
const meta = {
title: 'Terraform Provider reference',
@@ -1,11 +1,11 @@
import ReactMarkdown from 'react-markdown'
import { CodeBlock } from 'ui'
import { Heading } from 'ui/src/components/CustomHTMLElements'
import { type TOCHeader } from '~/components/GuidesSidebar'
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
import type { Parameter } from '~/lib/refGenerator/refTypes'
import specFile from '~/spec/cli_v1_config.yaml' with { type: 'yml' }
import ReactMarkdown from 'react-markdown'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { Heading } from 'ui/src/components/CustomHTMLElements'
const meta = {
title: 'Supabase CLI config',
+4 -5
View File
@@ -1,18 +1,17 @@
import '@code-hike/mdx/styles'
import '@code-hike/mdx/styles.css'
import 'config/code-hike.scss'
import 'ui-patterns/ShimmeringLoader/index.css'
import '../styles/main.scss'
import '../styles/new-docs.scss'
import '../styles/prism-okaidia.scss'
import { TelemetryTagManager } from 'common'
import { genFaviconData } from 'common/MetaFavicons/app-router'
import type { Metadata, Viewport } from 'next'
import { GlobalProviders } from '~/features/app.providers'
import { TopNavSkeleton } from '~/layouts/MainSkeleton'
import { BASE_PATH, IS_PRODUCTION } from '~/lib/constants'
import { getCustomContent } from '~/lib/custom-content/getCustomContent'
import { TelemetryTagManager } from 'common'
import { genFaviconData } from 'common/MetaFavicons/app-router'
import type { Metadata, Viewport } from 'next'
const { metadataApplicationName, metadataTitle } = getCustomContent([
'metadata:application_name',
+9 -8
View File
@@ -1,15 +1,16 @@
import { isFeatureEnabled } from 'common'
import { type Metadata, type ResolvingMetadata } from 'next'
import Link from 'next/link'
import { cn, IconBackground, TextLink } from 'ui'
import { cn, IconBackground } from 'ui'
import { IconPanel } from 'ui-patterns/IconPanel'
import { TextLink } from 'ui-patterns/TextLink'
import { isFeatureEnabled } from 'common'
import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker'
import { MIGRATION_PAGES } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
import { GlassPanelWithIconPicker } from '~/features/ui/GlassPanelWithIconPicker'
import { IconPanelWithIconPicker } from '~/features/ui/IconPanelWithIconPicker'
import HomeLayout from '~/layouts/HomeLayout'
import { BASE_PATH } from '~/lib/constants'
import MenuIconPicker from '@/components/Navigation/NavigationMenu/MenuIconPicker'
import { MIGRATION_PAGES } from '@/components/Navigation/NavigationMenu/NavigationMenu.constants'
import { GlassPanelWithIconPicker } from '@/features/ui/GlassPanelWithIconPicker'
import { IconPanelWithIconPicker } from '@/features/ui/IconPanelWithIconPicker'
import HomeLayout from '@/layouts/HomeLayout'
import { BASE_PATH } from '@/lib/constants'
const { sdkCsharp, sdkDart, sdkKotlin, sdkPython, sdkSwift } = isFeatureEnabled([
'sdk:csharp',
@@ -1,6 +1,7 @@
import { KJUR } from 'jsrsasign'
import { ChangeEvent, useState } from 'react'
import { Button, CodeBlock, Input, Select } from 'ui'
import { Button, Input, Select } from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
const JWT_HEADER = { alg: 'HS256', typ: 'JWT' }
const now = new Date()
@@ -1,6 +1,7 @@
import { KJUR } from 'jsrsasign'
import { useState } from 'react'
import { Button, CodeBlock } from 'ui'
import { Button } from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
const JWT_HEADER = { alg: 'HS256', typ: 'JWT' }
@@ -2862,6 +2862,7 @@ export const self_hosting: NavMenuConstant = {
},
{ name: 'Configure S3 Storage', url: '/guides/self-hosting/self-hosted-s3' },
{ name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' },
{ name: 'Custom Email Templates', url: '/guides/self-hosting/custom-email-templates' },
{ name: 'Configure Social Login (OAuth)', url: '/guides/self-hosting/self-hosted-oauth' },
{ name: 'Configure Phone Login & MFA', url: '/guides/self-hosting/self-hosted-phone-mfa' },
{ name: 'Enable MCP server', url: '/guides/self-hosting/enable-mcp' },
@@ -156,7 +156,7 @@ export const GET: APIRoute = async ({ request, cookies, redirect }) => {
getAll() {
return parseCookieHeader(Astro.request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, _headers) {
cookiesToSet.forEach(({ name, value, options }) =>
Astro.cookies.set(name, value, options)
)
@@ -191,7 +191,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
const next = requestUrl.searchParams.get('next') || '/'
const headers = new Headers()
const responseHeaders = new Headers()
if (code) {
const supabase = createServerClient(
@@ -202,10 +202,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
getAll() {
return parseCookieHeader(request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, cacheHeaders) {
cookiesToSet.forEach(({ name, value, options }) =>
headers.append('Set-Cookie', serializeCookieHeader(name, value, options))
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value, options))
)
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
},
},
}
@@ -214,12 +215,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return redirect(next, { headers })
return redirect(next, { headers: responseHeaders })
}
}
// return the user to an error page with instructions
return redirect('/auth/auth-code-error', { headers })
return redirect('/auth/auth-code-error', { headers: responseHeaders })
}
```
@@ -240,11 +241,14 @@ app.get("/auth/callback", async function (req, res) {
process.env.SUPABASE_PUBLISHABLE_KEY, {
cookies: {
getAll() {
return parseCookieHeader(context.req.headers.cookie ?? '')
return parseCookieHeader(req.headers.cookie ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) =>
context.res.appendHeader('Set-Cookie', serializeCookieHeader(name, value, options))
res.appendHeader('Set-Cookie', serializeCookieHeader(name, value, options))
)
Object.entries(headers).forEach(([key, value]) =>
res.setHeader(key, value)
)
},
},
@@ -212,7 +212,7 @@ export default async function ConsentPage({
{
cookies: {
getAll: async () => (await cookies()).getAll(),
setAll: async (cookiesToSet) => {
setAll: async (cookiesToSet, _headers) => {
const cookieStore = await cookies()
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
},
@@ -297,7 +297,7 @@ export async function POST(request: Request) {
{
cookies: {
getAll: async () => (await cookies()).getAll(),
setAll: async (cookiesToSet) => {
setAll: async (cookiesToSet, _headers) => {
const cookieStore = await cookies()
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
},
+2 -2
View File
@@ -280,7 +280,7 @@ export const GET: APIRoute = async ({ request, cookies, redirect }) => {
getAll() {
return parseCookieHeader(request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, _headers) {
cookiesToSet.forEach(({ name, value, options }) => cookies.set(name, value, options))
},
},
@@ -801,7 +801,7 @@ export const GET: APIRoute = async ({ request, cookies, redirect }) => {
getAll() {
return parseCookieHeader(request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, _headers) {
cookiesToSet.forEach(({ name, value, options }) => cookies.set(name, value, options))
},
},
@@ -72,7 +72,9 @@ Do not enable ISR on any route where authentication is handled or where a sessio
When `@supabase/ssr` refreshes a session token server-side, it writes the updated JWT to the HTTP response via a `Set-Cookie` header. If your CDN (e.g. Vercel Edge, Cloudflare) caches that response and serves it to a different user, that user's browser will store the cached token and be signed in as the wrong person.
To prevent this, set `Cache-Control: private, no-store` on responses from any route that handles authentication, typically your middleware. Most CDNs respect this header and will not cache the response.
As of `@supabase/ssr` v0.10.0, the library automatically passes the necessary cache headers (`Cache-Control`, `Expires`, `Pragma`) to your `setAll` callback as a second argument whenever a token refresh occurs. If your `setAll` implementation applies those headers to the response (as shown in the examples in [Creating a Supabase client for SSR](/docs/guides/auth/server-side/creating-a-client)), no additional manual configuration is needed for most CDNs.
If you are on an older version or need to set headers manually, add `Cache-Control: private, no-store` to responses from any route that handles authentication:
#### Next.js middleware
@@ -183,7 +183,7 @@ The Proxy is responsible for:
The cookies object lets the Supabase client know how to access the cookies, so it can read and write the user session data. To make `@supabase/ssr` framework-agnostic, the cookies methods aren't hard-coded. These utility functions adapt `@supabase/ssr`'s cookie handling for Next.js.
The `set` and `remove` methods for the server client need error handlers, because Next.js throws an error if cookies are set from Server Components. You can safely ignore this error because you'll set up Proxy in the next step to write refreshed cookies to storage.
`setAll` is called whenever the library needs to write cookies, for example after a token refresh. It receives two arguments: the array of cookies to set, and a `headers` object containing cache headers (`Cache-Control`, `Expires`, `Pragma`) that must be applied to the HTTP response to prevent CDNs from caching the response and leaking the session to other users. In the Proxy, apply these headers to the response. In Server Components, the headers cannot be set, which is why the `setAll` call is wrapped in a try/catch and the error is ignored. The Proxy handles writing cookies and headers on every request.
The cookie is named `sb-<project_ref>-auth-token` by default.
@@ -346,9 +346,12 @@ const supabase = createServerClient(
getAll() {
return parseCookieHeader(Astro.request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) =>
Astro.cookies.set(name, value))
Object.entries(headers).forEach(([key, value]) =>
Astro.response.headers.set(key, value)
)
},
},
}
@@ -388,7 +391,7 @@ export async function GET(context: APIContext) {
getAll() {
return parseCookieHeader(context.request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, _headers) {
cookiesToSet.forEach(({ name, value }) =>
context.cookies.set(name, value))
},
@@ -417,7 +420,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
getAll() {
return parseCookieHeader(context.request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, _headers) {
cookiesToSet.forEach(({ name, value }) => context.cookies.set(name, value))
},
},
@@ -452,7 +455,7 @@ import { type LoaderFunctionArgs } from '@remix-run/node'
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'
export async function loader({ request }: LoaderFunctionArgs) {
const headers = new Headers()
const responseHeaders = new Headers()
const supabase = createServerClient(
process.env.SUPABASE_URL!,
@@ -462,17 +465,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
getAll() {
return parseCookieHeader(request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, cacheHeaders) {
cookiesToSet.forEach(({ name, value, options }) =>
headers.append('Set-Cookie', serializeCookieHeader(name, value, options))
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value, options))
)
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
},
},
}
)
return new Response('...', {
headers,
headers: responseHeaders,
})
}
```
@@ -486,7 +490,7 @@ import { type ActionFunctionArgs } from '@remix-run/node'
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'
export async function action({ request }: ActionFunctionArgs) {
const headers = new Headers()
const responseHeaders = new Headers()
const supabase = createServerClient(
process.env.SUPABASE_URL!,
@@ -496,17 +500,18 @@ export async function action({ request }: ActionFunctionArgs) {
getAll() {
return parseCookieHeader(request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, cacheHeaders) {
cookiesToSet.forEach(({ name, value, options }) =>
headers.append('Set-Cookie', serializeCookieHeader(name, value, options))
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value, options))
)
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
},
},
}
)
return new Response('...', {
headers,
headers: responseHeaders,
})
}
```
@@ -563,23 +568,24 @@ import { LoaderFunctionArgs } from 'react-router'
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'
export async function loader({ request }: LoaderFunctionArgs) {
const headers = new Headers()
const responseHeaders = new Headers()
const supabase = createServerClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
cookies: {
getAll() {
return parseCookieHeader(request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, cacheHeaders) {
cookiesToSet.forEach(({ name, value }) =>
headers.append('Set-Cookie', serializeCookieHeader(name, value))
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value))
)
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
},
},
})
return new Response('...', {
headers,
headers: responseHeaders,
})
}
```
@@ -593,23 +599,24 @@ import { type ActionFunctionArgs } from '@react-router'
import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'
export async function action({ request }: ActionFunctionArgs) {
const headers = new Headers()
const responseHeaders = new Headers()
const supabase = createServerClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
cookies: {
getAll() {
return parseCookieHeader(request.headers.get('Cookie') ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, cacheHeaders) {
cookiesToSet.forEach(({ name, value }) =>
headers.append('Set-Cookie', serializeCookieHeader(name, value))
responseHeaders.append('Set-Cookie', serializeCookieHeader(name, value))
)
Object.entries(cacheHeaders).forEach(([key, value]) => responseHeaders.set(key, value))
},
},
})
return new Response('...', {
headers,
headers: responseHeaders,
})
}
```
@@ -670,10 +677,11 @@ exports.createClient = (context) => {
getAll() {
return parseCookieHeader(context.req.headers.cookie ?? '')
},
setAll(cookiesToSet) {
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) =>
context.res.appendHeader('Set-Cookie', serializeCookieHeader(name, value))
)
Object.entries(headers).forEach(([key, value]) => context.res.setHeader(key, value))
},
},
})
@@ -8,7 +8,7 @@ sidebar_label: 'Monitoring'
Monitoring replication lag is important and there are 3 ways to do this:
1. Dashboard - Under the [Reports](/docs/guides/platform/reports) of the dashboard, you can view the replication lag of your project
1. Dashboard - Under the [Reports](/docs/guides/telemetry/reports) of the dashboard, you can view the replication lag of your project
2. Database -
- pg_stat_subscription (subscriber) - if PID is null, then the subscription is not active
- pg_stat_subscription_stats - look here for error_count to see if there were issues applying or syncing (if yes, check the logs for why)
@@ -27,4 +27,4 @@ When you merge any branch into your main project, Supabase automatically runs a
6. **Seed** - Runs seed files to populate your branch with initial data (must be [enabled in config.toml](/docs/guides/deployment/branching/configuration#branch-configuration-with-remotes) for persistent branches)
7. **Deploy** - Deploys any changed Edge Functions and updates function secrets
If a parent deployment step fails, all dependent children steps will be skipped. For e.g., if your database migrations failed at step 5, our runner will not seed your branch because step 6 is skipped. If you are using GitHub integration, the same deployment workflow will be run on every commit pushed to your git branch.
If a parent deployment step fails, all dependent child steps will be skipped. For instance, if your database migrations failed at step 5, our runner will not seed your branch because step 6 is skipped. If you are using GitHub integration, the same deployment workflow will be run on every commit pushed to your git branch.
@@ -9,7 +9,7 @@ description: 'Learn how to use Supabase in your Ionic React App.'
<Admonition type="note">
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/mhartington/supabase-ionic-react).
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/ionic-react-user-management).
</Admonition>
@@ -17,11 +17,11 @@ If you get stuck while working through this guide, refer to the [full example on
## Building the app
Let's start building the React app from scratch.
Start building the React app from scratch.
### Initialize an Ionic React app
We can use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize
Use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize
an app called `supabase-ionic-react`:
```bash
@@ -30,352 +30,63 @@ ionic start supabase-ionic-react blank --type react
cd supabase-ionic-react
```
Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
Install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
```bash
npm install @supabase/supabase-js
```
And finally we want to save the environment variables in a `.env`.
All we need are the API URL and the key that you copied [earlier](#get-api-details).
Save the environment variables in a `.env`. You need the API URL and the key that you copied [earlier](#get-api-details).
<$CodeTabs>
```bash name=.env
VITE_SUPABASE_URL=YOUR_SUPABASE_URL
VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY
VITE_SUPABASE_KEY=YOUR_SUPABASE_KEY
```
</$CodeTabs>
Now that we have the API credentials in place, let's create a helper file to initialize the Supabase client. These variables will be exposed
on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
With the API credentials in place, create a helper file to initialize the Supabase client. These variables will be exposed
in the browser, which is safe because they use a restricted publishable key and the SQL quickstart enables [Row Level Security](/docs/guides/auth#row-level-security) on the `profiles` table.
<$CodeTabs>
```js name=src/supabaseClient.ts
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || ''
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || ''
export const supabase = createClient(supabaseUrl, supabasePublishableKey)
```
</$CodeTabs>
<$CodeSample
path="/user-management/ionic-react-user-management/src/supabaseClient.ts"
lines={[[1, -1]]}
meta="name=src/supabaseClient.ts"
/>
### Set up a login route
Let's set up a React component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
Set up a React component to manage logins and sign ups which uses Magic Links, so users can sign in with their email without using passwords.
<$CodeTabs>
```jsx name=/src/pages/Login.tsx
import { useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonInput,
IonItem,
IonLabel,
IonList,
IonPage,
IonTitle,
IonToolbar,
useIonToast,
useIonLoading,
} from '@ionic/react';
import {supabase} from '../supabaseClient'
export function LoginPage() {
const [email, setEmail] = useState('');
const [showLoading, hideLoading] = useIonLoading();
const [showToast ] = useIonToast();
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
console.log()
e.preventDefault();
await showLoading();
try {
await supabase.auth.signInWithOtp({
"email": email
});
await showToast({ message: 'Check your email for the login link!' });
} catch (e: any) {
await showToast({ message: e.error_description || e.message , duration: 5000});
} finally {
await hideLoading();
}
};
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Login</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div className="ion-padding">
<h1>Supabase + Ionic React</h1>
<p>Sign in via magic link with your email below</p>
</div>
<IonList inset={true}>
<form onSubmit={handleLogin}>
<IonItem>
<IonLabel position="stacked">Email</IonLabel>
<IonInput
value={email}
name="email"
onIonChange={(e) => setEmail(e.detail.value ?? '')}
type="email"
></IonInput>
</IonItem>
<div className="ion-text-center">
<IonButton type="submit" fill="clear">
Login
</IonButton>
</div>
</form>
</IonList>
</IonContent>
</IonPage>
);
}
```
</$CodeTabs>
<$CodeSample
path="/user-management/ionic-react-user-management/src/pages/Login.tsx"
lines={[[1, -1]]}
meta="name=src/pages/Login.tsx"
/>
### Account page
After a user is signed in we can allow them to edit their profile details and manage their account.
After a user signs in, they should be able to edit their profile details and manage their account.
Let's create a new component for that called `Account.tsx`.
Create a new component for that called `Account.tsx`.
<$CodeTabs>
```jsx name=src/pages/Account.tsx
import {
IonButton,
IonContent,
IonHeader,
IonInput,
IonItem,
IonLabel,
IonPage,
IonTitle,
IonToolbar,
useIonLoading,
useIonToast,
useIonRouter
} from '@ionic/react';
import { useEffect, useState } from 'react';
import { supabase } from '../supabaseClient';
import { Session } from '@supabase/supabase-js';
export function AccountPage() {
const [showLoading, hideLoading] = useIonLoading();
const [showToast] = useIonToast();
const [session, setSession] = useState<Session | null>(null)
const router = useIonRouter();
const [profile, setProfile] = useState({
username: '',
website: '',
avatar_url: '',
});
useEffect(() => {
const getSession = async () => {
setSession(await supabase.auth.getSession().then((res) => res.data.session))
}
getSession()
supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
})
}, [])
useEffect(() => {
getProfile();
}, [session]);
const getProfile = async () => {
console.log('get');
await showLoading();
try {
const user = await supabase.auth.getUser();
const { data, error, status } = await supabase
.from('profiles')
.select(`username, website, avatar_url`)
.eq('id', user!.data.user?.id)
.single();
if (error && status !== 406) {
throw error;
}
if (data) {
setProfile({
username: data.username,
website: data.website,
avatar_url: data.avatar_url,
});
}
} catch (error: any) {
showToast({ message: error.message, duration: 5000 });
} finally {
await hideLoading();
}
};
const signOut = async () => {
await supabase.auth.signOut();
router.push('/', 'forward', 'replace');
}
const updateProfile = async (e?: any, avatar_url: string = '') => {
e?.preventDefault();
console.log('update ');
await showLoading();
try {
const user = await supabase.auth.getUser();
const updates = {
id: user!.data.user?.id,
...profile,
avatar_url: avatar_url,
updated_at: new Date(),
};
const { error } = await supabase.from('profiles').upsert(updates);
if (error) {
throw error;
}
} catch (error: any) {
showToast({ message: error.message, duration: 5000 });
} finally {
await hideLoading();
}
};
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Account</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<form onSubmit={updateProfile}>
<IonItem>
<IonLabel>
<p>Email</p>
<p>{session?.user?.email}</p>
</IonLabel>
</IonItem>
<IonItem>
<IonLabel position="stacked">Name</IonLabel>
<IonInput
type="text"
name="username"
value={profile.username}
onIonChange={(e) =>
setProfile({ ...profile, username: e.detail.value ?? '' })
}
></IonInput>
</IonItem>
<IonItem>
<IonLabel position="stacked">Website</IonLabel>
<IonInput
type="url"
name="website"
value={profile.website}
onIonChange={(e) =>
setProfile({ ...profile, website: e.detail.value ?? '' })
}
></IonInput>
</IonItem>
<div className="ion-text-center">
<IonButton fill="clear" type="submit">
Update Profile
</IonButton>
</div>
</form>
<div className="ion-text-center">
<IonButton fill="clear" onClick={signOut}>
Log Out
</IonButton>
</div>
</IonContent>
</IonPage>
);
}
```
</$CodeTabs>
<$CodeSample
path="/user-management/ionic-react-user-management/src/pages/Account.tsx"
lines={[[1, -1]]}
meta="name=src/pages/Account.tsx"
/>
### Launch!
Now that we have all the components in place, let's update `App.tsx`:
Now that you have all the components in place, update `App.tsx`:
<$CodeTabs>
```jsx name=src/App.tsx
import { Redirect, Route } from 'react-router-dom'
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react'
import { IonReactRouter } from '@ionic/react-router'
import { supabase } from './supabaseClient'
import '@ionic/react/css/ionic.bundle.css'
/* Theme variables */
import './theme/variables.css'
import { LoginPage } from './pages/Login'
import { AccountPage } from './pages/Account'
import { useEffect, useState } from 'react'
import { Session } from '@supabase/supabase-js'
setupIonicReact()
const App: React.FC = () => {
const [session, setSession] = useState<Session | null>(null)
useEffect(() => {
const getSession = async () => {
setSession(await supabase.auth.getSession().then((res) => res.data.session))
}
getSession()
supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
})
}, [])
return (
<IonApp>
<IonReactRouter>
<IonRouterOutlet>
<Route
exact
path="/"
render={() => {
return session ? <Redirect to="/account" /> : <LoginPage />
}}
/>
<Route exact path="/account">
<AccountPage />
</Route>
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
)
}
export default App
```
</$CodeTabs>
<$CodeSample
path="/user-management/ionic-react-user-management/src/App.tsx"
lines={[[1, -1]]}
meta="name=src/App.tsx"
/>
Once that's done, run this in a terminal window:
@@ -383,7 +94,7 @@ Once that's done, run this in a terminal window:
ionic serve
```
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
Then open your browser to the URL printed by `ionic serve` (by default, [http://localhost:8100](http://localhost:8100)) and you should see the completed app.
![Supabase Ionic React](/docs/img/ionic-demos/ionic-react.png)
@@ -403,142 +114,30 @@ npm install @ionic/pwa-elements @capacitor/camera
Ionic PWA elements is a companion package that will polyfill certain browser APIs that provide no user interface with custom Ionic UI.
With those packages installed we can update our `index.tsx` to include an additional bootstrapping call for the Ionic PWA Elements.
With those packages installed update `index.tsx` to include an additional bootstrapping call for the Ionic PWA Elements.
<$CodeTabs>
```ts name=src/index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import * as serviceWorkerRegistration from './serviceWorkerRegistration'
import reportWebVitals from './reportWebVitals'
import { defineCustomElements } from '@ionic/pwa-elements/loader'
defineCustomElements(window)
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
)
serviceWorkerRegistration.unregister()
reportWebVitals()
```
</$CodeTabs>
<$CodeSample
path="/user-management/ionic-react-user-management/src/index.tsx"
lines={[[1, -1]]}
meta="name=src/index.tsx"
/>
Then create an `AvatarComponent`.
<$CodeTabs>
```jsx name=src/components/Avatar.tsx
import { IonIcon } from '@ionic/react';
import { person } from 'ionicons/icons';
import { Camera, CameraResultType } from '@capacitor/camera';
import { useEffect, useState } from 'react';
import { supabase } from '../supabaseClient';
import './Avatar.css'
export function Avatar({
url,
onUpload,
}: {
url: string;
onUpload: (e: any, file: string) => Promise<void>;
}) {
const [avatarUrl, setAvatarUrl] = useState<string | undefined>();
useEffect(() => {
if (url) {
downloadImage(url);
}
}, [url]);
const uploadAvatar = async () => {
try {
const photo = await Camera.getPhoto({
resultType: CameraResultType.DataUrl,
});
const file = await fetch(photo.dataUrl!)
.then((res) => res.blob())
.then(
(blob) =>
new File([blob], 'my-file', { type: `image/${photo.format}` })
);
const fileName = `${Math.random()}-${new Date().getTime()}.${
photo.format
}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, file);
if (uploadError) {
throw uploadError;
}
onUpload(null, fileName);
} catch (error) {
console.log(error);
}
};
const downloadImage = async (path: string) => {
try {
const { data, error } = await supabase.storage
.from('avatars')
.download(path);
if (error) {
throw error;
}
const url = URL.createObjectURL(data!);
setAvatarUrl(url);
} catch (error: any) {
console.log('Error downloading image: ', error.message);
}
};
return (
<div className="avatar">
<div className="avatar_wrapper" onClick={uploadAvatar}>
{avatarUrl ? (
<img src={avatarUrl} />
) : (
<IonIcon icon={person} className="no-avatar" />
)}
</div>
</div>
);
}
```
</$CodeTabs>
<$CodeSample
path="/user-management/ionic-react-user-management/src/components/Avatar.tsx"
lines={[[1, -1]]}
meta="name=src/components/Avatar.tsx"
/>
### Add the new widget
And then we can add the widget to the Account page:
And then add the widget to the Account page:
<$CodeTabs>
```jsx name=src/pages/Account.tsx
// Import the new component
import { Avatar } from '../components/Avatar';
// ...
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Account</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<Avatar url={profile.avatar_url} onUpload={updateProfile}></Avatar>
```
</$CodeTabs>
<$CodeSample
path="/user-management/ionic-react-user-management/src/pages/Account.tsx"
lines={[[16, 16], [101, 110]]}
meta="name=src/pages/Account.tsx"
/>
At this stage you have a fully functional application!
@@ -0,0 +1,175 @@
---
title: 'Custom Email Templates'
description: 'Configure custom email templates with self-hosted Supabase instance'
subtitle: 'Configure custom email templates with self-hosted Supabase instance'
---
When running a self-hosted Supabase instance, you can fully customize emails sent by Supabase Auth.
## Overview
Supabase Auth does not read email templates from mounted Docker volumes. Instead, it expects each template to be available at a URL that returns a valid HTML template.
This URL:
- Does not need to be public
- Must be reachable from `auth` service
- Must return a valid Golang HTML template
To provide templates to Supabase Auth, you need a service that serves static HTML files. This can be any server of your choice. You can even use `kong` service which is included with the default Supabase docker configuration. The only requirement is that the `auth` service must be able to reach it via a HTTP GET request.
This guide uses [Caddy](https://github.com/caddyserver/caddy) for serving templates.
<Admonition type="tip">
If Supabase Auth cannot fetch the template or if the fetched template is invalid, it falls back to the default template.
</Admonition>
## Authentication email templates
Authentication email templates can be configured using the following environment variables:
- `GOTRUE_MAILER_TEMPLATES_<AUTH_FLOW>`: Provide a custom template URL. Falls back to the default template if not set.
- `GOTRUE_MAILER_SUBJECTS_<AUTH_FLOW>`: Customize the email subject. Falls back to the default subject if not set.
| Auth flow | Sent |
| ------------------ | -------------------------------------------------------------------- |
| `CONFIRMATION` | When a user signs up and needs to verify their email address |
| `RECOVERY` | When a user requests a password reset |
| `MAGIC_LINK` | When a user requests a magic link for password-less authentication |
| `INVITE` | When a user is invited to join your application via email invitation |
| `EMAIL_CHANGE` | When a user requests to change their email address |
| `REAUTHENTICATION` | When a user needs to re-authenticate for sensitive operations |
For example:
```sh
GOTRUE_MAILER_TEMPLATES_MAGIC_LINK='<template_url>'
GOTRUE_MAILER_SUBJECTS_MAGIC_LINK='<custom_subject>'
```
### Example
Below is an example configuration for setting up a custom invite template.
### Step 1: Create a templates directory
Create a `templates` directory inside the existing `volumes` directory and add your email templates to it.
Your directory structure should look like this:
```
volumes/
templates/
invite.html
```
### Step 2: Update `docker-compose.yml`
Update the `auth` service to depend on `templates-server`, and pass the email template environment variables. Then add a `templates-server` service to serve the templates from `./volumes/templates`.
```yml
services:
auth:
depends_on:
db:
condition: service_healthy
templates-server: # 👈 new dependency
condition: service_started
environment:
GOTRUE_MAILER_TEMPLATES_INVITE: 'http://templates-server/invite.html'
GOTRUE_MAILER_SUBJECTS_INVITE: 'You have been invited'
templates-server:
image: caddy:2-alpine
command: ['caddy', 'file-server', '-r', '/templates', '--listen', ':80']
volumes:
- ./volumes/templates:/templates
```
#### What this configuration does
- Adds a `templates-server`service that runs alongside the Supabase services in the same docker network.
- Serves your custom email template files from the `./volumes/templates` directory.
- Keeps the templates-server private to the Docker network (no published ports), so it is not accessible from outside.
- Allows the `auth` service to fetch templates via `http://templates-server/<template>.html`.
### Step 3: Restart containers
```sh
docker compose up -d --force-recreate --no-deps auth templates-server
```
## Notification email templates
Notification email templates can be configured using the following environment variables:
- `GOTRUE_MAILER_NOTIFICATIONS_<NOTIFICATION_TYPE>_ENABLED`: Enable the notification email
- `GOTRUE_MAILER_TEMPLATES_<NOTIFICATION_TYPE>_NOTIFICATION`: Provide a custom template URL. Falls back to the default template if not set.
- `GOTRUE_MAILER_SUBJECTS_<NOTIFICATION_TYPE>_NOTIFICATION`: Customize the email subject. Falls back to the default subject if not set.
| Notification Type | Sent |
| ----------------------- | ----------------------------------------------------- |
| `PASSWORD_CHANGED` | When a user's password is changed |
| `EMAIL_CHANGED` | When a user's email address is changed |
| `PHONE_CHANGED` | When a user's phone number is changed |
| `MFA_FACTOR_ENROLLED` | When a new MFA factor is added to the user's account |
| `MFA_FACTOR_UNENROLLED` | When an MFA factor is removed from the user's account |
| `IDENTITY_LINKED` | When a new identity is linked to the account |
| `IDENTITY_UNLINKED` | When an identity is unlinked from the account |
For example:
```sh
GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED='true'
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION='<template_url>'
GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION='<custom_subject>'
```
### Example
Below is an example configuration for setting up a custom password changed notification template.
### Step 1: Create the templates directory
This example reuses the directory created in **Auth Email Templates – Step 1** (`volumes/templates`). Add your notification email templates to this directory.
Your directory structure should look like this:
```
volumes/
templates/
invite.html
password_changed_notification.html
```
### Step 2: Update `docker-compose.yml`
Update the `auth` service in `docker-compose.yml` to enable password changed notification
```yml
services:
auth:
depends_on:
db:
condition: service_healthy
templates-server:
condition: service_started
environment:
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED: 'true' # 👈 enabling the notification is required
GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION: 'http://templates-server/password_changed_notification.html'
GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: 'Your password has been changed'
templates-server:
image: caddy:2-alpine
command: ['caddy', 'file-server', '-r', '/templates', '--listen', ':80']
volumes:
- ./volumes/templates:/templates
```
### Step 3: Restart containers
```sh
docker compose up -d --force-recreate --no-deps auth templates-server
```
@@ -446,7 +446,7 @@ See the [Configure Phone Login & MFA](/docs/guides/self-hosting/self-hosted-phon
Configuring the Supabase AI Assistant is optional. By adding **your own** `OPENAI_API_KEY` to `.env` you can enable AI services, which help with writing SQL queries, statements, and policies.
#### Setting database's `log_min_messages`
#### Setting log_min_messages in Postgres
By default, the database's `log_min_messages` configuration is set to `fatal` in [docker-compose.yml](https://github.com/supabase/supabase/blob/df8729a82b1847e2989c14ede27965612761d503/docker/docker-compose.yml#L466) to prevent redundant logs generated by Realtime. You can configure `log_min_messages` using any of the Postgres [Severity Levels](https://www.postgresql.org/docs/current/runtime-config-logging.html#RUNTIME-CONFIG-SEVERITY-LEVELS).
@@ -131,7 +131,7 @@ When `JWT_KEYS` is set, Auth will start signing new user session JWTs with the n
</Admonition>
## Rotating `sb_` API keys
## Rotating the new API keys
If your new API keys are compromised or you want to rotate them periodically, you can regenerate `sb_publishable` and `sb_secret` without touching the asymmetric key pair:
@@ -175,9 +175,9 @@ Regenerating asymmetric keys invalidates all ES256 user sessions. Plan a mainten
Below are a few notes on the details of the new authentication architecture.
### What `supabase-js` sends
### What client SDK sends
Every request includes two headers:
Every request via `supabase-js` includes two headers:
- `apikey` - the API key (`sb_` or legacy JWT)
- `Authorization` - when unauthenticated, the client SDK copies the API key here (`Bearer sb_publishable_xxx` or `Bearer eyJ...`). When authenticated, this contains the user session JWT minted by Auth.
@@ -24,7 +24,7 @@ This returns `"Hello from Edge Functions!"`.
## Create a new function
### Step 1: Create a new directory with an `index.ts` file in `volumes/functions/`:
### Step 1: Add a new function directory and the function code
```
mkdir -p volumes/functions/my-function &&
@@ -44,13 +44,13 @@ Deno.serve(async (req: Request) => {
})
```
### Step 2: Restart the functions service to pick up the new function:
### Step 2: Restart the functions service to pick up the new function
```bash
docker compose restart functions --no-deps
```
### Step 3: Invoke your function:
### Step 3: Invoke your function
```bash
curl -X POST http://<your-domain>:8000/functions/v1/my-function \
@@ -56,7 +56,7 @@ The default `.env.example` and `docker-compose.yml` include commented-out placeh
2. Set the **authorized redirect URL**, e.g., `https://<your-domain>/auth/v1/callback`
3. Copy the **client ID** and **client secret** into your `.env` file.
### Step 2: Configure variables in the `.env` file
### Step 2: Configure environment variables
Uncomment the lines for your provider in `.env` and add your client ID and secret, e.g., for Google:
@@ -66,7 +66,9 @@ GOOGLE_CLIENT_ID=your-client-id
GOOGLE_SECRET=your-client-secret
```
### Step 3: Enable the matching lines in `docker-compose.yml`
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Step 3: Enable the matching lines in Docker Compose configuration
Uncomment the corresponding `GOTRUE_EXTERNAL_` lines in the `auth` service's `environment`:
@@ -400,12 +402,12 @@ For detailed client-side integration, see [Social Login](/docs/guides/auth/socia
## Troubleshooting
### "Provider not enabled" or provider shows `false` in `/auth/v1/settings`
### "Provider not enabled" or provider seen as false in settings
- Check that `GOTRUE_EXTERNAL_*_ENABLED` is set to `true` in `docker-compose.yml`
- Verify the `.env` variable is not empty, e.g., check with `docker compose exec auth env | grep GOOGLE`
### Variables added to `.env` but provider still not working
### Variables added to the environment but provider still not working
Configuration variables from `.env` are **not** automatically available inside the container unless there's a matching passthrough definition in `docker-compose.yml`. Check, e.g., for:
@@ -415,7 +417,7 @@ GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED}
Run `docker compose exec auth env | grep GOTRUE_EXTERNAL` to verify the variables are reaching the container.
### `SITE_URL` or redirect URL errors after login
### Site URL or redirect URL errors after login
After a successful OAuth login, the Auth service redirects to `SITE_URL` or a URL from `ADDITIONAL_REDIRECT_URLS`. Ensure:
@@ -23,7 +23,7 @@ The default `.env.example` and `docker-compose.yml` include commented-out SMS pr
To enable SMS delivery:
### Step 1: Uncomment and configure the settings in `.env`
### Step 1: Uncomment and configure the environment variables
```
SMS_PROVIDER=twilio
@@ -38,7 +38,9 @@ SMS_TWILIO_AUTH_TOKEN=your-auth-token
SMS_TWILIO_MESSAGE_SERVICE_SID=your-message-service-sid
```
### Step 2: Uncomment the matching lines in `docker-compose.yml`
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Step 2: Uncomment the matching lines in Docker Compose configuration
Uncomment the `GOTRUE_SMS_*` lines in the `auth` service's `environment` block:
@@ -200,7 +202,7 @@ Common causes:
- Provider credentials are wrong
- Phone number format is wrong (use E.164 format: `+1234567890`)
### Variables are configured in `.env` but not working
### Variables added to the environment but not working
Configuration variables from `.env` are **not** automatically available inside the container unless there's a matching passthrough definition in `docker-compose.yml`. Check, e.g., for:
@@ -147,7 +147,7 @@ kong:
KONG_SSL_CERT_KEY: /home/kong/server.key
```
### Step 3: Update configuration variables in `.env`
### Step 3: Update configuration variables
Edit your `.env` file to use HTTPS with the Kong HTTPS port:
@@ -134,6 +134,40 @@ storage:
- Open Studio and upload a file to a bucket. List the file using the AWS CLI or `rclone` to confirm the S3 endpoint works.
- If using an S3 backend: confirm the file appears in your S3 provider's console.
## Session token
You can authenticate to Supabase's S3-compatible storage using a user’s JWT to enforce Row-Level Security (RLS) across S3 operations. This is useful when initializing the S3 client on the server for a specific user session, or when using the client directly from the frontend.
All operations performed with a session token are scoped to the authenticated user, and any RLS policies defined in the storage schema will be applied.
To authenticate with S3 using a session token, provide the following credentials:
- **region:** value from the `REGION` environment variable in your `.env` file
- **access_key_id:** value from the `STORAGE_TENANT_ID` environment variable in your `.env` file
- **secret_access_key:** value from the `ANON_KEY` environment variable
- **session_token:** a valid user JWT
Example using the `aws-sdk` library:
```javascript
import { S3Client } from '@aws-sdk/client-s3'
const {
data: { session },
} = await supabase.auth.getSession()
const client = new S3Client({
forcePathStyle: true,
region: 'stub', // REGION in .env
endpoint: 'http://<your-domain>/storage/v1/s3', // Edit <your-domain>
credentials: {
accessKeyId: 'stub', // STORAGE_TENANT_ID in .env
secretAccessKey: 'your-anon-key', // ANON_KEY in .env
sessionToken: session.access_token,
},
})
```
## Troubleshooting
### Signature mismatch errors
@@ -108,3 +108,9 @@ const client = new S3Client({
},
})
```
<Admonition type="note">
On self-hosted Supabase, the `accessKeyId` is the `STORAGE_TENANT_ID` environment variable defined in the `.env` file. Refer to the [self-hosted S3 guide](/docs/guides/self-hosting/self-hosted-s3#session-token) for more details.
</Admonition>
@@ -95,6 +95,16 @@ response = supabase.storage.from_('bucket_name').upload('file_path', file)
</TabPanel>
</$Show>
<TabPanel id="curl" label="cURL">
```bash
curl -X POST "https://{your_project_ref}.supabase.co/storage/v1/object/{bucket_name}/{file_path}" \
-H "apikey: {your_anon_key}" \
-H "Authorization: Bearer {your_jwt_token}" \
--data-binary "@/local/path/to/your/file.ext"
```
</TabPanel>
</Tabs>
## Overwriting files
@@ -181,6 +191,17 @@ response = supabase.storage.from_('bucket_name').upload('file_path', file, {
</TabPanel>
</$Show>
<TabPanel id="curl" label="cURL">
```bash
curl -X POST "https://{your_project_ref}.supabase.co/storage/v1/object/{bucket_name}/{file_path}" \
-H "apikey: {your_anon_key}" \
-H "Authorization: Bearer {your_jwt_token}" \
-H "x-upsert: true" \
--data-binary "@/local/path/to/your/file.ext"
```
</TabPanel>
</Tabs>
We do advise against overwriting files when possible, as our Content Delivery Network will take sometime to propagate the changes to all the edge nodes leading to stale content.
@@ -269,6 +290,17 @@ response = supabase.storage.from_('bucket_name').upload('file_path', file, {
</TabPanel>
</$Show>
<TabPanel id="curl" label="cURL">
```bash
curl -X POST "https://{your_project_ref}.supabase.co/storage/v1/object/{bucket_name}/{file_path}" \
-H "apikey: {your_anon_key}" \
-H "Authorization: Bearer {your_jwt_token}" \
-H "Content-Type: {Content-Type}" \
--data-binary "@/local/path/to/your/file.ext"
```
</TabPanel>
</Tabs>
## Concurrency
@@ -51,7 +51,7 @@ export async function createClient() {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
setAll(cookiesToSet, _headers) {
try {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
} catch {
@@ -84,12 +84,15 @@ export async function updateSession(request: NextRequest) {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) => supabaseResponse.cookies.set(name, value, options))
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
)
},
},
}
Loaded 100 of 571 files, more files were not shown because too many files have changed in this diff. Show more