mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore: make agent instructions agent-agnostic (#49941)
Makes the repo's AI-agent setup tool-agnostic: instructions live in
`AGENTS.md` files, skills live in `.agents/skills/`, and Claude Code,
Codex, Cursor, and Copilot all read the same sources. Also sweeps the
skills for stale and duplicated content while everything was being
moved.
**Changed:**
- Every `CLAUDE.md` (root, `apps/studio`, `apps/docs`, `apps/kb`) is now
a one-line `@AGENTS.md` import; the content moved verbatim into an
`AGENTS.md` beside it. The root one moved from `.claude/CLAUDE.md` to
the repo root for consistency.
- All skills now live in `.agents/skills/`; `.claude/skills` is a single
symlink to it (replacing the old mix of real dirs and per-skill
symlinks). Path references in `.coderabbit.yaml`, code comments, and
docs updated to match.
- `.github/copilot-instructions.md` keeps only the review policy and
points at `AGENTS.md` + `.agents/skills/`. Copilot code review reads
those natively now, so the per-topic
`.github/instructions/*.instructions.md` files were duplicates of the
skills.
- Stale skill content fixed: `studio-queries` imported a toast library
Studio doesn't use, `telemetry-standards` and `studio-testing` used
import paths that don't resolve, `safe-sql-execution` cited a boundary
test that doesn't exist, the ask-the-docs references described an
`AiPrompt` mechanism that was replaced by the ID-keyed registry, plus a
handful of wrong paths, a self-contradicting `waitForTimeout` rule, an
invalid Playwright signature, and a ConfigCat flag described as PostHog.
- `studio-error-handling` now explains when to use `AlertError` (the
default) vs `ErrorMatcher`.
**Added:**
- `apps/docs/AGENTS.md` (docs test requirements, from the old Cursor
rule)
- `studio-shortcuts` skill (from the old Copilot instruction file,
verified against the current registry)
- `ask-the-docs/reference/graphql-endpoint.md` and
`search-embeddings.md` (from the old Cursor rules, with the missing
resolver/registration/codegen steps filled in)
- Feature-flag measurement section in `telemetry-standards`
**Removed:**
- `.cursor/` (rules folded in as above; skill symlinks no longer needed)
and `.cursorignore`
- `.github/instructions/` (8 files)
- `vercel-composition-patterns/AGENTS.md` – a 946-line verbatim
concatenation of its own `rules/` directory, and a nested `AGENTS.md`
that agents could auto-load as repo instructions
- `edit-the-docs/reference/structure-and-flow.md` – word-for-word copy
of the skill's own Phase 2 text
## To test
- `readlink .claude/skills` → `../.agents/skills`, and `ls
.claude/skills/copywriting/SKILL.md` resolves
- Open a Claude Code session at the repo root and in `apps/studio` – the
imported `AGENTS.md` content should load as before
- `git diff master --stat -M` shows the skill moves as 100% renames
(content unchanged except the listed fixes)
- Spot-check a fixed claim, e.g. `import { toast } from 'sonner'` in
`studio-queries`, or the `logs.all` ESLint rule cited in
`clickhouse-logs-queries/references/codebase-integration.md`
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **Documentation**
- Expanded guidance for documentation workflows, GraphQL resources,
search, ClickHouse logs, React forms, Studio testing, shortcuts,
telemetry, accessibility, copywriting, and composition patterns.
- Clarified local testing, linting, build workflows, error handling, and
AI coding agent usage.
- Added contributor guidance for the knowledge base, documentation, and
Studio areas.
- **Chores**
- Consolidated agent instructions and skill references.
- Removed obsolete editor-specific guidance, duplicate links, and
superseded documentation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
1 parent
6181e27b93
commit
f125126aec
75 files changed
+656
-1805
No files matched your search
@@ -1,72 +0,0 @@
|
||||
# Supabase Monorepo
|
||||
|
||||
pnpm 11 + Turborepo monorepo. Requires Node >= 22.13.
|
||||
|
||||
## Structure
|
||||
|
||||
| Directory | Purpose |
|
||||
| ------------------------ | --------------------------------------------------------------------------- |
|
||||
| `apps/studio` | Supabase Studio/Dashboard — has its own `apps/studio/CLAUDE.md` (see below) |
|
||||
| `apps/docs` | Documentation site — Next.js app router, MDX (port 3001) |
|
||||
| `apps/www` | Marketing website — Next.js, app + pages (port 3000) |
|
||||
| `apps/design-system` | Component demos — source of truth for Studio UI patterns (port 3003) |
|
||||
| `apps/ui-library` | shadcn-style registry site for Supabase UI blocks (port 3004) |
|
||||
| `apps/lite-studio` | Lightweight Studio — different stack: React Router 7 + Vite + Tailwind v4 |
|
||||
| `packages/ui` | Shared UI components (shadcn/ui based) — `import { Button } from 'ui'` |
|
||||
| `packages/ui-patterns` | Composite components — subpath imports, e.g. `ui-patterns/AssistantChat` |
|
||||
| `packages/common` | Shared utils, telemetry constants, feature flags |
|
||||
| `packages/api-types` | Generated platform Management API types |
|
||||
| `packages/pg-meta` | SQL builders for Postgres introspection (`SafeSqlFragment`) |
|
||||
| `packages/shared-data` | Static data: pricing, plans, regions, error codes |
|
||||
| `e2e/studio`, `e2e/docs` | Playwright E2E tests |
|
||||
| `supabase/` | Local Supabase project: edge functions, migrations, config.toml |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
pnpm dev:studio # run Studio dev server → http://localhost:8082
|
||||
pnpm dev:docs # run docs dev server
|
||||
pnpm dev:www # run www dev server
|
||||
pnpm test:studio # Studio unit tests (vitest)
|
||||
pnpm e2e # Studio E2E tests (playwright)
|
||||
pnpm build --filter=studio # build Studio
|
||||
pnpm lint --filter=studio # lint Studio
|
||||
pnpm typecheck # typecheck all packages
|
||||
pnpm format # Prettier write (check: pnpm test:prettier)
|
||||
pnpm generate:types # local DB types → supabase/functions/common/database-types.ts
|
||||
pnpm api:codegen # platform Management API types → packages/api-types
|
||||
```
|
||||
|
||||
## CI
|
||||
|
||||
Every PR must pass typecheck + lint (one workflow), Prettier, and a typos check. Other checks are path-filtered: Studio unit tests/build and the lint ratchet (ESLint warning count must not increase) run on `apps/studio/**` changes; app-specific test suites run on their own paths.
|
||||
|
||||
Never hand-edit generated files: `packages/api-types/types/**`, `**/routeTree.gen.ts`, `**/__generated__/**`, `apps/docs/features/docs/generated/**`, `apps/www/.generated/**`, `supabase/functions/common/database-types.ts`, `apps/docs/content/_partials/access-control/scoped_pat_*.mdx` (run `make -C apps/docs/spec generate.partials.access-control`).
|
||||
|
||||
## Conventions
|
||||
|
||||
**UI** — import from `'ui'`; primitives are shadcn/ui-based and exported unsuffixed (`Input`, `Select`, `Form`, …). Use `Button` — the in-house component and the standard everywhere (a raw shadcn `Button_Shadcn_` also exists but is rarely the right choice). Check `packages/ui/index.tsx` before creating new primitives. Higher-level patterns live in `packages/ui-patterns`.
|
||||
|
||||
**Styling** — Tailwind only, semantic tokens (`bg-muted`, `text-foreground-light`), no hardcoded colors.
|
||||
|
||||
**Exports** — named exports only; default exports are allowed only where a framework requires them (`pages/**`, `app/**`, config files — the eslint preset has the exact carve-out list). Lint-enforced across all apps via `eslint-config-supabase` (severity `warn` everywhere; hard-enforced in Studio by the lint ratchet).
|
||||
|
||||
**Language** — Use U.S. English everywhere.
|
||||
|
||||
**Public surfaces** — this repo is public: PR descriptions, issues, and code comments are world-readable. Keep internal content out of them: absolute production metrics (event counts, user counts, revenue figures: state percentages, ratios, or relative change instead), internal decision detail (vendor, legal, pricing, or strategy discussions), and competitor names (protocol identifiers such as user-agent strings are fine). Put that context in the Linear issue and link it.
|
||||
|
||||
## Skills
|
||||
|
||||
The skills in `.claude/skills/` are the source of truth for conventions — load the relevant ones before working, don't guess:
|
||||
|
||||
- `copywriting` — any user-facing text, anywhere in the monorepo
|
||||
- `pm-the-docs` / `write-the-docs` / `edit-the-docs` / `ask-the-docs` / `review-the-docs` — anything under `apps/docs` (see `apps/docs/CONTRIBUTING.md` for the authoring skill model)
|
||||
- `telemetry-standards` — PostHog events, `packages/common/telemetry-constants.ts`
|
||||
- `dev-toolbar-review` — `packages/dev-tools`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx`
|
||||
- `safe-sql-execution` — any code that builds or executes SQL against user databases
|
||||
- `react-hook-form` — writing or modifying any form code, anywhere in the monorepo
|
||||
- `vitest` / `vercel-composition-patterns` — generic unit-testing and React composition references
|
||||
|
||||
## Studio
|
||||
|
||||
Before working on anything in `apps/studio`, read `apps/studio/CLAUDE.md` if it isn't already in context — it maps Studio tasks to required skills and covers the TanStack Start migration rules.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/ask-the-docs
|
||||
@@ -1,226 +0,0 @@
|
||||
---
|
||||
name: clickhouse-logs-queries
|
||||
description: >-
|
||||
Write, review, and migrate Supabase logs queries against the ClickHouse-backed
|
||||
`logs` table (the `logs.all.otel` analytics endpoint). Use this whenever a task
|
||||
involves Logs Explorer SQL, the `log_attributes` map, querying a log `source`
|
||||
(edge_logs, postgres_logs, auth_logs, etc.), translating an old BigQuery
|
||||
`cross join unnest(metadata)` logs query to ClickHouse, or wiring analytics log
|
||||
SQL in `apps/studio/data/logs` and `apps/studio/components/interfaces/Settings/Logs`.
|
||||
Reach for it even when the user just says "logs query", "Logs Explorer", or
|
||||
pastes a BigQuery logs query to convert, not only when they name ClickHouse.
|
||||
---
|
||||
|
||||
# Querying Supabase logs (ClickHouse)
|
||||
|
||||
Supabase logs live in a single ClickHouse `logs` table, served by the
|
||||
`logs.all.otel` analytics endpoint. Every log line from every part of the stack
|
||||
is one row in this table, tagged by a `source` column. This replaces the older
|
||||
BigQuery model, where each service had its own table and fields were reached
|
||||
through `cross join unnest(metadata)`.
|
||||
|
||||
Two kinds of work use this skill, and they share the same SQL model:
|
||||
|
||||
1. **Writing or reviewing a logs query** (in the Logs Explorer or anywhere a raw
|
||||
ClickHouse logs query is needed). Start here in this file.
|
||||
2. **Wiring a logs query in the Studio codebase** (branded analytics SQL, the
|
||||
endpoint picker, the OTEL query builders). Read
|
||||
[references/codebase-integration.md](references/codebase-integration.md).
|
||||
|
||||
If you are converting an existing BigQuery logs query, read
|
||||
[references/bigquery-migration.md](references/bigquery-migration.md) for the full
|
||||
translation table.
|
||||
|
||||
## The logs table
|
||||
|
||||
Each row has a small set of real columns. Everything specific to a service lives
|
||||
in `log_attributes`.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ---------------- | --------------------- | ----------------------------------------------------- |
|
||||
| `id` | `String` | Unique log identifier. |
|
||||
| `timestamp` | `DateTime64` (UTC) | When the log was produced. Order/compare it directly. |
|
||||
| `event_message` | `String` | The raw log line. |
|
||||
| `severity_text` | `String` | Log level, when the source sets one. |
|
||||
| `source` | `String` | The service the log came from. Always filter on this. |
|
||||
| `log_attributes` | `Map(String, String)` | Structured per-source fields, keyed by a dotted path. |
|
||||
|
||||
`timestamp` is formatted like `2026-06-22T09:34:06.215000` (ISO 8601, microsecond
|
||||
precision, no trailing `Z`). In the Logs Explorer the selected time range is
|
||||
applied for you, so you rarely need to write a `timestamp` filter by hand.
|
||||
|
||||
A minimal, well-formed query. Lead with a comment naming the query, filter by
|
||||
`source`, and always `limit`:
|
||||
|
||||
```sql
|
||||
-- recent edge requests
|
||||
select timestamp, event_message
|
||||
from logs
|
||||
where source = 'edge_logs'
|
||||
order by timestamp desc
|
||||
limit 100;
|
||||
```
|
||||
|
||||
## Sources
|
||||
|
||||
`source` selects the service. The common ones:
|
||||
|
||||
- `edge_logs` — API gateway requests and responses
|
||||
- `postgres_logs` — database statements and errors (also where pg_cron logs live)
|
||||
- `auth_logs` — authentication and authorization activity
|
||||
- `function_edge_logs` — edge function requests and responses
|
||||
- `function_logs` — `console` output from inside edge functions
|
||||
- `storage_logs` — object upload and retrieval activity
|
||||
- `realtime_logs` — Realtime client connections
|
||||
- `postgrest_logs`, `supavisor_logs`, `pgbouncer_logs` — mostly `id`, `timestamp`, `event_message`
|
||||
|
||||
The Logs Explorer **Field Reference** drawer lists every source and the fields it
|
||||
actually sets. When in doubt about a key, discover it from real data rather than
|
||||
guessing (see below).
|
||||
|
||||
## Reading fields from log_attributes
|
||||
|
||||
`log_attributes` maps a string key to a string value. Read a field with bracket
|
||||
access. There are no unnesting joins:
|
||||
|
||||
```sql
|
||||
select
|
||||
log_attributes['request.method'] as method,
|
||||
log_attributes['request.path'] as path,
|
||||
log_attributes['response.status_code'] as status
|
||||
from logs
|
||||
where source = 'edge_logs'
|
||||
```
|
||||
|
||||
The key keeps the dotted path that BigQuery expressed through nested structs, with
|
||||
the `metadata` root dropped: BigQuery `metadata.request.method` becomes
|
||||
`log_attributes['request.method']`. Keep the full prefix — `request.cf.country`
|
||||
is `log_attributes['request.cf.country']`, not `log_attributes['cf.country']`.
|
||||
|
||||
Common keys by source:
|
||||
|
||||
- `edge_logs`: `request.method`, `request.path`, `request.search`, `response.status_code`, `identifier`
|
||||
- `postgres_logs`: `parsed.error_severity`, `parsed.detail`, `parsed.hint`, `parsed.query`, `identifier`
|
||||
- `auth_logs`: `level`, `status`, `path`, `msg`, `error`
|
||||
- `function_edge_logs`: `response.status_code`, `request.method`, `request.pathname`, `function_id`, `execution_id`, `execution_time_ms`
|
||||
- `function_logs`: `event_type`, `function_id`, `execution_id`, `level`
|
||||
|
||||
### Numeric fields are strings
|
||||
|
||||
Map values are always strings. To compare or aggregate a numeric field, wrap it in
|
||||
`toInt32OrZero`, which returns `0` for missing or non-numeric values so it never
|
||||
errors on partial data:
|
||||
|
||||
```sql
|
||||
select count() as server_errors
|
||||
from logs
|
||||
where source = 'edge_logs'
|
||||
and toInt32OrZero(log_attributes['response.status_code']) between 500 and 599
|
||||
```
|
||||
|
||||
### Discover the keys a source sets
|
||||
|
||||
Read `mapKeys` from recent rows rather than guessing key names:
|
||||
|
||||
```sql
|
||||
select arrayJoin(mapKeys(log_attributes)) as key, count() as n
|
||||
from logs
|
||||
where source = 'postgres_logs'
|
||||
group by key
|
||||
order by n desc
|
||||
limit 100;
|
||||
```
|
||||
|
||||
`arrayJoin(mapKeys(...))` flattens the map keys into one row per key so you can
|
||||
rank them by frequency. (The Studio codebase does exactly this for the Field
|
||||
Reference drawer and to feed real keys to the AI rewrite.)
|
||||
|
||||
## ClickHouse vs BigQuery functions
|
||||
|
||||
These are the substitutions that trip people up most:
|
||||
|
||||
| Need | BigQuery | ClickHouse |
|
||||
| ------------------ | ----------------------------- | -------------------------------------------- |
|
||||
| Count rows | `count(*)` | `count()` |
|
||||
| Regex match | `regexp_contains(x, 'p')` | `match(x, 'p')` |
|
||||
| Substring match | `x like '%p%'` | `x ilike '%p%'` (case-insensitive) or `like` |
|
||||
| Numeric coercion | `cast(x as int64)` | `toInt32OrZero(x)` |
|
||||
| Read the timestamp | `cast(timestamp as datetime)` | `timestamp` (use the column directly) |
|
||||
| Map keys | n/a (used unnest) | `mapKeys(log_attributes)` |
|
||||
|
||||
The `logs.all.otel` analytics endpoint (and the Logs Explorer on top of it)
|
||||
rejects `count(*)` and `select *` — use `count()` and list the columns you need.
|
||||
(Raw ClickHouse supports both; this is a constraint of the logs query surface.)
|
||||
|
||||
## Best practices
|
||||
|
||||
These keep queries correct and cheap. Log tables are large; an unbounded scan
|
||||
reads far more data than you need.
|
||||
|
||||
- **Start every query with an identifying comment** (e.g. `-- errors since last deploy`). It labels the query in logs and review, and makes each of several queries in a file easy to tell apart.
|
||||
- **Always include a `LIMIT`.** Even for aggregates while you iterate.
|
||||
- **Always query `from logs where source = '...'`.** There is no per-service table (no `edge_logs`, `postgres_logs`, etc. table) — there is one `logs` table, and `source` scopes it to a service. Filtering by `source` is required, not just an optimization.
|
||||
- **Keep the time range tight.** A smaller window returns results faster.
|
||||
- **Filter on the real columns** (`source`, `timestamp`) before reaching into
|
||||
`log_attributes`.
|
||||
- **Order by `timestamp desc`** to see the most recent logs first.
|
||||
- **Use `count()`**, not `count(*)` or `select *`.
|
||||
|
||||
## Worked examples
|
||||
|
||||
Requests by status code:
|
||||
|
||||
```sql
|
||||
select
|
||||
toInt32OrZero(log_attributes['response.status_code']) as status,
|
||||
count() as count
|
||||
from logs
|
||||
where source = 'edge_logs'
|
||||
group by status
|
||||
order by count desc
|
||||
limit 50
|
||||
```
|
||||
|
||||
Auth errors:
|
||||
|
||||
```sql
|
||||
select timestamp, event_message, log_attributes['msg'] as message
|
||||
from logs
|
||||
where source = 'auth_logs'
|
||||
and log_attributes['level'] in ('error', 'fatal')
|
||||
order by timestamp desc
|
||||
limit 100
|
||||
```
|
||||
|
||||
Search the raw message:
|
||||
|
||||
```sql
|
||||
select timestamp, event_message
|
||||
from logs
|
||||
where source = 'postgres_logs'
|
||||
and event_message ilike '%deadlock%'
|
||||
order by timestamp desc
|
||||
limit 100
|
||||
```
|
||||
|
||||
Postgres errors grouped by severity (the canonical unnest-to-map conversion):
|
||||
|
||||
```sql
|
||||
select log_attributes['parsed.error_severity'] as severity, count() as count
|
||||
from logs
|
||||
where source = 'postgres_logs'
|
||||
and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC')
|
||||
group by severity
|
||||
order by count desc
|
||||
limit 100
|
||||
```
|
||||
|
||||
## When the user pastes a BigQuery query
|
||||
|
||||
Convert it rather than running it as-is. The mechanical steps (drop the
|
||||
per-service table for `from logs where source = ...`, remove every
|
||||
`cross join unnest(...)`, rewrite unnest-alias columns as `log_attributes['...']`
|
||||
lookups, swap the functions above) are spelled out with a full before/after in
|
||||
[references/bigquery-migration.md](references/bigquery-migration.md). The Logs
|
||||
Explorer also has a built-in **Rewrite to ClickHouse** action that does this with
|
||||
AI; point users to it for one-off conversions in the dashboard.
|
||||
@@ -1,93 +0,0 @@
|
||||
# Migrating a BigQuery logs query to ClickHouse
|
||||
|
||||
The old logs engine gave each service its own table and exposed structured fields
|
||||
through repeated `cross join unnest(...)` over a nested `metadata` column. The
|
||||
ClickHouse engine has one `logs` table and a flat `log_attributes` map. Conversion
|
||||
is mechanical once you internalize the mapping.
|
||||
|
||||
## The five steps
|
||||
|
||||
1. **Replace the table with `logs` plus a `source` filter.** The old table name is
|
||||
the `source` value: `from postgres_logs as t` becomes
|
||||
`from logs where source = 'postgres_logs'`. Never select from a per-service
|
||||
table name (`postgres_logs`, `edge_logs`, ...) on the ClickHouse engine.
|
||||
- Exception: pg_cron logs live under `source = 'postgres_logs'`.
|
||||
|
||||
2. **Remove every unnest join.** Delete `cross join unnest(metadata) as m`,
|
||||
`cross join unnest(m.parsed) as p`, `left join unnest(...) on true`, and so on.
|
||||
They have no equivalent — the data is already flat in the map.
|
||||
|
||||
3. **Rewrite every unnest-alias column as a map lookup.** A field taken off
|
||||
`unnest(metadata)` becomes `log_attributes['field']`. A field off a nested
|
||||
struct like `unnest(m.parsed)` becomes `log_attributes['parsed.field']`: keep
|
||||
the struct name as a dotted prefix, drop the `metadata` root and every alias.
|
||||
|
||||
4. **Wrap numeric fields in `toInt32OrZero(...)`** before comparing or aggregating
|
||||
them — map values are strings.
|
||||
|
||||
5. **Swap BigQuery functions for ClickHouse ones** (see the table below).
|
||||
|
||||
Preserve the original select list, filters, group by, order by, and limit intent
|
||||
throughout.
|
||||
|
||||
## Function substitutions
|
||||
|
||||
| Need | BigQuery | ClickHouse |
|
||||
| ---------------- | ----------------------------- | --------------------------- |
|
||||
| Count rows | `count(*)` | `count()` |
|
||||
| Regex match | `regexp_contains(x, 'p')` | `match(x, 'p')` |
|
||||
| Substring match | `x like '%p%'` | `x ilike '%p%'` or `like` |
|
||||
| Numeric coercion | `cast(x as int64)` | `toInt32OrZero(x)` |
|
||||
| Timestamp value | `cast(timestamp as datetime)` | `timestamp` |
|
||||
| All columns | `select *` | list the columns explicitly |
|
||||
|
||||
## Full before/after
|
||||
|
||||
BigQuery:
|
||||
|
||||
```sql
|
||||
select count(t.timestamp) as count, p.error_severity
|
||||
from
|
||||
postgres_logs as t
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.parsed) as p
|
||||
where p.error_severity in ('ERROR', 'FATAL', 'PANIC')
|
||||
group by p.error_severity
|
||||
order by count desc
|
||||
limit 100;
|
||||
```
|
||||
|
||||
ClickHouse:
|
||||
|
||||
```sql
|
||||
select count() as count, log_attributes['parsed.error_severity'] as error_severity
|
||||
from logs
|
||||
where source = 'postgres_logs'
|
||||
and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC')
|
||||
group by log_attributes['parsed.error_severity']
|
||||
order by count desc
|
||||
limit 100
|
||||
```
|
||||
|
||||
Notice: `count(t.timestamp)` became `count()`, the two unnest joins are gone,
|
||||
`p.error_severity` became `log_attributes['parsed.error_severity']`, and the
|
||||
`from`/`where` targets the single table.
|
||||
|
||||
## Getting the keys right
|
||||
|
||||
The most common conversion mistake is dropping a dotted prefix — writing
|
||||
`log_attributes['x_real_ip']` when the real key is
|
||||
`log_attributes['request.headers.x_real_ip']`, or `log_attributes['cf.country']`
|
||||
instead of `log_attributes['request.cf.country']`. Do not invent or shorten keys.
|
||||
When unsure, discover the real keys from data:
|
||||
|
||||
```sql
|
||||
select arrayJoin(mapKeys(log_attributes)) as key, count() as n
|
||||
from logs
|
||||
where source = 'edge_logs'
|
||||
group by key
|
||||
order by n desc
|
||||
limit 200;
|
||||
```
|
||||
|
||||
Then map each old nested path to the matching key exactly as it appears.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Wiring a logs query in the Studio codebase
|
||||
|
||||
This covers writing analytics log SQL inside `apps/studio` so it is safe, routed
|
||||
to the right endpoint, and consistent with the existing OTEL builders. Read this
|
||||
when you are editing TypeScript that builds or runs a logs query, not when you are
|
||||
just writing a query in the Logs Explorer UI.
|
||||
|
||||
## Branded SQL: never concatenate user input
|
||||
|
||||
All analytics log SQL must be a `SafeLogSqlFragment`, built with the helpers in
|
||||
`apps/studio/data/logs/safe-analytics-sql.ts`. This is enforced by eslint, and the
|
||||
branding is what keeps interpolated values from becoming injection. The key
|
||||
exports:
|
||||
|
||||
- `safeSql\`...\``— a tagged template that only accepts`SafeLogSqlFragment`interpolations. Plain strings (and Postgres-branded`SafeSqlFragment`) are
|
||||
rejected at compile time, so you cannot accidentally drop a raw value in.
|
||||
- `analyticsLiteral(value)` — turns a string/number/boolean into a safely escaped
|
||||
literal fragment (single quotes and backslashes are escaped). Use it for every
|
||||
dynamic value, especially the `source`.
|
||||
- `joinSqlFragments(fragments, separator)` — joins already-safe fragments with a
|
||||
fixed structural separator (`' and '`, `', '`, etc.).
|
||||
- `keyword(value, allowed)` — resolves a value against a compile-time allow-list of
|
||||
fragments (e.g. an `AND`/`OR` operator). Returns the allow-listed fragment, never
|
||||
the raw input.
|
||||
- `quotedIdent(value)` — backtick-quotes a dotted identifier path after validating
|
||||
each segment.
|
||||
|
||||
There is intentionally no exported "raw" escape hatch. Compose with `safeSql` plus
|
||||
these helpers.
|
||||
|
||||
```ts
|
||||
import { analyticsLiteral, safeSql } from 'data/logs/safe-analytics-sql'
|
||||
|
||||
const source = 'edge_logs'
|
||||
const sql = safeSql`
|
||||
select timestamp, event_message
|
||||
from logs
|
||||
where source = ${analyticsLiteral(source)}
|
||||
order by timestamp desc
|
||||
limit 100
|
||||
`
|
||||
```
|
||||
|
||||
## Pick the endpoint and builder by flag
|
||||
|
||||
The ClickHouse path is gated by the `otelLegacyLogs` PostHog flag
|
||||
(`useFlag('otelLegacyLogs')` from `common`). Keep the BigQuery path working when
|
||||
the flag is off. Two helpers in `apps/studio/data/logs/logs-endpoint.ts` express
|
||||
the split:
|
||||
|
||||
- `logsAllEndpointUrl(useOtel)` — returns the `logs.all.otel` endpoint when
|
||||
`useOtel`, otherwise the legacy `logs.all` endpoint.
|
||||
- `pickLogsQueryBuilder(useOtel, otel, bq)` — picks between an OTEL builder and a
|
||||
BigQuery builder while preserving the type.
|
||||
|
||||
```ts
|
||||
const useOtel = useFlag('otelLegacyLogs')
|
||||
const builder = pickLogsQueryBuilder(useOtel, genDefaultQueryOtel, genDefaultQuery)
|
||||
const endpoint = logsAllEndpointUrl(useOtel)
|
||||
// include { otel: useOtel } in the React Query key so the two paths cache separately
|
||||
```
|
||||
|
||||
Run the fragment through `executeAnalyticsSql` (`apps/studio/data/logs/execute-analytics-sql.ts`)
|
||||
against that endpoint.
|
||||
|
||||
## Follow the existing OTEL builders
|
||||
|
||||
When you need a new query shape, mirror the generators in
|
||||
`apps/studio/components/interfaces/Settings/Logs/Logs.utils.otel.ts` rather than
|
||||
inventing a parallel style. They already encode the conventions:
|
||||
|
||||
- `genDefaultQueryOtel`, `genCountQueryOtel`, `genChartQueryOtel`,
|
||||
`genSingleLogQueryOtel` — the row/count/chart/single-log builders. They select
|
||||
the real columns plus source-specific `log_attributes[...]` lookups aliased to
|
||||
the leaf names the renderers expect.
|
||||
- `mapOtelPreviewRow`, `mapOtelSingleLogToLegacy`, `otelTimestampToMicros` — the
|
||||
JS normalization layer. `timestamp` must end up a microsecond number for the
|
||||
pagination cursor and the renderers, so reuse `otelTimestampToMicros` rather
|
||||
than parsing the ISO string yourself.
|
||||
|
||||
The map-key discovery hook (`apps/studio/data/logs/otel-log-keys-query.ts`,
|
||||
`fetchOtelLogKeys` / `useOtelLogKeysQuery`) is the canonical example of a small,
|
||||
correctly-branded OTEL query — read it before writing a new one.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Every dynamic value goes through `analyticsLiteral` (or another sanitizer),
|
||||
never string concatenation.
|
||||
- [ ] The query filters by `source` and includes a `LIMIT`.
|
||||
- [ ] Numeric `log_attributes` values are wrapped in `toInt32OrZero`.
|
||||
- [ ] The endpoint and builder are chosen with `logsAllEndpointUrl` /
|
||||
`pickLogsQueryBuilder` off `useFlag('otelLegacyLogs')`.
|
||||
- [ ] The React Query key distinguishes the OTEL and BigQuery paths.
|
||||
- [ ] `timestamp` is normalized to micros for any row consumed by the table/cursor.
|
||||
- [ ] There is a unit test asserting the generated SQL string (see
|
||||
`Logs.utils.otel.test.ts` and `safe-analytics-sql.test.ts` for the pattern).
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
name: copywriting
|
||||
description: Write or audit UI copy (buttons, labels, empty states, error messages, tooltips, form text) anywhere in the monorepo. Load it before shipping or reviewing any user-facing text — including when copy is incidental to the task, like a new feature that adds buttons, toasts, dialogs, or validation messages.
|
||||
---
|
||||
|
||||
# Copywriting
|
||||
|
||||
Source of truth: `apps/design-system/content/docs/copywriting.mdx`. Read it before writing or auditing any UI copy — it covers voice and tone, buttons, error messages, empty states, and confirmation dialogs with good/bad examples.
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
name: dev-toolbar-review
|
||||
description: Safety rules for the dev toolbar, PostHog client, and feature flags. Use
|
||||
when writing or reviewing any change to packages/dev-tools/, packages/common/posthog-client.ts,
|
||||
or packages/common/feature-flags.tsx. Covers environment guards, flag override cookies,
|
||||
telemetry event subscription, and SSE stream safety.
|
||||
---
|
||||
|
||||
# Dev Toolbar Review Guide
|
||||
|
||||
Review checklist for PRs touching the dev toolbar (`packages/dev-tools/`) and its
|
||||
integration points in `packages/common/`. The toolbar surfaces telemetry events and
|
||||
allows feature flag overrides during local development (expanding to staging/preview).
|
||||
|
||||
## When This Applies
|
||||
|
||||
PRs modifying any of these paths need growth eng review:
|
||||
|
||||
- `packages/dev-tools/**` (owned by `@supabase/growth-eng` in CODEOWNERS)
|
||||
- `packages/common/posthog-client.ts` (flag override reads, event subscription)
|
||||
- `packages/common/feature-flags.tsx` (flag override merge logic)
|
||||
- App-level mounting: `DevToolbarProvider`/`DevToolbar`/`DevToolbarTrigger` in `apps/studio/`, `apps/www/`, `apps/docs/`
|
||||
|
||||
Note: `posthog-client.ts` and `feature-flags.tsx` are NOT in CODEOWNERS for growth-eng,
|
||||
so PRs touching only those files won't auto-request review. Watch for these in the PR feed.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### 1. Environment Guards
|
||||
|
||||
**Files:** `packages/dev-tools/index.ts`, `DevToolbar.tsx`, `DevToolbarTrigger.tsx`, `DevToolbarContext.tsx`
|
||||
|
||||
The toolbar uses two layers of protection:
|
||||
|
||||
- **Build-time tree-shaking** in `index.ts`: `process.env.NODE_ENV !== 'development'` ternaries that replace components with noops/stubs so the implementation is eliminated from production bundles.
|
||||
- **Runtime guards** in components: `IS_LOCAL_DEV` checks — `DevToolbar` and `DevToolbarTrigger` return `null` to hide themselves, while `DevToolbarProvider` passes children through (`<>{children}</>`) to preserve the component tree.
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Guards being removed or broadened. The toolbar is expanding to staging and preview deploys but must remain invisible in production.
|
||||
- Tree-shaking ternaries in `index.ts` staying intact — these are the primary production safety mechanism.
|
||||
- New components or exports that bypass the existing guard pattern.
|
||||
|
||||
### 2. Flag Override Cookies
|
||||
|
||||
**Files:** `packages/dev-tools/DevToolbar.tsx`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx`
|
||||
|
||||
The toolbar writes two cookies that override feature flags locally:
|
||||
|
||||
- `x-ph-flag-overrides` — PostHog flag overrides
|
||||
- `x-cc-flag-overrides` — ConfigCat flag overrides
|
||||
|
||||
These are read by:
|
||||
|
||||
- `posthog-client.ts:getFeatureFlag()` — checks the PostHog override cookie before querying the SDK
|
||||
- `feature-flags.tsx` — merges both override cookies into the flag store during initialization
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Cookie name changes (must stay in sync across writer and all readers)
|
||||
- Changes to the merge/precedence logic in `feature-flags.tsx` (currently: `vercel-flag-overrides` first, then `x-cc-flag-overrides` takes precedence in local dev)
|
||||
- Override cookies being read outside the `IS_LOCAL_DEV` / `isLocalDev` guard — overrides must never affect production flag evaluation
|
||||
- Changes to `parseOverrideValue` or `valuesAreEqual` in `packages/dev-tools/utils.ts` that could cause type coercion bugs
|
||||
|
||||
### 3. Telemetry Event Subscription
|
||||
|
||||
**Files:** `packages/common/posthog-client.ts`, `packages/dev-tools/DevToolbarContext.tsx`
|
||||
|
||||
The toolbar subscribes to client-side PostHog events via `posthogClient.subscribeToEvents()`.
|
||||
The PostHog client calls `emitToDevListeners()` after `capturePageView`, `capturePageLeave`,
|
||||
and `identify`. Note: `captureExperimentExposure` calls `posthog.capture()` directly
|
||||
without emitting to dev listeners — experiment exposure events are invisible in the toolbar.
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Changes to `emitToDevListeners` or `subscribeToEvents` that could introduce side effects on the actual capture path (e.g., throwing errors, blocking, mutating event data)
|
||||
- The listener set (`devListeners`) being iterated synchronously in a way that could delay event dispatch
|
||||
- New PostHog client methods that capture events but don't call `emitToDevListeners` (gap in toolbar visibility)
|
||||
|
||||
### 4. SSE Server Telemetry Stream
|
||||
|
||||
**Files:** `packages/dev-tools/DevToolbarContext.tsx`
|
||||
|
||||
The toolbar connects to `${apiUrl}/telemetry/stream` via Server-Sent Events to display
|
||||
server-side telemetry. Uses exponential backoff on connection errors.
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Changes to the SSE endpoint URL or `session_id` cookie handling
|
||||
- Reconnection logic changes that could cause excessive retries or connection leaks
|
||||
- Note: the stream endpoint lives in the platform repo — cross-repo changes need coordinated review
|
||||
|
||||
### 5. App-Level Mounting
|
||||
|
||||
**Provider + toolbar panel** (`DevToolbarProvider`, `DevToolbar`):
|
||||
|
||||
- `apps/studio/pages/_app.tsx`
|
||||
- `apps/www/pages/_app.tsx`, `apps/www/app/providers.tsx`
|
||||
- `apps/docs/features/app.providers.tsx`
|
||||
|
||||
**Trigger button** (`DevToolbarTrigger`) — rendered separately in nav/header components:
|
||||
|
||||
- `apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx`
|
||||
- `apps/www/components/Nav/index.tsx`
|
||||
- `apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx`
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Provider being added or removed from an app
|
||||
- `apiUrl` prop changes (must point to the correct platform API)
|
||||
- Rendering order changes that could affect the toolbar's access to PostHog context
|
||||
|
||||
## What Doesn't Need Growth Review
|
||||
|
||||
Changes that are purely UI/UX within the toolbar panel itself — styling, layout, copy
|
||||
changes, drag behavior, popover positioning — don't need growth eng review unless they
|
||||
also touch the integration points above.
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/edit-the-docs
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/pm-the-docs
|
||||
@@ -1,284 +0,0 @@
|
||||
---
|
||||
name: react-hook-form
|
||||
description: Correct React Hook Form usage anywhere in the monorepo — data flow, subscriptions,
|
||||
reset, dirty state, number inputs, and controlled-input rules. Load this BEFORE
|
||||
writing or modifying ANY form code, adding a field to an existing form, touching
|
||||
watch/useWatch/formState/getValues/setValue/reset, wiring a form into a dialog or
|
||||
sheet, or building a submit/cancel footer — even when the change looks trivial.
|
||||
The codebase contains widespread RHF anti-patterns; without this skill you will
|
||||
copy them. For form layout and which components to use, also load
|
||||
studio-ui-patterns.
|
||||
---
|
||||
|
||||
# React Hook Form
|
||||
|
||||
How to write forms that stay correct as they grow. The existing codebase is **not**
|
||||
a safe reference: `form.watch()` off prop-drilled form objects, subscription-only
|
||||
watches, unguarded `valueAsNumber`, and `?? undefined` controlled values are all
|
||||
common in older code and all wrong. Follow this skill, not the neighboring file.
|
||||
|
||||
**Policy — fix what you touch.** New code must follow these rules. When you modify
|
||||
existing form code, upgrade the specific fields/hooks/components you're editing to
|
||||
match (e.g. a component you touch that calls `form.watch` gets converted to
|
||||
`useWatch`). Leave untouched code alone, but tell the user about anti-patterns you
|
||||
noticed and didn't fix. Never add new violations: `react-hook-form/no-use-watch`
|
||||
is ratcheted in Studio CI — any increase in the warning count fails the build.
|
||||
|
||||
## Mental model: subscriptions decide who re-renders
|
||||
|
||||
RHF is uncontrolled at heart. Values live in refs; nothing re-renders unless a
|
||||
subscription says so. Every read API is a subscription decision:
|
||||
|
||||
| API | Subscribes | Re-renders | Use for |
|
||||
| ----------------------------- | ---------- | -------------------------- | ---------------------------------------------- |
|
||||
| `useWatch({ control, name })` | yes | only the calling component | reactive value reads, anywhere |
|
||||
| `useFormState({ control })` | yes | only the calling component | `isDirty`/`errors`/etc. outside the form owner |
|
||||
| `formState` (destructured) | yes | the `useForm` owner | form state **in the owner component only** |
|
||||
| `form.watch(name)` | yes | the **entire form tree** | avoid — lint-flagged, see below |
|
||||
| `getValues()` | no | never | event handlers and `onSubmit` only |
|
||||
| `subscribe()` | callback | none | side effects outside render |
|
||||
|
||||
Two facts explain most of the bugs we've shipped:
|
||||
|
||||
1. **`form.watch()` and `form.formState` hoist their subscription to the `useForm`
|
||||
owner**, no matter which component calls them. A child that reads
|
||||
`form.watch('x')` off a prop works today only because the whole tree re-renders
|
||||
on every change — it silently goes stale the moment anyone adds `React.memo`
|
||||
between owner and child, and until then it re-renders every sibling on every
|
||||
keystroke. A no-arg `form.watch()` sets `watchAll` and re-renders the tree on
|
||||
every field change for the life of the form.
|
||||
2. **`formState` is a Proxy** — reading a property is what arms the subscription.
|
||||
Destructure it (`const { isDirty } = form.formState`), never pass the object
|
||||
around or read it conditionally (`a && formState.isValid` may never subscribe).
|
||||
Enforced by `react-hook-form/destructuring-formstate` (error).
|
||||
|
||||
### Reading values, by location
|
||||
|
||||
- **In the component that owns `useForm`:** destructure `formState`; prefer
|
||||
`useWatch` over `form.watch` even here (the `no-use-watch` rule flags every
|
||||
`watch`, and `useWatch` scopes the re-render if the JSX is later extracted).
|
||||
- **In any child component or custom hook:** accept `control` (not the whole
|
||||
`form`) and use `useWatch({ control, name })` / `useFormState({ control })`.
|
||||
Inside `<Form {...form}>` (which _is_ `FormProvider`), `useFormContext()` +
|
||||
`useWatch({ name })` also works and avoids prop-drilling entirely.
|
||||
- **Consume the return value.** Never call a watch for its subscription side
|
||||
effect and then read via `getValues()` — the watch list and the read list will
|
||||
drift apart (it has already happened; fields silently lost reactivity). The
|
||||
value you render must _be_ the value you subscribed to.
|
||||
- **One read path per value per render.** Mixing `useWatch('x')` on one line and
|
||||
`getValues('x')` a few lines later lets the two disagree within a single render.
|
||||
- **Name what you watch.** `useWatch({ control })` with no `name` re-renders on
|
||||
every keystroke in every field. Subscribe to the specific names you use.
|
||||
- `watch(callback)` is deprecated — use `subscribe()` for render-free listeners,
|
||||
and always return its cleanup from `useEffect`.
|
||||
|
||||
```tsx
|
||||
// ❌ common in the codebase — all three subscriptions hoist to the form owner
|
||||
function Fields({ form }: { form: UseFormReturn<FormValues> }) {
|
||||
form.watch(['storageType', 'totalSize']) // return value discarded
|
||||
const { errors } = form.formState // prop-form formState
|
||||
const size = form.getValues('totalSize') // non-reactive read in render
|
||||
...
|
||||
}
|
||||
|
||||
// ✅ child subscribes for itself and consumes what it watches
|
||||
function Fields({ control }: { control: Control<FormValues> }) {
|
||||
const [storageType, totalSize] = useWatch({ control, name: ['storageType', 'totalSize'] })
|
||||
const { errors } = useFormState({ control })
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## The canonical form
|
||||
|
||||
zod schema → `z.infer` type → `useForm` with `zodResolver` and **complete**
|
||||
`defaultValues` → `<Form {...form}>` → `FormField` render-prop per field →
|
||||
`FormItemLayout` → `FormControl` → primitive from `ui`. Layout/container choices
|
||||
(Card vs Sheet, `layout=` variants) are covered by the `studio-ui-patterns` skill
|
||||
and the demos in `apps/design-system/registry/default/example/`
|
||||
(`form-patterns-pagelayout.tsx`, `form-patterns-sidepanel.tsx`) — check them
|
||||
before inventing structure.
|
||||
|
||||
```tsx
|
||||
// Module level — static references, not recreated on every render
|
||||
const FORM_ID = 'pool-config-form'
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
maxConnections: z
|
||||
.union([z.literal(''), z.coerce.number().gte(1, 'Must be at least 1')])
|
||||
.refine((v) => v !== '', 'Max connections is required'),
|
||||
})
|
||||
type FormValues = z.infer<typeof FormSchema>
|
||||
|
||||
const defaultValues: FormValues = { name: '', maxConnections: '' }
|
||||
|
||||
// Inside the component
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="horizontal" label="Name">
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
```
|
||||
|
||||
Define the schema, `type`, static `defaultValues`, and the form's id at module
|
||||
level, outside the component. Rebuilding them per render is wasted work and
|
||||
unstable references — RHF reads `defaultValues` only on the first render, but
|
||||
anything else comparing against these objects sees a fresh identity each time.
|
||||
When they genuinely depend on runtime data, build the schema with `useMemo` and
|
||||
feed server-driven defaults through the `values` option (next section) instead
|
||||
of hoisting.
|
||||
|
||||
Submit buttons living outside the `<form>` (sheet/dialog footers) use the same
|
||||
module-level `FORM_ID` via `form={FORM_ID}` on the button. A module-level id is
|
||||
only safe for singleton forms — if the component can mount more than once at a
|
||||
time, duplicate ids make external buttons submit the first matching form, so
|
||||
mint a per-instance id with `useId()` and share it between the `<form>` and its
|
||||
buttons.
|
||||
|
||||
## defaultValues, server data, and reset
|
||||
|
||||
- **Provide a complete `defaultValues` object — every field, no `undefined`.**
|
||||
`isDirty`, `dirtyFields`, and Cancel-reset all compare against it; a missing or
|
||||
`undefined` default breaks all three, and `undefined` also makes React treat the
|
||||
input as uncontrolled (see below).
|
||||
- **Form populated from an API? Use the `values` option, not a hand-rolled
|
||||
effect.** `values` reacts to the query resolving and resets the form for you;
|
||||
computing `defaultValues` from a query that may not have loaded freezes whatever
|
||||
happened to be in cache at mount. Add
|
||||
`resetOptions: { keepDirtyValues: true }` when a background refetch must not
|
||||
clobber the user's in-progress edits. (Good examples:
|
||||
`components/interfaces/Settings/Database/ConnectionLogging.tsx`,
|
||||
`components/interfaces/Storage/EditBucketModal.tsx`.)
|
||||
- **After a successful mutation, re-baseline the form** in `onSuccess` so the
|
||||
saved state becomes the new baseline (`isDirty` returns to false, Cancel now
|
||||
reverts to the saved values). Prefer what the server actually persisted: if the
|
||||
form uses `values` and the mutation invalidates the query, the refetch handles
|
||||
this for you; if the mutation returns the updated resource, `reset(response)`.
|
||||
`reset(submittedValues)` is the fallback for APIs that store exactly what was
|
||||
sent — if the server normalizes or fills values, it baselines the form to data
|
||||
that was never saved. A bare `reset()` reverts to the _previous_ defaults —
|
||||
wrong after a save.
|
||||
- Cancel buttons call `form.reset()`. This only visually restores fields whose
|
||||
values round-trip through defined, controlled values — which is why the null
|
||||
rules below matter.
|
||||
|
||||
## Controlled inputs: never let `value` flip to `undefined`
|
||||
|
||||
React decides controlled vs uncontrolled per render from whether `value` is
|
||||
defined. A field whose value can be `undefined` (or becomes `undefined` on reset)
|
||||
flips modes: console warnings, and — worse — `reset()` stops clearing the visible
|
||||
text because React abandoned the DOM value. `value={field.value ?? undefined}` is
|
||||
a bug, not a fix.
|
||||
|
||||
- Text fields: default to `''`, never `null`/`undefined`.
|
||||
- **Normalize `null` from the API at the form boundary** (`growthPercent ?? ''`
|
||||
when building defaults) and convert back on submit (`'' → null`). Do not paper
|
||||
over a `null` default with a `placeholder` that looks like a value: the user
|
||||
sees "50", the form holds `null`, and every downstream comparison
|
||||
(`defaultValues.growthPercent !== watched` → `null !== 50`) reports a permanent
|
||||
phantom change while Cancel silently fails to reset the field.
|
||||
- Selects/radios: default to `''` or a real option value; checkboxes/switches to
|
||||
`false`.
|
||||
|
||||
## Number inputs
|
||||
|
||||
The blessed pattern keeps `''` as the "empty" sentinel so the input stays
|
||||
controlled, and lets zod coerce on validation (see `maxConnections` above):
|
||||
`z.union([z.literal(''), z.coerce.number()...]).refine((v) => v !== '', '…')`
|
||||
with a plain `<Input {...field} type="number" />`.
|
||||
|
||||
If you instead wire `onChange` through `e.target.valueAsNumber` (or
|
||||
`valueAsNumber: true`), an empty or partially-typed input produces `NaN`, which
|
||||
lands in form state and propagates into every calculation, price preview, and
|
||||
`value` attribute downstream. Guard it with the **same empty sentinel the
|
||||
field's schema declares** — with the `''`-union schema above:
|
||||
`field.onChange(Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber)`.
|
||||
Never let `NaN` into form state.
|
||||
|
||||
A nullable API field (`null` = "unset", e.g. a platform default applies)
|
||||
doesn't change the in-form sentinel — keep `''` inside the form and convert at
|
||||
the boundaries:
|
||||
|
||||
```tsx
|
||||
// inbound: null → '' when building defaults/values
|
||||
values: { growthPercent: data.growth_percent ?? '' },
|
||||
// schema: '' stays the in-form sentinel, zod coerces real input
|
||||
growthPercent: z.union([z.literal(''), z.coerce.number().gte(10).lte(100)]),
|
||||
// outbound: '' → null in onSubmit
|
||||
mutate({ growth_percent: values.growthPercent === '' ? null : values.growthPercent })
|
||||
```
|
||||
|
||||
If `null` does end up in form state (some existing forms hold it), keep it out
|
||||
of both the input and the coercion: render via `value={field.value ?? ''}`, and
|
||||
don't pass the value through `z.coerce.number()` — `Number(null)` is `0`, so a
|
||||
nullable field fed into the coercing union silently validates empty as `0`.
|
||||
Either way it's one sentinel per field, used consistently across defaults,
|
||||
schema, `onChange`, rendering, and the submit mapping.
|
||||
|
||||
## Dirty state and change detection
|
||||
|
||||
- Gate Save on `isDirty`; show Cancel only when dirty. In the owner, destructure
|
||||
from `form.formState`; anywhere else, `useFormState({ control })`.
|
||||
- When the form lives in a Sheet or Dialog, also wire dirty dismissal:
|
||||
`useConfirmOnClose` + `DiscardChangesConfirmationDialog`. Route Cancel,
|
||||
Escape, and backdrop through the guard; call the raw `onClose` on successful
|
||||
submit so you do not prompt after save. Details:
|
||||
`apps/design-system/content/docs/ui-patterns/modality.mdx` (Dirty form
|
||||
dismissal) and the studio-ui-patterns skill Sheets section.
|
||||
- To show _which_ fields changed (review/summary dialogs), read `dirtyFields`
|
||||
from the same subscription instead of hand-comparing
|
||||
`defaultValues.x !== watchedX`. RHF already does that comparison correctly;
|
||||
hand-rolled versions break on the null-vs-placeholder mismatch and must be
|
||||
kept in sync with the watch list by hand.
|
||||
- `setValue` outside user input needs explicit flags:
|
||||
`setValue('x', v, { shouldDirty: true, shouldValidate: true })` — otherwise the
|
||||
change is invisible to `isDirty` and validation.
|
||||
|
||||
## Disabling and gating
|
||||
|
||||
If a field must not be edited (plan tier, permissions, cooldown), disable the
|
||||
field itself — a notice next to an editable input gates nothing. Wire the same
|
||||
condition into both the notice and the control. Permission checks come from
|
||||
`useAsyncCheckPermissions`; disabled buttons that need an explanation use
|
||||
`ButtonTooltip`.
|
||||
|
||||
Caution: `register`/`useController` `disabled: true` removes the field's value
|
||||
from submission data. For "visible but locked" fields whose value must survive
|
||||
submit, use the input's own `disabled`/`readOnly` prop (as `FormField` +
|
||||
primitive props do) rather than RHF-level disabling, or the form-level
|
||||
`disabled` option to freeze everything during async work.
|
||||
|
||||
## Submit and mutations
|
||||
|
||||
`onSubmit` receives validated, typed data — trust it; don't re-read via
|
||||
`getValues()`. Mutations follow Studio conventions: `onSuccess` → `toast.success`
|
||||
|
||||
- `reset(values)` (or query invalidation when using `values:`), `onError` →
|
||||
`toast.error`; pass the mutation's `isPending` to the button's `loading` prop.
|
||||
Default validation `mode: 'onSubmit'` is right for most forms — pick another mode
|
||||
deliberately, not by copying.
|
||||
|
||||
## Lint rules in force (Studio)
|
||||
|
||||
| Rule | Level | Meaning |
|
||||
| ------------------------------------------- | ---------------- | ------------------------------------------------ |
|
||||
| `react-hook-form/destructuring-formstate` | error | destructure `formState`, never hold the object |
|
||||
| `react-hook-form/no-access-control` | error | don't reach into `control` internals |
|
||||
| `react-hook-form/no-nested-object-setvalue` | error | `setValue('a.b', v)`, not `setValue('a', {b:v})` |
|
||||
| `react-hook-form/no-use-watch` | warn (ratcheted) | use `useWatch`, not `watch` |
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/review-the-docs
|
||||
@@ -1,454 +0,0 @@
|
||||
---
|
||||
name: safe-sql-execution
|
||||
description: >-
|
||||
Use whenever code will build, return, fetch, or execute SQL that runs against
|
||||
a user's real Postgres database — even when the request reads like an ordinary
|
||||
feature or bug fix and never says "security," "injection," or
|
||||
"SafeSqlFragment." This covers: writing or editing any pg-meta function, query
|
||||
builder, or endpoint that builds/returns SQL for database objects (tables,
|
||||
views, functions, DB triggers, indexes, RLS policies); interpolating a
|
||||
schema/table/column/search/route-param value into SQL text; storing, fetching,
|
||||
or re-running SQL that round-trips from the database (a policy's definition, a
|
||||
function/view definition, a snippet's saved content); and any
|
||||
"Run"/"Apply"/"Execute" action that sends SQL to a project's database (SQL
|
||||
editor run-selection, policy editor apply, snippet runner). Load this BEFORE
|
||||
writing such code, not only when reviewing a finished diff. Skip only for
|
||||
changes that never touch SQL text or execution — styling, unrelated data
|
||||
hooks, non-SQL form validation, or UI layout work.
|
||||
---
|
||||
|
||||
# Safe SQL execution
|
||||
|
||||
Supabase Studio executes SQL statements directly against the user's database.
|
||||
Because this is the authenticated user's own database, our security model is
|
||||
different from most frontend applications: a user should be able to execute any
|
||||
SQL statement, as long as it is proven that they themselves authored it. What
|
||||
we SHOULD NOT ALLOW is execution of SQL statements that can be influenced by an
|
||||
attacker, such as through URL parameters.
|
||||
|
||||
## Security model
|
||||
|
||||
The security model for SQL execution in Supabase Studio is based on the
|
||||
principle of "proven authorship". This means that a user should only be able to
|
||||
execute SQL statements that they have explicitly authored, and not statements
|
||||
that can be influenced by external input.
|
||||
|
||||
There are three classes of SQL fragments:
|
||||
|
||||
1. Hardcoded within the application code. These are safe to execute because
|
||||
they cannot be influenced by an attacker. They can be marked with the
|
||||
`safeSql` utility with `pg-meta`:
|
||||
|
||||
```ts
|
||||
import { safeSql } from '@supabase/pg-meta'
|
||||
|
||||
const sql = safeSql`
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE id = 1
|
||||
`
|
||||
```
|
||||
|
||||
`safeSql` automatically creates a string of the branded type
|
||||
`SafeSqlFragment`. (See Provenance Tracking below.)
|
||||
|
||||
2. Third-party influenceable. These are SQL fragments that can be influenced
|
||||
by an attacker, such as through URL parameters or LLM output. These should
|
||||
be marked with the `untrustedSql` utility with `pg-meta`:
|
||||
|
||||
```ts
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
const unsafeQuery = searchParams.get('query')
|
||||
const querySql = untrustedSql(unsafeQuery)
|
||||
```
|
||||
|
||||
`untrustedSql` creates a string of the branded type `UntrustedSqlFragment`.
|
||||
(See Provenance Tracking below.)
|
||||
|
||||
3. User-authored. These are SQL fragments that are authored by the user
|
||||
themselves within the UI, for example in a text input field. Because the
|
||||
user is the author, these should be considered safe to execute.
|
||||
|
||||
However, there is a caveat, where third-party and user-authored code can
|
||||
mix, contaminating the user-authored code (for example, if an input is
|
||||
prefilled from an unsanitized URL parameter). Provenance tracking helps us
|
||||
track these cases.
|
||||
|
||||
For example, a safe input component could be implemented as follows by requiring that its placeholder and controlled value are of type `SafeSqlFragment`. In this case we can use its onChange to promote the user input to `SafeSqlFragment` type, because we know that the user is the author of the input. An implementation of this is in
|
||||
@apps/studio/components/ui/SafeSqlInput.tsx:
|
||||
|
||||
```ts
|
||||
import { rawSql, type SafeSqlFragment } from '@supabase/pg-meta'
|
||||
import type { ChangeEvent, ComponentProps } from 'react'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
|
||||
type InputProps = ComponentProps<typeof Input>
|
||||
|
||||
export type SafeSqlInputProps = Omit<
|
||||
InputProps, 'placeholder' | 'value' | 'onChange'
|
||||
> & {
|
||||
placeholder?: SafeSqlFragment
|
||||
value: SafeSqlFragment
|
||||
onChange?:
|
||||
(event: ChangeEvent<HTMLInputElement>, value: SafeSqlFragment) => void
|
||||
}
|
||||
|
||||
export const SafeSqlInput = ({ onChange, ...props }: SafeSqlInputProps) => (
|
||||
<Input
|
||||
{...props}
|
||||
onChange={(event) => onChange?.(event, rawSql(event.target.value))}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
This is pretty much the ONLY VALID USE CASE of the rawSql export from
|
||||
pg-meta, and it should be used with caution.
|
||||
|
||||
## Provenance tracking
|
||||
|
||||
Branded types are used to track the provenance of SQL fragments. The types,
|
||||
exported from `pg-meta`, are:
|
||||
|
||||
- `SafeSqlFragment`: represents SQL fragments that are safe to execute, because
|
||||
they are either hardcoded in the application or authored by the user
|
||||
themselves.
|
||||
- `UntrustedSqlFragment`: represents SQL fragments that can be influenced by an
|
||||
attacker, such as through URL parameters or LLM output.
|
||||
|
||||
These are valid ways to generate a `SafeSqlFragment`:
|
||||
|
||||
- Using the `safeSql` utility from `pg-meta` to create hardcoded SQL fragments.
|
||||
- Using the sanitization utilities from `pg-meta` to sanitize untrusted input
|
||||
and promote it to a `SafeSqlFragment`:
|
||||
- `ident`
|
||||
- `literal`
|
||||
- `keyword`
|
||||
- Using the safe SQL manipulation utilities:
|
||||
- `joinSqlFragments`
|
||||
- `trimSafeSqlFragment`
|
||||
|
||||
`UntrustedSqlFragments` can be generated from raw strings using
|
||||
`untrustedSql()`.
|
||||
|
||||
There is also a union type, `DisplayableSqlFragment`, which represents SQL fragments that can be safely displayed in the UI, but not necessarily executed. This includes both `SafeSqlFragment` and `UntrustedSqlFragment`.
|
||||
|
||||
## Security of SQL round-tripped from the user's database
|
||||
|
||||
SQL derived directly from catalog tables (e.g., function definitions, RLS
|
||||
expressions, etc.) is considered safe, and it is promoted AT THE POINT OF
|
||||
BEING QUERIED from the database. In most cases, this is in an
|
||||
apps/studio/data/\*_/_.ts file, in the utility function that makes the API or
|
||||
database fetch.
|
||||
|
||||
A critical exception to the safety of SQL round-tripped from the database is
|
||||
user snippets. These must NEVER BE CONSIDERED SAFE because they are both (a)
|
||||
externally influenceable and (b) auto-saved. The snippet type uses the
|
||||
`unchecked_sql` property, which is an `UntrustedSqlFragment`, to enforce this.
|
||||
|
||||
## Promoting SQL fragments to `SafeSqlFragment` type
|
||||
|
||||
Given an insecure string or `UntrustedSqlFragment`, how do we promote it safely
|
||||
to a `SafeSqlFragment`?
|
||||
|
||||
### Sanitization utilities
|
||||
|
||||
This is the preferred method when the input is sanitizable, e.g., it is a
|
||||
relation name, a column name, will be compared as a literal, etc.
|
||||
|
||||
The pg-meta library provides the following sanitization utilities that can be
|
||||
used to safely promote untrusted input to `SafeSqlFragment`:
|
||||
|
||||
- `ident`: for sanitizing identifiers such as table names or column names.
|
||||
- `literal`: for sanitizing literal values that will be used in SQL statements.
|
||||
- `keyword`: for sanitizing SQL keywords.
|
||||
|
||||
### `acceptUntrustedSql`
|
||||
|
||||
Some untrusted SQL fragments cannot be sanitized with the above utilities. For
|
||||
example, the `USING` expression in the RLS policy editor is an arbitrary SQL
|
||||
expression.
|
||||
|
||||
In these cases, we can promote the SQL fragment _upon explicit user action_.
|
||||
User action indicates that the user has seen the SQL and is OK with running it.
|
||||
For example, an explicit user action could be clicking a "Run" button.
|
||||
|
||||
The promotion happens with the `acceptUntrustedSql` utility from `pg-meta`,
|
||||
which takes an `UntrustedSqlFragment` and returns a `SafeSqlFragment`.
|
||||
|
||||
This utility MUST ONLY BE USED IN event handlers. It should NEVER be used in
|
||||
a useQuery, direct in the render body of a component, in a useEffect, or
|
||||
anywhere it could auto-run without explicit user action.
|
||||
|
||||
This is safe:
|
||||
|
||||
```ts
|
||||
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
function SafeComponent() {
|
||||
const { mutate: execute } = useExecuteSqlMutation()
|
||||
|
||||
const handleRun = () => {
|
||||
// ✅ GOOD: Safe because it is in an event handler which requires a user
|
||||
// click
|
||||
execute({ sql: acceptUntrustedSql(/* sql */) })
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleRun}>Run</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This is unsafe:
|
||||
|
||||
```ts
|
||||
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
function UnsafeComponent() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['execute-sql', sql],
|
||||
queryFn: () => {
|
||||
// 🛑 BAD: Unsafe because it is in a query which could auto-run without
|
||||
// explicit user action
|
||||
return execute({ sql: acceptUntrustedSql(/* sql */) })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Type guarantees
|
||||
|
||||
SQL run against the user's Postgres database runs through the `executeSql`
|
||||
function, which only takes arguments of type `SafeSqlFragment` for the SQL
|
||||
parameter. Raw strings or `UntrustedSqlFragment`s will error at compile time.
|
||||
|
||||
## Examples
|
||||
|
||||
### Hard-coded SQL
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: Automatically safe with `safeSql` utility
|
||||
const selectStatement = safeSql`select 1`
|
||||
```
|
||||
|
||||
### SQL with sanitizable interpolations
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: `pg-meta` utilities sanitize the input
|
||||
const tableName = ident(userInputTableName)
|
||||
const searchString = literal(userInputSearchString)
|
||||
const sqlStatement = safeSql`
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
WHERE search_column = ${searchString}
|
||||
`
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: Passing raw strings will type error
|
||||
const tableName = 'my_table'
|
||||
const sqlStatement = safeSql`
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
`
|
||||
```
|
||||
|
||||
### Non-sanitizable SQL from a user input
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: SafeSqlInput only allows a value that is a SafeSqlFragment
|
||||
import { SafeSqlInput } from '@apps/studio/components/ui/SafeSqlInput'
|
||||
|
||||
function MyComponent() {
|
||||
const [sql, setSql] = useState<SafeSqlFragment>(safeSql``)
|
||||
|
||||
return (
|
||||
<SafeSqlInput
|
||||
placeholder={safeSql`Enter your SQL query here...`}
|
||||
value={sql}
|
||||
onChange={(event, value) => setSql(value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: This input mixes SafeSqlFragments and unsafe strings
|
||||
|
||||
function MyBadComponent() {
|
||||
const [sql, setSql] = useState<SafeSqlFragment>(safeSql``)
|
||||
|
||||
return (
|
||||
<Input
|
||||
// 🛑 BAD: This is unsafe because the placeholder is a raw string
|
||||
placeholder="Enter your SQL query here..."
|
||||
value={sql}
|
||||
onChange={(event) => setSql(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Round-tripping SQL from the database (NOT snippet content)
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: SQL from the database is promoted to SafeSqlFragment at the point
|
||||
// of fetching
|
||||
|
||||
// data/function-definitions.ts
|
||||
function markFunctionDefinitionSafe(
|
||||
functionDefinition: FunctionDefinition
|
||||
): SafeFunctionDefinition {
|
||||
return {
|
||||
...functionDefinition,
|
||||
definition: functionDefinition.definition as SafeSqlFragment,
|
||||
}
|
||||
}
|
||||
|
||||
// data/function-definitions.ts
|
||||
function getFunctionDefinitions() {
|
||||
return GET(`/function-definitions`).then((functionDefinitions) =>
|
||||
functionDefinitions.map(markFunctionDefinitionSafe)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: Strings are promoted to SafeSqlFragment in a utility function, where
|
||||
// it is impossible to easily determine the safety of the input
|
||||
|
||||
// utils.ts
|
||||
function markFunctionDefinitionSafe(
|
||||
functionDefinition: FunctionDefinition
|
||||
): SafeFunctionDefinition {
|
||||
return {
|
||||
...functionDefinition,
|
||||
definition: functionDefinition.definition as SafeSqlFragment,
|
||||
}
|
||||
}
|
||||
|
||||
// Component.ts
|
||||
function MyComponent() {
|
||||
const { data: functionDefinitions } = useFunctionDefinitions()
|
||||
const safeFunctionDefinitions = functionDefinitions.map(markFunctionDefinitionSafe)
|
||||
}
|
||||
```
|
||||
|
||||
### Snippet content is ALWAYS UNSAFE
|
||||
|
||||
Snippets are auto-persisted to the database and can be created or modified
|
||||
through externally influenceable channels (e.g., prefilled from URL params).
|
||||
The `unchecked_sql` property is typed as `UntrustedSqlFragment` to enforce this
|
||||
— it must only be promoted to `SafeSqlFragment` via `acceptUntrustedSql` in an
|
||||
event handler that requires explicit user action.
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: Snippet content is executed automatically via useQuery, with no
|
||||
// explicit user action confirming that the user has reviewed the SQL.
|
||||
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
function UnsafeSnippetPreview({ snippet }: { snippet: Snippet }) {
|
||||
const { data } = useExecuteSqlQuery({
|
||||
sql: acceptUntrustedSql(snippet.content.unchecked_sql),
|
||||
})
|
||||
|
||||
return <Results data={data} />
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: Casting bypasses the type system entirely. The snippet's
|
||||
// `unchecked_sql` is `UntrustedSqlFragment` for a reason — never cast it.
|
||||
function UnsafeSnippetRunner({ snippet }: { snippet: Snippet }) {
|
||||
const { mutate: execute } = useExecuteSqlMutation()
|
||||
|
||||
useEffect(() => {
|
||||
execute({ sql: snippet.content.unchecked_sql as SafeSqlFragment })
|
||||
}, [snippet])
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// ✅ GOOD: Snippet content is only promoted to SafeSqlFragment inside an event
|
||||
// handler, after the user clicks Run. The user has seen the SQL in the editor
|
||||
// and explicitly chosen to execute it.
|
||||
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
||||
|
||||
function SnippetRunner({ snippet }: { snippet: Snippet }) {
|
||||
const { mutate: execute } = useExecuteSqlMutation()
|
||||
|
||||
const handleRun = () => {
|
||||
execute({ sql: acceptUntrustedSql(snippet.content.unchecked_sql) })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SnippetEditor snippet={snippet} />
|
||||
<button onClick={handleRun}>Run</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Analytics SQL (BigQuery / ClickHouse)
|
||||
|
||||
The same security model applies to analytics queries, which target BigQuery
|
||||
or ClickHouse via the
|
||||
`/platform/projects/{ref}/analytics/endpoints/logs.all{,.otel}` endpoints.
|
||||
Filter keys and values from URL parameters and UI inputs are spliced into SQL
|
||||
that runs against the project's logs, so the same injection risk exists.
|
||||
|
||||
The brand and helpers live in `apps/studio/data/logs/safe-analytics-sql.ts`,
|
||||
intentionally **disjoint** from the pg-meta `SafeSqlFragment` brand:
|
||||
|
||||
- `SafeLogSqlFragment` — branded type for analytics SQL.
|
||||
- `safeSql` — template tag that only accepts `SafeLogSqlFragment`
|
||||
interpolations.
|
||||
- `analyticsLiteral(value)` — sanitizes string/number/boolean literals.
|
||||
- `quotedIdent(name)` — validates and backtick-quotes dotted identifiers.
|
||||
- `keyword(value, allowed)` — validates against an allow-list of operators.
|
||||
- `joinSqlFragments(fragments, separator)` — composes already-branded
|
||||
fragments.
|
||||
|
||||
The brands are kept separate because escape semantics differ — Postgres-safe
|
||||
`E'…'` strings, `::jsonb` casts, and double-quoted identifiers are unsafe for
|
||||
BigQuery and/or ClickHouse, and vice versa. Crossing the brands would silently
|
||||
emit unsafe SQL.
|
||||
|
||||
The wire-boundary wrapper is `executeAnalyticsSql` in
|
||||
`apps/studio/data/logs/execute-analytics-sql.ts`, analogous to pg-meta's
|
||||
`executeSql`. It accepts only `SafeLogSqlFragment` for its `sql` parameter, so
|
||||
raw strings are rejected at compile time. A grep-based vitest
|
||||
(`apps/studio/tests/unit/lints/analytics-sql-boundary.test.ts`) prevents
|
||||
regressions by failing the build if any file outside
|
||||
`execute-analytics-sql.ts` calls `post()` or `get()` directly against
|
||||
`logs.all` or `logs.all.otel`.
|
||||
|
||||
```ts
|
||||
import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql'
|
||||
import { analyticsLiteral, quotedIdent, safeSql } from '@/data/logs/safe-analytics-sql'
|
||||
|
||||
// ✅ GOOD: every interpolation is sanitized.
|
||||
const sql = safeSql`
|
||||
SELECT timestamp, event_message
|
||||
FROM ${quotedIdent(table)}
|
||||
WHERE id = ${analyticsLiteral(id)}
|
||||
`
|
||||
|
||||
await executeAnalyticsSql({
|
||||
projectRef,
|
||||
endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all',
|
||||
sql,
|
||||
iso_timestamp_start,
|
||||
iso_timestamp_end,
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// 🛑 BAD: raw string interpolation. This fails to type-check at the
|
||||
// executeAnalyticsSql boundary because the result is `string`, not
|
||||
// `SafeLogSqlFragment`.
|
||||
const sql = `SELECT * FROM ${table} WHERE id = '${id}'`
|
||||
await executeAnalyticsSql({ projectRef, endpoint, sql, ... })
|
||||
```
|
||||
@@ -1,435 +0,0 @@
|
||||
---
|
||||
name: studio-e2e-tests
|
||||
description: Write and run Playwright E2E tests for Supabase Studio (e2e/studio).
|
||||
Use when asked to run e2e tests, write new E2E tests, or debug flaky or failing
|
||||
Playwright tests. Covers running commands, avoiding race conditions, waiting
|
||||
strategies, selectors, helper functions, and CI vs local differences.
|
||||
---
|
||||
|
||||
# E2E Studio Tests
|
||||
|
||||
Run Playwright end-to-end tests for the Studio application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
Tests must be run from the `e2e/studio` directory:
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e
|
||||
```
|
||||
|
||||
### Run specific file
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e -- features/cron-jobs.spec.ts
|
||||
```
|
||||
|
||||
### Run with grep filter
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e -- --grep "test name pattern"
|
||||
```
|
||||
|
||||
### UI mode for debugging
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e -- --ui
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
- Tests auto-start Supabase local containers via web server config
|
||||
- Self-hosted mode (`IS_PLATFORM=false`) runs tests in parallel (3 workers)
|
||||
- No manual setup needed for self-hosted tests
|
||||
|
||||
## Test File Structure
|
||||
|
||||
- Tests are in `e2e/studio/features/*.spec.ts`
|
||||
- Use custom test utility: `import { test } from '../utils/test.js'`
|
||||
- Test fixtures provide `page`, `ref`, and other helpers
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Wait for elements with generous timeouts:
|
||||
|
||||
```typescript
|
||||
await expect(locator).toBeVisible({ timeout: 30000 })
|
||||
```
|
||||
|
||||
Add messages to expects for debugging:
|
||||
|
||||
```typescript
|
||||
await expect(locator).toBeVisible({ timeout: 30000 }, 'Element should be visible after page load')
|
||||
```
|
||||
|
||||
Use serial mode for tests sharing database state:
|
||||
|
||||
```typescript
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
```
|
||||
|
||||
## Writing Robust Selectors
|
||||
|
||||
### 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 })
|
||||
```
|
||||
|
||||
4. **`locator` with CSS** - Use sparingly, more fragile
|
||||
```typescript
|
||||
page.locator('[data-state="open"]')
|
||||
```
|
||||
|
||||
### 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')
|
||||
```
|
||||
|
||||
- **Broad `filter({ hasText })` on generic elements** - May match multiple elements
|
||||
```typescript
|
||||
// BAD - popover may have more than one combobox
|
||||
// Could consider scoping down the container or filtering the combobox more specifically
|
||||
popover.getByRole('combobox')
|
||||
```
|
||||
|
||||
### Add accessible labels to components
|
||||
|
||||
When a component lacks a good accessible name, add one in the source code:
|
||||
|
||||
```tsx
|
||||
// In the React component
|
||||
<Button aria-label="Configure API privileges">
|
||||
<Settings />
|
||||
</Button>
|
||||
```
|
||||
|
||||
Then use it in tests:
|
||||
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Configure API privileges' })
|
||||
```
|
||||
|
||||
### Narrowing search scope
|
||||
|
||||
Scope selectors to specific containers to avoid matching wrong elements:
|
||||
|
||||
```typescript
|
||||
// Good - scoped to side panel
|
||||
const sidePanel = page.getByTestId('table-editor-side-panel')
|
||||
const toggle = sidePanel.getByRole('switch')
|
||||
|
||||
// Good - find unique element, then scope from there
|
||||
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:
|
||||
|
||||
```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
|
||||
|
||||
// GOOD - wait for toast indicating operation complete
|
||||
await expect(page.getByText('Table created successfully')).toBeVisible({ timeout: 15000 })
|
||||
```
|
||||
|
||||
## Avoiding `force: true` on clicks
|
||||
|
||||
Instead of forcing clicks on hidden elements, make them visible first:
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await menuButton.click({ force: true })
|
||||
|
||||
// GOOD - hover to reveal, then click
|
||||
await tableRow.hover()
|
||||
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
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm exec playwright show-trace <path-to-trace.zip>
|
||||
```
|
||||
|
||||
### View HTML report
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
### Error context
|
||||
|
||||
Error context files are saved in the `test-results/` directory.
|
||||
|
||||
### Playwright MCP tools
|
||||
|
||||
Use Playwright MCP tools to inspect UI when debugging locally.
|
||||
|
||||
## CI vs Local Development
|
||||
|
||||
The key difference is **cold start vs warm state**:
|
||||
|
||||
### CI (cold start)
|
||||
|
||||
Tests run from a blank database slate. Each test run resets the database and starts fresh containers. Extensions like pg_cron are NOT enabled by default.
|
||||
|
||||
### Local dev with `pnpm dev:studio-local`
|
||||
|
||||
When debugging with a running dev server, the database may already have state from previous runs (extensions enabled, test data present).
|
||||
|
||||
## Handling Cold Start Bugs
|
||||
|
||||
Tests that work locally but fail in CI often have assumptions about existing state.
|
||||
|
||||
### Common issues
|
||||
|
||||
1. Extension not enabled (must enable in test setup)
|
||||
2. Race conditions when parallel tests try to modify shared state (use `test.describe.configure({ mode: 'serial' })`)
|
||||
3. Locators matching wrong elements because the page structure differs when state isn't set up
|
||||
|
||||
### Reproducing CI behavior locally
|
||||
|
||||
The test framework automatically resets the database when running `pnpm run e2e`. This matches CI behavior.
|
||||
|
||||
If using `pnpm dev:studio-local` for Playwright MCP debugging, remember the state differs from CI.
|
||||
|
||||
## Debugging Workflow for CI Failures
|
||||
|
||||
1. First, run the test locally with `pnpm run e2e -- features/<file>.spec.ts` (cold start)
|
||||
2. Check error context in `test-results/` directory
|
||||
3. If you need to inspect UI state, start `pnpm dev:studio-local` and use Playwright MCP tools
|
||||
4. Remember: what you see in the dev server may have state that doesn't exist in CI
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: studio-error-handling
|
||||
description: Error display and troubleshooting pattern for Supabase Studio. Use when
|
||||
showing a failed API request or query error in the UI (AlertError, toast, inline
|
||||
message), adding troubleshooting steps for a new error type, or wiring up the AI
|
||||
assistant debug button from an error state.
|
||||
---
|
||||
|
||||
# Studio Error Handling Pattern
|
||||
|
||||
Full docs and code examples: `apps/studio/components/interfaces/ErrorHandling/README.md`
|
||||
|
||||
## How it works
|
||||
|
||||
Classification happens in the **data layer**: `handleError` in `data/fetchers.ts` tests the error message against `ERROR_PATTERNS` and throws the matching error subclass (e.g. `ConnectionTimeoutError extends ResponseError`). The component (`ErrorMatcher`) reads `errorType` from the instance and does an O(1) lookup — it never does regex matching.
|
||||
|
||||
```
|
||||
handleError() → throws ConnectionTimeoutError → React Query catches → ErrorMatcher reads errorType → renders troubleshooting
|
||||
```
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `data/error-patterns.ts` | Array of `{ pattern, ErrorClass }` — the regex lives here |
|
||||
| `types/api-errors.ts` | Error classes, `KnownErrorType` union, `ClassifiedError` type |
|
||||
| `ErrorMatcher.tsx` | Component — reads `errorType`, looks up mapping, renders |
|
||||
| `error-mappings.tsx` | `Record<KnownErrorType, { id, Troubleshooting: ComponentType }>` |
|
||||
| `errorMappings/ConnectionTimeout.tsx` | Reference troubleshooting component |
|
||||
| `TroubleshootingSections.tsx` | Reusable accordion section components |
|
||||
| `TroubleshootingAccordion.tsx` | Accordion wrapper with telemetry |
|
||||
|
||||
## Usage
|
||||
|
||||
Pass the **full error object** from React Query — not `error.message`:
|
||||
|
||||
```tsx
|
||||
{
|
||||
isError && (
|
||||
<ErrorMatcher title="Failed to load tables" error={error} supportFormParams={{ projectRef }} />
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do not pass `error.message` to `ErrorMatcher` — pass the full `error` object so the class is preserved.
|
||||
- Do not put regex patterns in `error-mappings.tsx` — they belong in `data/error-patterns.ts`.
|
||||
- Do not use `Object.assign` to stamp `errorType` — throw a proper subclass instead.
|
||||
- Do not pass a raw URL string for support — use `supportFormParams={{ projectRef }}`.
|
||||
- Do not put the page title inside the error mapping — it belongs on the `<ErrorMatcher>` caller.
|
||||
- Do not add callback props (`onDebugWithAI`, `onRestartProject`) to troubleshooting components — use hooks inside them instead.
|
||||
@@ -1,294 +0,0 @@
|
||||
---
|
||||
name: studio-mock-api-tests
|
||||
description: Component tests for Supabase Studio that mock API requests at the
|
||||
network layer with MSW. Use when writing or reviewing a component test that
|
||||
exercises a React Query hook or mutation, or when migrating an existing
|
||||
test away from vi.mock('@/data/...'). Covers the customRender + addAPIMock
|
||||
template and the jsdom/MSW gotchas that cost real debugging time.
|
||||
---
|
||||
|
||||
# Studio MSW component tests
|
||||
|
||||
Mount a Studio component, intercept its network calls with MSW, assert
|
||||
what renders and what gets sent. The infrastructure is already wired up —
|
||||
this skill is the working template plus the gotchas.
|
||||
|
||||
## When to use
|
||||
|
||||
- The component (or any descendant it renders) calls a React Query hook
|
||||
or mutation that hits `/platform/...`, `/v1/...`, or another endpoint
|
||||
in `apps/studio/data/api.d.ts`.
|
||||
- You'd otherwise be tempted to write `vi.mock('@/data/some-query', ...)`.
|
||||
**Don't.** Mock the network instead — see "Why not vi.mock" below.
|
||||
|
||||
If the component is purely presentational with no data fetching, you
|
||||
don't need MSW; render and assert directly.
|
||||
|
||||
## The template
|
||||
|
||||
```tsx
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { MyComponent } from './MyComponent'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
|
||||
// Needed if the component renders inside a Sheet, Modal, Popover, or
|
||||
// anything else built on Radix that uses Web Animations.
|
||||
mockAnimationsApi()
|
||||
|
||||
describe('MyComponent', () => {
|
||||
test('renders rows from the API', async () => {
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/organizations',
|
||||
response: () =>
|
||||
HttpResponse.json<OrganizationResponse[]>([
|
||||
{
|
||||
/* ... */
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
customRender(<MyComponent />)
|
||||
|
||||
expect(await screen.findByText('Acme')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
That's the whole pattern. Server lifecycle (`listen`/`resetHandlers`/
|
||||
`close`) is handled by `apps/studio/tests/vitestSetup.ts` — handlers
|
||||
registered via `addAPIMock` are scoped to the current test.
|
||||
|
||||
## Gotchas that will eat your afternoon
|
||||
|
||||
### 1. Path params use `:slug`, not `{slug}`
|
||||
|
||||
`addAPIMock` is typed from the OpenAPI `paths`, but path params are
|
||||
remapped to MSW's `:param` format. Autocomplete will guide you, but if
|
||||
typecheck reports the path isn't assignable, you're using the OpenAPI
|
||||
`{slug}` form.
|
||||
|
||||
```ts
|
||||
// ❌ TypeScript error, MSW won't match
|
||||
path: '/platform/organizations/{slug}/projects'
|
||||
|
||||
// ✅ Correct
|
||||
path: '/platform/organizations/:slug/projects'
|
||||
```
|
||||
|
||||
### 2. Use `HttpResponse.json`, not `new HttpResponse`
|
||||
|
||||
For success responses, always go through `HttpResponse.json` — even for
|
||||
204/201-no-content endpoints. A raw `new HttpResponse(null, { status: 201 })`
|
||||
returns no content-type, and `openapi-fetch` can hang the mutation flow,
|
||||
which silently breaks `onSuccess` callbacks.
|
||||
|
||||
```ts
|
||||
// ❌ Mutation onSuccess silently never fires
|
||||
response: () => new HttpResponse(null, { status: 201 })
|
||||
|
||||
// ✅ Works (pass the OpenAPI body shape explicitly — see gotcha #8)
|
||||
response: () => HttpResponse.json<MyResponse>({}, { status: 201 })
|
||||
```
|
||||
|
||||
### 3. Submit buttons in Sheets/Modals need `fireEvent.click`
|
||||
|
||||
The convention `<Button form={FORM_ID} type="submit" />` (button
|
||||
outside the form, associated by id) doesn't reliably trigger submission
|
||||
under `userEvent.click` in jsdom. Use `fireEvent.click` for the submit
|
||||
button. Continue to use `userEvent.type` for inputs.
|
||||
|
||||
```ts
|
||||
await userEvent.type(screen.getByPlaceholderText('value'), 'hello')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Save' }))
|
||||
```
|
||||
|
||||
### 4. Profile-gated queries need a `profileContext`
|
||||
|
||||
Many hooks (`useOrganizationsQuery`, anything in `data/projects/`,
|
||||
anything that calls `useProfile`) refuse to fire until a profile is
|
||||
loaded. Pass one explicitly:
|
||||
|
||||
```ts
|
||||
import type { ProfileContextType } from '@/lib/profile'
|
||||
|
||||
const PROFILE_CONTEXT: ProfileContextType = {
|
||||
profile: {
|
||||
id: 1,
|
||||
auth0_id: 'auth0|test',
|
||||
gotrue_id: 'gotrue-test',
|
||||
username: 'testuser',
|
||||
primary_email: 'test@example.com',
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
mobile: null,
|
||||
is_alpha_user: false,
|
||||
is_sso_user: false,
|
||||
disabled_features: [],
|
||||
free_project_limit: null,
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
}
|
||||
|
||||
customRender(<MyComponent />, { profileContext: PROFILE_CONTEXT })
|
||||
```
|
||||
|
||||
### 5. `useParams` is globally mocked to `{ ref: 'default' }`
|
||||
|
||||
You don't need to mock the Next router for project-scoped components.
|
||||
Just use `'default'` as the project ref in your mock paths:
|
||||
`/v1/projects/default/secrets`, `/platform/projects/default/...`. If
|
||||
you need a different ref, override with `routerMock.setCurrentUrl(...)`
|
||||
(see `apps/studio/tests/lib/route-mock.ts`).
|
||||
|
||||
### 6. Unhandled requests fail loudly — mock every endpoint a render triggers
|
||||
|
||||
`mswServer.listen({ onUnhandledRequest: 'error' })` is set globally. If a
|
||||
component (or any child it renders) fires an unmocked request, you'll see
|
||||
MSW errors in stderr and likely flaky behavior. Cards, lists, and details
|
||||
panels often fire nested queries (e.g. `OrganizationCard` calls
|
||||
`useOrgProjectsInfiniteQuery`) — read what the rendered subtree does and
|
||||
mock all of it, or stub it with `vi.mock` for nested components only.
|
||||
|
||||
### 7. Don't put query strings in the handler `path`
|
||||
|
||||
`addAPIMock` accepts `?foo=bar` suffixes via `TrimQueryParams`, but the
|
||||
helper strips them before matching. MSW v2 doesn't match query params via
|
||||
path strings — read them inside the resolver instead:
|
||||
|
||||
```ts
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects',
|
||||
response: ({ request }) => {
|
||||
const limit = new URL(request.url).searchParams.get('limit')
|
||||
// ...
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 8. Always pass an explicit generic to `HttpResponse.json`
|
||||
|
||||
`addAPIMock`'s resolver is typed against the OpenAPI success body (and the
|
||||
standard `{ message: string }` error envelope, exported as `APIErrorBody`).
|
||||
But MSW's `HttpResponse.json` uses `NoInfer`, so the body type doesn't
|
||||
narrow from context. Pass the expected shape explicitly — it doubles as a
|
||||
self-documenting contract assertion:
|
||||
|
||||
```ts
|
||||
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
||||
|
||||
response: () => HttpResponse.json<OrganizationResponse[]>([...])
|
||||
response: () =>
|
||||
HttpResponse.json<APIErrorBody>({ message: 'Boom' }, { status: 500 })
|
||||
```
|
||||
|
||||
A mock that drifts from the contract (wrong envelope, missing fields,
|
||||
stale enum values) now fails at compile time, not at runtime. The cost is
|
||||
one type annotation per resolver — well worth it.
|
||||
|
||||
For mocks at the network boundary, also prefer `createMockOrganizationResponse`
|
||||
(returns the raw OpenAPI `OrganizationResponse`) over `createMockOrganization`
|
||||
(which extends with frontend-derived `managed_by` / `partner_id` that the
|
||||
query layer attaches). Same pattern applies to any type that's a frontend
|
||||
extension of an OpenAPI schema: build a `createMockXResponse` helper that
|
||||
returns the raw API shape.
|
||||
|
||||
## Prefer asserting on UI state
|
||||
|
||||
MSW's own best-practices doc explicitly recommends asserting on what
|
||||
renders, not on whether a handler was called. The "did the form
|
||||
submit?" question is best answered by `expect(onClose).toHaveBeenCalled()`
|
||||
or by `findByText('Saved')` — not by spying on the resolver.
|
||||
|
||||
There's one legitimate exception: **the request body itself is the
|
||||
contract you care about**, and the server's reply doesn't reflect it
|
||||
back. Bulk-create endpoints (like `POST /v1/projects/:ref/secrets`) are
|
||||
the canonical case — 201 with no body, so the only way to verify the
|
||||
shape sent is to capture it:
|
||||
|
||||
```ts
|
||||
const requests: Array<{ ref: string | undefined; body: unknown }> = []
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/v1/projects/:ref/secrets',
|
||||
response: async ({ request, params }) => {
|
||||
requests.push({ ref: params.ref as string | undefined, body: await request.json() })
|
||||
return HttpResponse.json<CreateSecretsResponse>({}, { status: 201 })
|
||||
},
|
||||
})
|
||||
|
||||
// ... drive the UI ...
|
||||
|
||||
expect(requests).toEqual([{ ref: 'default', body: [{ name: 'API_KEY', value: 'new-value' }] }])
|
||||
```
|
||||
|
||||
When in doubt, assert on the UI first; reach for request capture only
|
||||
when the UI doesn't observably encode the contract.
|
||||
|
||||
## Debugging an MSW test
|
||||
|
||||
If a request isn't being matched, wire up MSW's lifecycle events at the
|
||||
top of the test file (or temporarily in `msw.ts`):
|
||||
|
||||
```ts
|
||||
import { mswServer } from '@/tests/lib/msw'
|
||||
|
||||
mswServer.events.on('request:unhandled', ({ request }) => {
|
||||
console.log('[MSW] UNHANDLED:', request.method, request.url)
|
||||
})
|
||||
mswServer.events.on('response:mocked', ({ request, response }) => {
|
||||
console.log('[MSW] MATCHED:', request.method, request.url, response.status)
|
||||
})
|
||||
```
|
||||
|
||||
`request:start` is already wired in `msw.ts`. Add `request:unhandled` and
|
||||
`response:mocked` locally when a test misbehaves — usually surfaces a
|
||||
path-param mismatch or a nested query you forgot to mock.
|
||||
|
||||
## Why not `vi.mock('@/data/...')`
|
||||
|
||||
It bypasses the network boundary, so:
|
||||
|
||||
- It hides real bugs: a renamed query key or a changed request payload
|
||||
passes the test, then breaks in production.
|
||||
- It doesn't exercise React Query's caching, retry, or invalidation
|
||||
paths — `onMutate`, `onSuccess`, and `onError` callbacks won't run as
|
||||
they do in real life. ([tkdodo.eu/blog/testing-react-query](https://tkdodo.eu/blog/testing-react-query))
|
||||
- It drifts independently from the OpenAPI types — handlers stay in sync,
|
||||
module-level mocks don't.
|
||||
|
||||
Reach for `vi.mock` only for non-network concerns: a heavy child
|
||||
component (e.g. a Monaco editor) you want to stub, or a `common`-package
|
||||
hook with global state.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [TkDodo — Testing React Query](https://tkdodo.eu/blog/testing-react-query) —
|
||||
canonical reference for the principles behind everything in this skill.
|
||||
- [MSW best practices: structuring handlers](https://mswjs.io/docs/best-practices/structuring-handlers/)
|
||||
and [overriding network behavior](https://mswjs.io/docs/best-practices/network-behavior-overrides/) —
|
||||
the baseline-handlers + per-test-`server.use()` pattern.
|
||||
- [MSW best practices: avoid request assertions](https://mswjs.io/docs/best-practices/avoid-request-assertions/) —
|
||||
the source of the "assert on UI state" guidance above.
|
||||
|
||||
## Codebase references
|
||||
|
||||
| What | Where |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| Query-only example (loading, error, success) | `apps/studio/components/interfaces/Organization/OrgNotFound.test.tsx` |
|
||||
| Mutation example (form, payload assertion) | `apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.test.tsx` |
|
||||
| SQL-via-pg-meta example (POST resolver branch on `query` body) | `apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx` |
|
||||
| `addAPIMock` source | `apps/studio/tests/lib/msw.ts` |
|
||||
| `customRender` source | `apps/studio/tests/lib/custom-render.tsx` |
|
||||
| Global handlers + lifecycle | `apps/studio/tests/lib/msw-global-api-mocks.ts`, `apps/studio/tests/vitestSetup.ts` |
|
||||
| Related skills | `studio-testing` (when to write a component test at all), `studio-queries` (hook conventions), `vitest` |
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
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/
|
||||
— including adding the first fetch or mutation for a new API endpoint or resource.
|
||||
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
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
name: studio-testing
|
||||
description: Testing strategy for Supabase Studio. Use when writing tests, deciding
|
||||
whether a change needs tests and which type, extracting logic from components into
|
||||
testable utility functions, or reviewing test coverage. Covers unit tests, component
|
||||
tests, and E2E test selection criteria.
|
||||
---
|
||||
|
||||
# Studio Testing Strategy
|
||||
|
||||
How to write and structure tests for `apps/studio/`. The core principle: push
|
||||
logic out of React components into pure utility functions, then test those
|
||||
functions exhaustively. Only use component tests for complex UI interactions.
|
||||
Use E2E tests for features shared between self-hosted and platform.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Writing new tests for Studio code
|
||||
- Deciding which type of test to write (unit, component, E2E)
|
||||
- Extracting logic from a component to make it testable
|
||||
- Reviewing whether test coverage is sufficient
|
||||
- Adding a new feature that needs tests
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ---------------- | -------- | ---------- |
|
||||
| 1 | Logic Extraction | CRITICAL | `testing-` |
|
||||
| 2 | Test Coverage | CRITICAL | `testing-` |
|
||||
| 3 | Component Tests | HIGH | `testing-` |
|
||||
| 4 | E2E Tests | HIGH | `testing-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Logic Extraction (CRITICAL)
|
||||
|
||||
- `testing-extract-logic` - Remove logic from components into `.utils.ts` files
|
||||
as pure functions: args in, return out
|
||||
|
||||
### 2. Test Coverage (CRITICAL)
|
||||
|
||||
- `testing-exhaustive-permutations` - Test every permutation of utility functions:
|
||||
happy path, malformed input, empty values, edge cases
|
||||
|
||||
### 3. Component Tests (HIGH)
|
||||
|
||||
- `testing-component-tests-ui-only` - Only write component tests for complex UI
|
||||
interaction logic, not business logic
|
||||
|
||||
### 4. E2E Tests (HIGH)
|
||||
|
||||
- `testing-e2e-shared-features` - Write E2E tests for features used in both
|
||||
self-hosted and platform; cover clicks AND keyboard shortcuts
|
||||
|
||||
## Decision Tree: Which Test Type?
|
||||
|
||||
```
|
||||
Is the logic a pure transformation (parse, format, validate, compute)?
|
||||
YES -> Extract to .utils.ts, write unit test with vitest
|
||||
NO -> Does the feature involve complex UI interactions?
|
||||
YES -> Is it used in both self-hosted and platform?
|
||||
YES -> Write E2E test in e2e/studio/features/
|
||||
NO -> Write component test with customRender
|
||||
NO -> Can you extract the logic to make it pure?
|
||||
YES -> Do that, then unit test it
|
||||
NO -> Write a component test
|
||||
```
|
||||
|
||||
## 1. Extract Logic Into Utility Files (CRITICAL)
|
||||
|
||||
Remove as much logic from components as possible. Put it in co-located
|
||||
`.utils.ts` files as pure functions: arguments in, return value out.
|
||||
|
||||
**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)
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Test Every Permutation (CRITICAL)
|
||||
|
||||
Once logic is extracted, test exhaustively. Every code path needs a test:
|
||||
|
||||
- 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)
|
||||
|
||||
```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 | `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,137 +0,0 @@
|
||||
---
|
||||
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`; import primitives from `ui`
|
||||
|
||||
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)
|
||||
- When the sheet contains a form, wire dirty dismissal with `useConfirmOnClose` +
|
||||
`DiscardChangesConfirmationDialog` (Cancel, Escape, and backdrop). Source of
|
||||
truth: `apps/design-system/content/docs/ui-patterns/modality.mdx` (Dirty form
|
||||
dismissal). Also see the react-hook-form skill for `isDirty` destructuring.
|
||||
|
||||
## Copy
|
||||
|
||||
Source of truth: `apps/design-system/content/docs/copywriting.mdx` — sentence case, title case, proper nouns, voice and tone.
|
||||
|
||||
When changing visible copy, grep `e2e/studio/` for the old string.
|
||||
@@ -1,178 +0,0 @@
|
||||
---
|
||||
name: telemetry-standards
|
||||
description: PostHog event tracking standards for Supabase Studio. Use when adding
|
||||
useTrack() calls, defining events in packages/common/telemetry-constants.ts,
|
||||
implementing tracking for a new feature, or reviewing PRs for telemetry compliance.
|
||||
Covers event naming, property conventions, approved patterns, and implementation
|
||||
guide.
|
||||
---
|
||||
|
||||
# Telemetry Standards for Supabase Studio
|
||||
|
||||
Standards for PostHog event tracking in `apps/studio/`. Apply these when
|
||||
reviewing PRs that touch tracking or when implementing new tracking.
|
||||
|
||||
## Event Naming
|
||||
|
||||
**Format:** `[object]_[verb]` in snake_case
|
||||
|
||||
**Approved verbs only** (canonical list — derived from `packages/common/telemetry-constants.ts`):
|
||||
opened, clicked, submitted, created, removed, updated, intended, evaluated, added,
|
||||
enabled, disabled, copied, exposed, failed, converted, closed, completed, applied, sent, moved
|
||||
|
||||
**Flag these:**
|
||||
|
||||
- Unapproved verbs (saved, viewed, seen, pressed, etc.)
|
||||
- Wrong order: `click_product_card` → should be `product_card_clicked`
|
||||
- Wrong casing: `productCardClicked` → should be `product_card_clicked`
|
||||
|
||||
**Good examples:**
|
||||
|
||||
- `product_card_clicked`
|
||||
- `backup_button_clicked`
|
||||
- `sql_query_submitted`
|
||||
|
||||
**Common mistakes with corrections:**
|
||||
|
||||
- `database_saved` → `save_button_clicked` or `database_updated` (unapproved verb)
|
||||
- `click_backup_button` → `backup_button_clicked` (wrong order)
|
||||
- `dashboardViewed` → don't track passive views on page load
|
||||
- `component_rendered` → don't track — no user interaction
|
||||
|
||||
## Property Standards
|
||||
|
||||
**Casing:** camelCase preferred for new events. The codebase has existing snake_case properties (e.g., `schema_name`, `table_name`) — when adding properties to an existing event, match its established convention.
|
||||
|
||||
**Names must be self-explanatory:**
|
||||
|
||||
- `{ productType: 'database', planTier: 'pro' }`
|
||||
- `{ assistantType: 'sql', suggestionType: 'optimization' }`
|
||||
|
||||
**Flag these:**
|
||||
|
||||
- Generic names: `label`, `value`, `name`, `data`
|
||||
- PascalCase properties
|
||||
- Inconsistent names across similar events (e.g., `assistantType` in one event, `aiType` in a related event)
|
||||
- Mixing camelCase and snake_case within the same event
|
||||
|
||||
## What NOT to Track
|
||||
|
||||
- Passive views/renders on page load (`dashboard_viewed`, `sidebar_appeared`, `page_loaded`)
|
||||
- Component appearances without user interaction
|
||||
- Generic "viewed" or "seen" events — already captured by pageview events
|
||||
|
||||
**DO track:** user clicks, form submissions, explicit opens/closes, user-initiated actions.
|
||||
|
||||
**Exception:** `_exposed` events for A/B experiment exposure tracking are valid even though they fire on render.
|
||||
|
||||
**Never track PII** (emails, names, IPs, etc.) in event properties.
|
||||
|
||||
## Required Pattern
|
||||
|
||||
Import `useTrack` from `lib/telemetry/track` (within `apps/studio/`). Never use `useSendEventMutation` (deprecated).
|
||||
|
||||
```typescript
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
|
||||
const MyComponent = () => {
|
||||
const track = useTrack()
|
||||
|
||||
const handleClick = () => {
|
||||
track('product_card_clicked', {
|
||||
productType: 'database',
|
||||
planTier: 'pro',
|
||||
source: 'dashboard',
|
||||
})
|
||||
}
|
||||
|
||||
return <button onClick={handleClick}>Click me</button>
|
||||
}
|
||||
```
|
||||
|
||||
## Event Definitions
|
||||
|
||||
All events must be defined as TypeScript interfaces in `packages/common/telemetry-constants.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* [Event description]
|
||||
*
|
||||
* @group Events
|
||||
* @source [what triggers this event]
|
||||
*/
|
||||
export interface MyFeatureClickedEvent {
|
||||
action: 'my_feature_clicked'
|
||||
properties: {
|
||||
/** Description of property */
|
||||
featureType: string
|
||||
}
|
||||
groups: TelemetryGroups
|
||||
}
|
||||
```
|
||||
|
||||
Add the new interface to the `TelemetryEvent` union type so `useTrack` picks it up.
|
||||
`@group Events` and `@source` must be accurate.
|
||||
|
||||
## Review Rules
|
||||
|
||||
When reviewing a PR, flag these as **required changes:**
|
||||
|
||||
1. **Naming violations** — event not following `[object]_[verb]` snake_case, or using an unapproved verb
|
||||
2. **Property violations** — not camelCase, generic names, or inconsistent with similar events
|
||||
3. **Deprecated hook** — any usage of `useSendEventMutation` instead of `useTrack`
|
||||
4. **Unnecessary view tracking** — events that fire on page load without user interaction
|
||||
5. **Inaccurate docs** — `@page`/`@source` descriptions that don't match the actual implementation
|
||||
|
||||
When a PR adds user-facing interactions (buttons, forms, toggles, modals) **without** tracking, suggest:
|
||||
|
||||
- "This adds a user interaction that may benefit from tracking."
|
||||
- Propose the event name following `[object]_[verb]` convention
|
||||
- Propose the `useTrack()` call with suggested properties
|
||||
|
||||
When checking property consistency, search `packages/common/telemetry-constants.ts` for similar events and verify property names match.
|
||||
|
||||
## Well-Formed Event Examples
|
||||
|
||||
From the actual codebase:
|
||||
|
||||
```typescript
|
||||
// User copies a connection string
|
||||
track('connection_string_copied', {
|
||||
connectionType: 'psql',
|
||||
connectionMethod: 'transaction_pooler',
|
||||
connectionTab: 'Connection String',
|
||||
})
|
||||
|
||||
// User enables a feature preview
|
||||
track('feature_preview_enabled', {
|
||||
feature: 'realtime_inspector',
|
||||
})
|
||||
|
||||
// User clicks a banner CTA
|
||||
track('index_advisor_banner_dismiss_button_clicked')
|
||||
|
||||
// Experiment exposure (fires on render — valid exception)
|
||||
track('home_new_experiment_exposed', {
|
||||
variant: 'treatment',
|
||||
})
|
||||
```
|
||||
|
||||
## Implementing New Tracking
|
||||
|
||||
To add tracking for a user action:
|
||||
|
||||
1. **Name the event** — `[object]_[verb]` using approved verbs only
|
||||
2. **Choose properties** — camelCase preferred for new events; check `packages/common/telemetry-constants.ts` for similar events and match their property names and casing
|
||||
3. **Add interface to telemetry-constants.ts** — with `@group Events` and `@source` JSDoc, add to the `TelemetryEvent` union type
|
||||
4. **Add to component** — `import { useTrack } from 'lib/telemetry/track'`, call `track('event_name', { properties })`
|
||||
|
||||
### Verification checklist
|
||||
|
||||
- [ ] Event name follows `[object]_[verb]` with approved verb
|
||||
- [ ] Event name is snake_case
|
||||
- [ ] Properties are camelCase and self-explanatory
|
||||
- [ ] Event defined in telemetry-constants.ts with accurate `@page`/`@source`
|
||||
- [ ] Using `useTrack` hook (not `useSendEventMutation`)
|
||||
- [ ] Not tracking passive views/appearances
|
||||
- [ ] No PII in event properties (emails, names, IPs, etc.)
|
||||
- [ ] Property names consistent with similar events
|
||||
@@ -1,946 +0,0 @@
|
||||
# React Composition Patterns
|
||||
|
||||
**Version 1.0.0**
|
||||
Engineering
|
||||
January 2026
|
||||
|
||||
> **Note:**
|
||||
> This document is mainly for agents and LLMs to follow when maintaining,
|
||||
> generating, or refactoring React codebases using composition. Humans
|
||||
> may also find it useful, but guidance here is optimized for automation
|
||||
> and consistency by AI-assisted workflows.
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Component Architecture](#1-component-architecture) — **HIGH**
|
||||
- 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation)
|
||||
- 1.2 [Use Compound Components](#12-use-compound-components)
|
||||
2. [State Management](#2-state-management) — **MEDIUM**
|
||||
- 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui)
|
||||
- 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection)
|
||||
- 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components)
|
||||
3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM**
|
||||
- 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants)
|
||||
- 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props)
|
||||
4. [React 19 APIs](#4-react-19-apis) — **MEDIUM**
|
||||
- 4.1 [React 19 API Changes](#41-react-19-api-changes)
|
||||
|
||||
---
|
||||
|
||||
## 1. Component Architecture
|
||||
|
||||
**Impact: HIGH**
|
||||
|
||||
Fundamental patterns for structuring components to avoid prop
|
||||
proliferation and enable flexible composition.
|
||||
|
||||
### 1.1 Avoid Boolean Prop Proliferation
|
||||
|
||||
**Impact: CRITICAL (prevents unmaintainable component variants)**
|
||||
|
||||
Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
|
||||
|
||||
component behavior. Each boolean doubles possible states and creates
|
||||
|
||||
unmaintainable conditional logic. Use composition instead.
|
||||
|
||||
**Incorrect: boolean props create exponential complexity**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
onSubmit,
|
||||
isThread,
|
||||
channelId,
|
||||
isDMThread,
|
||||
dmId,
|
||||
isEditing,
|
||||
isForwarding,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
<Header />
|
||||
<Input />
|
||||
{isDMThread ? (
|
||||
<AlsoSendToDMField id={dmId} />
|
||||
) : isThread ? (
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<EditActions />
|
||||
) : isForwarding ? (
|
||||
<ForwardActions />
|
||||
) : (
|
||||
<DefaultActions />
|
||||
)}
|
||||
<Footer onSubmit={onSubmit} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: composition eliminates conditionals**
|
||||
|
||||
```tsx
|
||||
// Channel composer
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Attachments />
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Thread composer - adds "also send to channel" field
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit composer - different footer actions
|
||||
function EditComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about what it renders. We can share internals without
|
||||
|
||||
sharing a single monolithic parent.
|
||||
|
||||
### 1.2 Use Compound Components
|
||||
|
||||
**Impact: HIGH (enables flexible composition without prop drilling)**
|
||||
|
||||
Structure complex components as compound components with a shared context. Each
|
||||
|
||||
subcomponent accesses shared state via context, not props. Consumers compose the
|
||||
|
||||
pieces they need.
|
||||
|
||||
**Incorrect: monolithic component with render props**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
showAttachments,
|
||||
showFormatting,
|
||||
showEmojis,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{showAttachments && <Attachments />}
|
||||
{renderFooter ? (
|
||||
renderFooter()
|
||||
) : (
|
||||
<Footer>
|
||||
{showFormatting && <Formatting />}
|
||||
{showEmojis && <Emojis />}
|
||||
{renderActions?.()}
|
||||
</Footer>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: compound components with shared context**
|
||||
|
||||
```tsx
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
|
||||
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
|
||||
return (
|
||||
<ComposerContext value={{ state, actions, meta }}>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta: { inputRef },
|
||||
} = use(ComposerContext)
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerSubmit() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Send</Button>
|
||||
}
|
||||
|
||||
// Export as compound component
|
||||
const Composer = {
|
||||
Provider: ComposerProvider,
|
||||
Frame: ComposerFrame,
|
||||
Input: ComposerInput,
|
||||
Submit: ComposerSubmit,
|
||||
Header: ComposerHeader,
|
||||
Footer: ComposerFooter,
|
||||
Attachments: ComposerAttachments,
|
||||
Formatting: ComposerFormatting,
|
||||
Emojis: ComposerEmojis,
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
<Composer.Provider state={state} actions={actions} meta={meta}>
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</Composer.Provider>
|
||||
```
|
||||
|
||||
Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
|
||||
|
||||
---
|
||||
|
||||
## 2. State Management
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Patterns for lifting state and managing shared context across
|
||||
composed components.
|
||||
|
||||
### 2.1 Decouple State Management from UI
|
||||
|
||||
**Impact: MEDIUM (enables swapping state implementations without changing UI)**
|
||||
|
||||
The provider component should be the only place that knows how state is managed.
|
||||
|
||||
UI components consume the context interface—they don't know if state comes from
|
||||
|
||||
useState, Zustand, or a server sync.
|
||||
|
||||
**Incorrect: UI coupled to state implementation**
|
||||
|
||||
```tsx
|
||||
function ChannelComposer({ channelId }: { channelId: string }) {
|
||||
// UI component knows about global state implementation
|
||||
const state = useGlobalChannelState(channelId)
|
||||
const { submit, updateInput } = useChannelSync(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input
|
||||
value={state.input}
|
||||
onChange={(text) => sync.updateInput(text)}
|
||||
/>
|
||||
<Composer.Submit onPress={() => sync.submit()} />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: state management isolated in provider**
|
||||
|
||||
```tsx
|
||||
// Provider handles all state management details
|
||||
function ChannelProvider({
|
||||
channelId,
|
||||
children,
|
||||
}: {
|
||||
channelId: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update, submit }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// UI component only knows about the context interface
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Channel({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ChannelProvider channelId={channelId}>
|
||||
<ChannelComposer />
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers, same UI:**
|
||||
|
||||
```tsx
|
||||
// Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Provider state={state} actions={{ update, submit }}>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The same `Composer.Input` component works with both providers because it only
|
||||
|
||||
depends on the context interface, not the implementation.
|
||||
|
||||
### 2.2 Define Generic Context Interfaces for Dependency Injection
|
||||
|
||||
**Impact: HIGH (enables dependency-injectable state across use-cases)**
|
||||
|
||||
Define a **generic interface** for your component context with three parts:
|
||||
|
||||
`state`, `actions`, and `meta`. This interface is a contract that any provider
|
||||
|
||||
can implement—enabling the same UI components to work with completely different
|
||||
|
||||
state implementations.
|
||||
|
||||
**Core principle:** Lift state, compose internals, make state
|
||||
|
||||
dependency-injectable.
|
||||
|
||||
**Incorrect: UI coupled to specific state implementation**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
// Tightly coupled to a specific hook
|
||||
const { input, setInput } = useChannelComposerState()
|
||||
return <TextInput value={input} onChangeText={setInput} />
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: generic interface enables dependency injection**
|
||||
|
||||
```tsx
|
||||
// Define a GENERIC interface that any provider can implement
|
||||
interface ComposerState {
|
||||
input: string
|
||||
attachments: Attachment[]
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
interface ComposerActions {
|
||||
update: (updater: (state: ComposerState) => ComposerState) => void
|
||||
submit: () => void
|
||||
}
|
||||
|
||||
interface ComposerMeta {
|
||||
inputRef: React.RefObject<TextInput>
|
||||
}
|
||||
|
||||
interface ComposerContextValue {
|
||||
state: ComposerState
|
||||
actions: ComposerActions
|
||||
meta: ComposerMeta
|
||||
}
|
||||
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
```
|
||||
|
||||
**UI components consume the interface, not the implementation:**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta,
|
||||
} = use(ComposerContext)
|
||||
|
||||
// This component works with ANY provider that implements the interface
|
||||
return (
|
||||
<TextInput
|
||||
ref={meta.inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers implement the same interface:**
|
||||
|
||||
```tsx
|
||||
// Provider A: Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const inputRef = useRef(null)
|
||||
const submit = useForwardMessage()
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update: setState, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
// Provider B: Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }: Props) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**The same composed UI works with both:**
|
||||
|
||||
```tsx
|
||||
// Works with ForwardMessageProvider (local state)
|
||||
<ForwardMessageProvider>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
|
||||
// Works with ChannelProvider (global synced state)
|
||||
<ChannelProvider channelId="abc">
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ChannelProvider>
|
||||
```
|
||||
|
||||
**Custom UI outside the component can access state and actions:**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
{/* The composer UI */}
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
|
||||
{/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
|
||||
<MessagePreview />
|
||||
|
||||
{/* Actions at the bottom of the dialog */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
|
||||
function ForwardButton() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Forward</Button>
|
||||
}
|
||||
|
||||
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
|
||||
function MessagePreview() {
|
||||
const { state } = use(ComposerContext)
|
||||
return <Preview message={state.input} attachments={state.attachments} />
|
||||
}
|
||||
```
|
||||
|
||||
The provider boundary is what matters—not the visual nesting. Components that
|
||||
|
||||
need shared state don't have to be inside the `Composer.Frame`. They just need
|
||||
|
||||
to be within the provider.
|
||||
|
||||
The `ForwardButton` and `MessagePreview` are not visually inside the composer
|
||||
|
||||
box, but they can still access its state and actions. This is the power of
|
||||
|
||||
lifting state into providers.
|
||||
|
||||
The UI is reusable bits you compose together. The state is dependency-injected
|
||||
|
||||
by the provider. Swap the provider, keep the UI.
|
||||
|
||||
### 2.3 Lift State into Provider Components
|
||||
|
||||
**Impact: HIGH (enables state sharing outside component boundaries)**
|
||||
|
||||
Move state management into dedicated provider components. This allows sibling
|
||||
|
||||
components outside the main UI to access and modify state without prop drilling
|
||||
|
||||
or awkward refs.
|
||||
|
||||
**Incorrect: state trapped inside component**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageComposer() {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Problem: How does this button access composer state?
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Needs composer state */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Needs to call submit */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect: useEffect to sync state up**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const [input, setInput] = useState('')
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer onInputChange={setInput} />
|
||||
<MessagePreview input={input} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ onInputChange }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
useEffect(() => {
|
||||
onInputChange(state.input) // Sync on every change 😬
|
||||
}, [state.input])
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect: reading state from ref on submit**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const stateRef = useRef(null)
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer stateRef={stateRef} />
|
||||
<ForwardButton onPress={() => submit(stateRef.current)} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct: state lifted to provider**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Custom components can access state and actions */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Custom components can access state and actions */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardButton() {
|
||||
const { actions } = use(Composer.Context)
|
||||
return <Button onPress={actions.submit}>Forward</Button>
|
||||
}
|
||||
```
|
||||
|
||||
The ForwardButton lives outside the Composer.Frame but still has access to the
|
||||
|
||||
submit action because it's within the provider. Even though it's a one-off
|
||||
|
||||
component, it can still access the composer's state and actions from outside the
|
||||
|
||||
UI itself.
|
||||
|
||||
**Key insight:** Components that need shared state don't have to be visually
|
||||
|
||||
nested inside each other—they just need to be within the same provider.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation Patterns
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
Specific techniques for implementing compound components and
|
||||
context providers.
|
||||
|
||||
### 3.1 Create Explicit Component Variants
|
||||
|
||||
**Impact: MEDIUM (self-documenting code, no hidden conditionals)**
|
||||
|
||||
Instead of one component with many boolean props, create explicit variant
|
||||
|
||||
components. Each variant composes the pieces it needs. The code documents
|
||||
|
||||
itself.
|
||||
|
||||
**Incorrect: one component, many modes**
|
||||
|
||||
```tsx
|
||||
// What does this component actually render?
|
||||
<Composer
|
||||
isThread
|
||||
isEditing={false}
|
||||
channelId='abc'
|
||||
showAttachments
|
||||
showFormatting={false}
|
||||
/>
|
||||
```
|
||||
|
||||
**Correct: explicit variants**
|
||||
|
||||
```tsx
|
||||
// Immediately clear what this renders
|
||||
<ThreadComposer channelId="abc" />
|
||||
|
||||
// Or
|
||||
<EditMessageComposer messageId="xyz" />
|
||||
|
||||
// Or
|
||||
<ForwardMessageComposer messageId="123" />
|
||||
```
|
||||
|
||||
Each implementation is unique, explicit and self-contained. Yet they can each
|
||||
|
||||
use shared parts.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```tsx
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ThreadProvider channelId={channelId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField channelId={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ThreadProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function EditMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<EditMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</EditMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<ForwardMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Mentions />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about:
|
||||
|
||||
- What provider/state it uses
|
||||
|
||||
- What UI elements it includes
|
||||
|
||||
- What actions are available
|
||||
|
||||
No boolean prop combinations to reason about. No impossible states.
|
||||
|
||||
### 3.2 Prefer Composing Children Over Render Props
|
||||
|
||||
**Impact: MEDIUM (cleaner composition, better readability)**
|
||||
|
||||
Use `children` for composition instead of `renderX` props. Children are more
|
||||
|
||||
readable, compose naturally, and don't require understanding callback
|
||||
|
||||
signatures.
|
||||
|
||||
**Incorrect: render props**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
}: {
|
||||
renderHeader?: () => React.ReactNode
|
||||
renderFooter?: () => React.ReactNode
|
||||
renderActions?: () => React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{renderFooter ? renderFooter() : <DefaultFooter />}
|
||||
{renderActions?.()}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage is awkward and inflexible
|
||||
return (
|
||||
<Composer
|
||||
renderHeader={() => <CustomHeader />}
|
||||
renderFooter={() => (
|
||||
<>
|
||||
<Formatting />
|
||||
<Emojis />
|
||||
</>
|
||||
)}
|
||||
renderActions={() => <SubmitButton />}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
**Correct: compound components with children**
|
||||
|
||||
```tsx
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerFooter({ children }: { children: React.ReactNode }) {
|
||||
return <footer className='flex'>{children}</footer>
|
||||
}
|
||||
|
||||
// Usage is flexible
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<CustomHeader />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<SubmitButton />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
```
|
||||
|
||||
**When render props are appropriate:**
|
||||
|
||||
```tsx
|
||||
// Render props work well when you need to pass data back
|
||||
<List
|
||||
data={items}
|
||||
renderItem={({ item, index }) => <Item item={item} index={index} />}
|
||||
/>
|
||||
```
|
||||
|
||||
Use render props when the parent needs to provide data or state to the child.
|
||||
|
||||
Use children when composing static structure.
|
||||
|
||||
---
|
||||
|
||||
## 4. React 19 APIs
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
React 19+ only. Don't use `forwardRef`; use `use()` instead of `useContext()`.
|
||||
|
||||
### 4.1 React 19 API Changes
|
||||
|
||||
**Impact: MEDIUM (cleaner component definitions and context usage)**
|
||||
|
||||
> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
|
||||
|
||||
In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
|
||||
|
||||
**Incorrect: forwardRef in React 19**
|
||||
|
||||
```tsx
|
||||
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
})
|
||||
```
|
||||
|
||||
**Correct: ref as a regular prop**
|
||||
|
||||
```tsx
|
||||
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect: useContext in React 19**
|
||||
|
||||
```tsx
|
||||
const value = useContext(MyContext)
|
||||
```
|
||||
|
||||
**Correct: use instead of useContext**
|
||||
|
||||
```tsx
|
||||
const value = use(MyContext)
|
||||
```
|
||||
|
||||
`use()` can also be called conditionally, unlike `useContext()`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
1. [https://react.dev](https://react.dev)
|
||||
2. [https://react.dev/learn/passing-data-deeply-with-context](https://react.dev/learn/passing-data-deeply-with-context)
|
||||
3. [https://react.dev/reference/react/use](https://react.dev/reference/react/use)
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
name: vercel-composition-patterns
|
||||
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
|
||||
API changes.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: vercel
|
||||
version: '1.0.0'
|
||||
---
|
||||
|
||||
# React Composition Patterns
|
||||
|
||||
Composition patterns for building flexible, maintainable React components. Avoid
|
||||
boolean prop proliferation by using compound components, lifting state, and
|
||||
composing internals. These patterns make codebases easier for both humans and AI
|
||||
agents to work with as they scale.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Refactoring components with many boolean props
|
||||
- Building reusable component libraries
|
||||
- Designing flexible component APIs
|
||||
- Reviewing component architecture
|
||||
- Working with compound components or context providers
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ----------------------- | ------ | --------------- |
|
||||
| 1 | Component Architecture | HIGH | `architecture-` |
|
||||
| 2 | State Management | MEDIUM | `state-` |
|
||||
| 3 | Implementation Patterns | MEDIUM | `patterns-` |
|
||||
| 4 | React 19 APIs | MEDIUM | `react19-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Component Architecture (HIGH)
|
||||
|
||||
- `architecture-avoid-boolean-props` - Don't add boolean props to customize
|
||||
behavior; use composition
|
||||
- `architecture-compound-components` - Structure complex components with shared
|
||||
context
|
||||
|
||||
### 2. State Management (MEDIUM)
|
||||
|
||||
- `state-decouple-implementation` - Provider is the only place that knows how
|
||||
state is managed
|
||||
- `state-context-interface` - Define generic interface with state, actions, meta
|
||||
for dependency injection
|
||||
- `state-lift-state` - Move state into provider components for sibling access
|
||||
|
||||
### 3. Implementation Patterns (MEDIUM)
|
||||
|
||||
- `patterns-explicit-variants` - Create explicit variant components instead of
|
||||
boolean modes
|
||||
- `patterns-children-over-render-props` - Use children for composition instead
|
||||
of renderX props
|
||||
|
||||
### 4. React 19 APIs (MEDIUM)
|
||||
|
||||
> **⚠️ React 19+ only.** Skip these patterns if you're on React 18 or earlier.
|
||||
|
||||
- `react19-no-forwardref` - Don't use `forwardRef`; use `use()` instead of `useContext()`
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
|
||||
```
|
||||
rules/architecture-avoid-boolean-props.md
|
||||
rules/state-context-interface.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect code example with explanation
|
||||
- Correct code example with explanation
|
||||
- Additional context and references
|
||||
|
||||
## Full Compiled Document
|
||||
|
||||
For the complete guide with all rules expanded: `AGENTS.md`
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
title: Avoid Boolean Prop Proliferation
|
||||
impact: CRITICAL
|
||||
impactDescription: prevents unmaintainable component variants
|
||||
tags: composition, props, architecture
|
||||
---
|
||||
|
||||
## Avoid Boolean Prop Proliferation
|
||||
|
||||
Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
|
||||
component behavior. Each boolean doubles possible states and creates
|
||||
unmaintainable conditional logic. Use composition instead.
|
||||
|
||||
**Incorrect (boolean props create exponential complexity):**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
onSubmit,
|
||||
isThread,
|
||||
channelId,
|
||||
isDMThread,
|
||||
dmId,
|
||||
isEditing,
|
||||
isForwarding,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
<Header />
|
||||
<Input />
|
||||
{isDMThread ? (
|
||||
<AlsoSendToDMField id={dmId} />
|
||||
) : isThread ? (
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<EditActions />
|
||||
) : isForwarding ? (
|
||||
<ForwardActions />
|
||||
) : (
|
||||
<DefaultActions />
|
||||
)}
|
||||
<Footer onSubmit={onSubmit} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (composition eliminates conditionals):**
|
||||
|
||||
```tsx
|
||||
// Channel composer
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Attachments />
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Thread composer - adds "also send to channel" field
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField id={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit composer - different footer actions
|
||||
function EditComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about what it renders. We can share internals without
|
||||
sharing a single monolithic parent.
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
title: Use Compound Components
|
||||
impact: HIGH
|
||||
impactDescription: enables flexible composition without prop drilling
|
||||
tags: composition, compound-components, architecture
|
||||
---
|
||||
|
||||
## Use Compound Components
|
||||
|
||||
Structure complex components as compound components with a shared context. Each
|
||||
subcomponent accesses shared state via context, not props. Consumers compose the
|
||||
pieces they need.
|
||||
|
||||
**Incorrect (monolithic component with render props):**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
showAttachments,
|
||||
showFormatting,
|
||||
showEmojis,
|
||||
}: Props) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{showAttachments && <Attachments />}
|
||||
{renderFooter ? (
|
||||
renderFooter()
|
||||
) : (
|
||||
<Footer>
|
||||
{showFormatting && <Formatting />}
|
||||
{showEmojis && <Emojis />}
|
||||
{renderActions?.()}
|
||||
</Footer>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (compound components with shared context):**
|
||||
|
||||
```tsx
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
|
||||
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
|
||||
return (
|
||||
<ComposerContext value={{ state, actions, meta }}>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta: { inputRef },
|
||||
} = use(ComposerContext)
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerSubmit() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Send</Button>
|
||||
}
|
||||
|
||||
// Export as compound component
|
||||
const Composer = {
|
||||
Provider: ComposerProvider,
|
||||
Frame: ComposerFrame,
|
||||
Input: ComposerInput,
|
||||
Submit: ComposerSubmit,
|
||||
Header: ComposerHeader,
|
||||
Footer: ComposerFooter,
|
||||
Attachments: ComposerAttachments,
|
||||
Formatting: ComposerFormatting,
|
||||
Emojis: ComposerEmojis,
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```tsx
|
||||
<Composer.Provider state={state} actions={actions} meta={meta}>
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</Composer.Provider>
|
||||
```
|
||||
|
||||
Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
---
|
||||
title: Prefer Composing Children Over Render Props
|
||||
impact: MEDIUM
|
||||
impactDescription: cleaner composition, better readability
|
||||
tags: composition, children, render-props
|
||||
---
|
||||
|
||||
## Prefer Children Over Render Props
|
||||
|
||||
Use `children` for composition instead of `renderX` props. Children are more
|
||||
readable, compose naturally, and don't require understanding callback
|
||||
signatures.
|
||||
|
||||
**Incorrect (render props):**
|
||||
|
||||
```tsx
|
||||
function Composer({
|
||||
renderHeader,
|
||||
renderFooter,
|
||||
renderActions,
|
||||
}: {
|
||||
renderHeader?: () => React.ReactNode
|
||||
renderFooter?: () => React.ReactNode
|
||||
renderActions?: () => React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<form>
|
||||
{renderHeader?.()}
|
||||
<Input />
|
||||
{renderFooter ? renderFooter() : <DefaultFooter />}
|
||||
{renderActions?.()}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage is awkward and inflexible
|
||||
return (
|
||||
<Composer
|
||||
renderHeader={() => <CustomHeader />}
|
||||
renderFooter={() => (
|
||||
<>
|
||||
<Formatting />
|
||||
<Emojis />
|
||||
</>
|
||||
)}
|
||||
renderActions={() => <SubmitButton />}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
**Correct (compound components with children):**
|
||||
|
||||
```tsx
|
||||
function ComposerFrame({ children }: { children: React.ReactNode }) {
|
||||
return <form>{children}</form>
|
||||
}
|
||||
|
||||
function ComposerFooter({ children }: { children: React.ReactNode }) {
|
||||
return <footer className='flex'>{children}</footer>
|
||||
}
|
||||
|
||||
// Usage is flexible
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<CustomHeader />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<SubmitButton />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
```
|
||||
|
||||
**When render props are appropriate:**
|
||||
|
||||
```tsx
|
||||
// Render props work well when you need to pass data back
|
||||
<List
|
||||
data={items}
|
||||
renderItem={({ item, index }) => <Item item={item} index={index} />}
|
||||
/>
|
||||
```
|
||||
|
||||
Use render props when the parent needs to provide data or state to the child.
|
||||
Use children when composing static structure.
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
title: Create Explicit Component Variants
|
||||
impact: MEDIUM
|
||||
impactDescription: self-documenting code, no hidden conditionals
|
||||
tags: composition, variants, architecture
|
||||
---
|
||||
|
||||
## Create Explicit Component Variants
|
||||
|
||||
Instead of one component with many boolean props, create explicit variant
|
||||
components. Each variant composes the pieces it needs. The code documents
|
||||
itself.
|
||||
|
||||
**Incorrect (one component, many modes):**
|
||||
|
||||
```tsx
|
||||
// What does this component actually render?
|
||||
<Composer
|
||||
isThread
|
||||
isEditing={false}
|
||||
channelId='abc'
|
||||
showAttachments
|
||||
showFormatting={false}
|
||||
/>
|
||||
```
|
||||
|
||||
**Correct (explicit variants):**
|
||||
|
||||
```tsx
|
||||
// Immediately clear what this renders
|
||||
<ThreadComposer channelId="abc" />
|
||||
|
||||
// Or
|
||||
<EditMessageComposer messageId="xyz" />
|
||||
|
||||
// Or
|
||||
<ForwardMessageComposer messageId="123" />
|
||||
```
|
||||
|
||||
Each implementation is unique, explicit and self-contained. Yet they can each
|
||||
use shared parts.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```tsx
|
||||
function ThreadComposer({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ThreadProvider channelId={channelId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<AlsoSendToChannelField channelId={channelId} />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ThreadProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function EditMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<EditMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.CancelEdit />
|
||||
<Composer.SaveEdit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</EditMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ messageId }: { messageId: string }) {
|
||||
return (
|
||||
<ForwardMessageProvider messageId={messageId}>
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
<Composer.Mentions />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each variant is explicit about:
|
||||
|
||||
- What provider/state it uses
|
||||
- What UI elements it includes
|
||||
- What actions are available
|
||||
|
||||
No boolean prop combinations to reason about. No impossible states.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
title: React 19 API Changes
|
||||
impact: MEDIUM
|
||||
impactDescription: cleaner component definitions and context usage
|
||||
tags: react19, refs, context, hooks
|
||||
---
|
||||
|
||||
## React 19 API Changes
|
||||
|
||||
> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
|
||||
|
||||
In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
|
||||
|
||||
**Incorrect (forwardRef in React 19):**
|
||||
|
||||
```tsx
|
||||
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
})
|
||||
```
|
||||
|
||||
**Correct (ref as a regular prop):**
|
||||
|
||||
```tsx
|
||||
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||
return <TextInput ref={ref} {...props} />
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (useContext in React 19):**
|
||||
|
||||
```tsx
|
||||
const value = useContext(MyContext)
|
||||
```
|
||||
|
||||
**Correct (use instead of useContext):**
|
||||
|
||||
```tsx
|
||||
const value = use(MyContext)
|
||||
```
|
||||
|
||||
`use()` can also be called conditionally, unlike `useContext()`.
|
||||
@@ -1,191 +0,0 @@
|
||||
---
|
||||
title: Define Generic Context Interfaces for Dependency Injection
|
||||
impact: HIGH
|
||||
impactDescription: enables dependency-injectable state across use-cases
|
||||
tags: composition, context, state, typescript, dependency-injection
|
||||
---
|
||||
|
||||
## Define Generic Context Interfaces for Dependency Injection
|
||||
|
||||
Define a **generic interface** for your component context with three parts:
|
||||
`state`, `actions`, and `meta`. This interface is a contract that any provider
|
||||
can implement—enabling the same UI components to work with completely different
|
||||
state implementations.
|
||||
|
||||
**Core principle:** Lift state, compose internals, make state
|
||||
dependency-injectable.
|
||||
|
||||
**Incorrect (UI coupled to specific state implementation):**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
// Tightly coupled to a specific hook
|
||||
const { input, setInput } = useChannelComposerState()
|
||||
return <TextInput value={input} onChangeText={setInput} />
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (generic interface enables dependency injection):**
|
||||
|
||||
```tsx
|
||||
// Define a GENERIC interface that any provider can implement
|
||||
interface ComposerState {
|
||||
input: string
|
||||
attachments: Attachment[]
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
interface ComposerActions {
|
||||
update: (updater: (state: ComposerState) => ComposerState) => void
|
||||
submit: () => void
|
||||
}
|
||||
|
||||
interface ComposerMeta {
|
||||
inputRef: React.RefObject<TextInput>
|
||||
}
|
||||
|
||||
interface ComposerContextValue {
|
||||
state: ComposerState
|
||||
actions: ComposerActions
|
||||
meta: ComposerMeta
|
||||
}
|
||||
|
||||
const ComposerContext = createContext<ComposerContextValue | null>(null)
|
||||
```
|
||||
|
||||
**UI components consume the interface, not the implementation:**
|
||||
|
||||
```tsx
|
||||
function ComposerInput() {
|
||||
const {
|
||||
state,
|
||||
actions: { update },
|
||||
meta,
|
||||
} = use(ComposerContext)
|
||||
|
||||
// This component works with ANY provider that implements the interface
|
||||
return (
|
||||
<TextInput
|
||||
ref={meta.inputRef}
|
||||
value={state.input}
|
||||
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers implement the same interface:**
|
||||
|
||||
```tsx
|
||||
// Provider A: Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const inputRef = useRef(null)
|
||||
const submit = useForwardMessage()
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update: setState, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
|
||||
// Provider B: Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }: Props) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<ComposerContext
|
||||
value={{
|
||||
state,
|
||||
actions: { update, submit },
|
||||
meta: { inputRef },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ComposerContext>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**The same composed UI works with both:**
|
||||
|
||||
```tsx
|
||||
// Works with ForwardMessageProvider (local state)
|
||||
<ForwardMessageProvider>
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ForwardMessageProvider>
|
||||
|
||||
// Works with ChannelProvider (global synced state)
|
||||
<ChannelProvider channelId="abc">
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Submit />
|
||||
</Composer.Frame>
|
||||
</ChannelProvider>
|
||||
```
|
||||
|
||||
**Custom UI outside the component can access state and actions:**
|
||||
|
||||
The provider boundary is what matters—not the visual nesting. Components that
|
||||
need shared state don't have to be inside the `Composer.Frame`. They just need
|
||||
to be within the provider.
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
{/* The composer UI */}
|
||||
<Composer.Frame>
|
||||
<Composer.Input placeholder="Add a message, if you'd like." />
|
||||
<Composer.Footer>
|
||||
<Composer.Formatting />
|
||||
<Composer.Emojis />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
|
||||
{/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
|
||||
<MessagePreview />
|
||||
|
||||
{/* Actions at the bottom of the dialog */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
|
||||
function ForwardButton() {
|
||||
const {
|
||||
actions: { submit },
|
||||
} = use(ComposerContext)
|
||||
return <Button onPress={submit}>Forward</Button>
|
||||
}
|
||||
|
||||
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
|
||||
function MessagePreview() {
|
||||
const { state } = use(ComposerContext)
|
||||
return <Preview message={state.input} attachments={state.attachments} />
|
||||
}
|
||||
```
|
||||
|
||||
The `ForwardButton` and `MessagePreview` are not visually inside the composer
|
||||
box, but they can still access its state and actions. This is the power of
|
||||
lifting state into providers.
|
||||
|
||||
The UI is reusable bits you compose together. The state is dependency-injected
|
||||
by the provider. Swap the provider, keep the UI.
|
||||
@@ -1,113 +0,0 @@
|
||||
---
|
||||
title: Decouple State Management from UI
|
||||
impact: MEDIUM
|
||||
impactDescription: enables swapping state implementations without changing UI
|
||||
tags: composition, state, architecture
|
||||
---
|
||||
|
||||
## Decouple State Management from UI
|
||||
|
||||
The provider component should be the only place that knows how state is managed.
|
||||
UI components consume the context interface—they don't know if state comes from
|
||||
useState, Zustand, or a server sync.
|
||||
|
||||
**Incorrect (UI coupled to state implementation):**
|
||||
|
||||
```tsx
|
||||
function ChannelComposer({ channelId }: { channelId: string }) {
|
||||
// UI component knows about global state implementation
|
||||
const state = useGlobalChannelState(channelId)
|
||||
const { submit, updateInput } = useChannelSync(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input
|
||||
value={state.input}
|
||||
onChange={(text) => sync.updateInput(text)}
|
||||
/>
|
||||
<Composer.Submit onPress={() => sync.submit()} />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (state management isolated in provider):**
|
||||
|
||||
```tsx
|
||||
// Provider handles all state management details
|
||||
function ChannelProvider({
|
||||
channelId,
|
||||
children,
|
||||
}: {
|
||||
channelId: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update, submit }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// UI component only knows about the context interface
|
||||
function ChannelComposer() {
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Header />
|
||||
<Composer.Input />
|
||||
<Composer.Footer>
|
||||
<Composer.Submit />
|
||||
</Composer.Footer>
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Channel({ channelId }: { channelId: string }) {
|
||||
return (
|
||||
<ChannelProvider channelId={channelId}>
|
||||
<ChannelComposer />
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Different providers, same UI:**
|
||||
|
||||
```tsx
|
||||
// Local state for ephemeral forms
|
||||
function ForwardMessageProvider({ children }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Global synced state for channels
|
||||
function ChannelProvider({ channelId, children }) {
|
||||
const { state, update, submit } = useGlobalChannel(channelId)
|
||||
|
||||
return (
|
||||
<Composer.Provider state={state} actions={{ update, submit }}>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The same `Composer.Input` component works with both providers because it only
|
||||
depends on the context interface, not the implementation.
|
||||
@@ -1,125 +0,0 @@
|
||||
---
|
||||
title: Lift State into Provider Components
|
||||
impact: HIGH
|
||||
impactDescription: enables state sharing outside component boundaries
|
||||
tags: composition, state, context, providers
|
||||
---
|
||||
|
||||
## Lift State into Provider Components
|
||||
|
||||
Move state management into dedicated provider components. This allows sibling
|
||||
components outside the main UI to access and modify state without prop drilling
|
||||
or awkward refs.
|
||||
|
||||
**Incorrect (state trapped inside component):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageComposer() {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
|
||||
return (
|
||||
<Composer.Frame>
|
||||
<Composer.Input />
|
||||
<Composer.Footer />
|
||||
</Composer.Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// Problem: How does this button access composer state?
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Needs composer state */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Needs to call submit */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (useEffect to sync state up):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const [input, setInput] = useState('')
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer onInputChange={setInput} />
|
||||
<MessagePreview input={input} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageComposer({ onInputChange }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
useEffect(() => {
|
||||
onInputChange(state.input) // Sync on every change 😬
|
||||
}, [state.input])
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (reading state from ref on submit):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageDialog() {
|
||||
const stateRef = useRef(null)
|
||||
return (
|
||||
<Dialog>
|
||||
<ForwardMessageComposer stateRef={stateRef} />
|
||||
<ForwardButton onPress={() => submit(stateRef.current)} />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (state lifted to provider):**
|
||||
|
||||
```tsx
|
||||
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState(initialState)
|
||||
const forwardMessage = useForwardMessage()
|
||||
const inputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<Composer.Provider
|
||||
state={state}
|
||||
actions={{ update: setState, submit: forwardMessage }}
|
||||
meta={{ inputRef }}
|
||||
>
|
||||
{children}
|
||||
</Composer.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardMessageDialog() {
|
||||
return (
|
||||
<ForwardMessageProvider>
|
||||
<Dialog>
|
||||
<ForwardMessageComposer />
|
||||
<MessagePreview /> {/* Custom components can access state and actions */}
|
||||
<DialogActions>
|
||||
<CancelButton />
|
||||
<ForwardButton /> {/* Custom components can access state and actions */}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ForwardMessageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ForwardButton() {
|
||||
const { actions } = use(Composer.Context)
|
||||
return <Button onPress={actions.submit}>Forward</Button>
|
||||
}
|
||||
```
|
||||
|
||||
The ForwardButton lives outside the Composer.Frame but still has access to the
|
||||
submit action because it's within the provider. Even though it's a one-off
|
||||
component, it can still access the composer's state and actions from outside the
|
||||
UI itself.
|
||||
|
||||
**Key insight:** Components that need shared state don't have to be visually
|
||||
nested inside each other—they just need to be within the same provider.
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vitest
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/write-the-docs
|
||||
Reference in new issue
Block a user