Sanity Studio β Rapidly configure content workspaces powered by structured content
# AGENTS.md - AI Agent Guidelines for Sanity Monorepo
This document helps AI agents work successfully with the Sanity monorepo.
> **Self-Improvement:** If you discover undocumented requirements, commands, or workflows during your work (e.g., a reviewer asks you to run something not covered here), update this file on the same PR. Keep this guide accurate and helpful for future agents.
## Prerequisites
- **Node.js**: v24 or latest LTS. Published packages must declare `"engines": { "node": ">=22.12" }` (`pnpm normalize-pkgfields` keeps this in sync; publint warns if the field is missing).
- **Package Manager**: pnpm v10+ (exact version managed via `packageManager` field in package.json)
## Quick Reference
```bash
# Install dependencies (pnpm ONLY - enforced)
pnpm install
# Build all packages (required before testing)
pnpm build
# Run dev studio (requires auth, see below)
pnpm dev
# Format code (MUST pass CI)
pnpm chore:format:fix
# Fix all lint issues (MUST pass CI) β includes TypeScript type checking via oxlint
pnpm lint:fix
# Run tests
pnpm test
# Update snapshots if tests fail due to expected changes
pnpm test -- -u
# Lint + type check (oxlint typeAware + typeCheck; no separate tsc step)
pnpm check:oxlint
```
## CI Checks - What Must Pass
These checks run on every PR and **must pass**:
| Check | Command | Notes |
| ---------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Format** | `pnpm check:format` | Uses oxfmt. Fix with `pnpm chore:format:fix` |
| **Oxlint** | `pnpm check:oxlint` | Rust linter with type-aware rules and TypeScript type checking via tsgolint (`options.typeCheck`). Fix with `pnpm chore:oxlint:fix` |
| **Unit Tests** | `pnpm test` | Vitest, sharded in CI |
| **Export Tests** | `pnpm test:exports` | Ensures ESM/CJS/DTS work |
| **Dep Check** | `pnpm depcheck` | Finds unused/missing deps |
| **Zizmor** | `pnpm lint:workflows` | Audits `.github/workflows/` for security issues. Fails CI on high-severity findings. Local run needs [`zizmor`](https://docs.zizmor.sh/installation/) on `PATH`. |
| **PR Title** | Conventional commits | e.g., `feat(scope): description` |
### Before Committing
Run these commands to avoid CI failures:
```bash
# Fix all formatting and lint issues
pnpm lint:fix
# Verify tests pass (build first if needed)
pnpm build && pnpm test
```
If tests fail due to **expected snapshot changes**, update them:
```bash
pnpm test -- -u
```
Snapshot files are located in `__snapshots__` directories alongside test files.
## Project Structure
```
sanity/
βββ packages/
β βββ sanity/ # Main Sanity studio package
β βββ @sanity/ # Scoped packages (cli, types, schema, etc.)
β βββ @repo/ # Internal tooling (test-config, tsconfig, etc.)
βββ dev/ # Development studios for testing
β βββ test-studio/ # Primary dev studio (pnpm dev runs this)
β βββ studio-diagnostics-viewer/ # Standalone viewer for pasted diagnostics JSON
β βββ preview-iframe/ # Presentation preview iframe (vanilla Vite, port 3334)
βββ e2e/ # End-to-end Playwright tests
βββ perf/ # Performance testing
βββ examples/ # Example studios
```
### Key Packages
- **`packages/sanity`** - Core studio package with all UI components
- **`packages/@sanity/types`** - TypeScript type definitions
- **`packages/@sanity/schema`** - Schema compilation
- **`packages/@sanity/mutator`** - Document mutation logic
## Build System
- **Package Manager**: pnpm (version 10.x, enforced via `preinstall`)
- **Build Orchestration**: Turbo (caches builds)
- **Versioning**: Lerna-lite with conventional commits
### Build Commands
```bash
pnpm build # Build all packages
pnpm watch # Watch mode for development
```
### Running the Dev Studio
```bash
pnpm dev # Starts dev studio at http://localhost:3333
```
**Note:** The dev studio requires Sanity user authentication in the browser. It's a Vite application that communicates with Sanity API endpoints, so you'll need to log in with a Sanity account when you access `http://localhost:3333` to use the studio.
## Local Development
This section clarifies what requires authentication and what doesn'tβcritical for AI agents to avoid getting stuck on auth flows.
### Running Tests (No Auth Required)
Unit tests run in jsdom with mocks and **do not require any authentication**:
```bash
# Build first (required), then run all tests
pnpm build && pnpm test
# Run a single test file (IMPORTANT: use vitest directly with --project to avoid running all tests)
pnpm vitest run --project=sanity packages/sanity/src/core/hooks/useClient.test.ts
# Run a single test file with verbose output
pnpm vitest run --project=sanity --reporter=verbose packages/sanity/src/core/hooks/useClient.test.ts
# Watch mode for iterative development
pnpm test -- --watch
# Run tests for a specific package
pnpm test -- --project=sanity
```
**Important:** Do NOT use `pnpm test -- path/to/file.test.ts` for running a single file β it runs all tests across all projects. Use `pnpm vitest run --project=<project> <path>` instead.
The headless validation package is registered as `@sanity/validation`: run its full suite with
`pnpm vitest run --project=@sanity/validation`. When testing inherited validation, cover Studio's
compilation path too: call `inferFromSchema` on the built-in schema before compiling custom types,
then call it on the compiled custom schema. Studio inherits already-normalized built-in rules.
Components that need auth context use `createMockAuthStore` in tests, so no real authentication is needed. This is the recommended way to verify most code changes.
### Running the Dev Studio (Auth Required)
```bash
pnpm dev # Starts test-studio at http://localhost:3333 and preview-iframe at http://localhost:3334
```
- **Requires browser authentication** on first visitβyou'll be prompted to log in with a Sanity account
- Connects to a real Sanity project (configured in `dev/test-studio/sanity.config.ts`)
- Uses staging API by default (`api.sanity.work`)
- Session persists in browser, so subsequent visits won't require re-authentication
- `pnpm dev` / `pnpm dev:test-studio` also starts `dev/preview-iframe` (vanilla Vite on port 3334) so Presentation can load its cross-origin iframe. Studio-only: `pnpm dev:test-studio:studio`. Preview-only: `pnpm dev:preview-iframe`.
- Deployed preview iframe: Sanity Sandbox Vercel project `test-studio-preview-iframe` (`https://test-studio-preview-iframe.sanity.dev`)
Use the dev studio when you need to:
- Visually verify UI changes
- Test real document editing workflows
- Debug issues that only appear with real data
- Exercise Presentation / visual editing against the local preview iframe
### Inspecting Production Builds with Vite DevTools
The test studio can run with [Vite DevTools](https://devtools.vite.dev) enabled, which lets you inspect the output of `sanity build` runs (module graph, chunks, plugin timings, bundle treemaps, session diffing) from inside a long-running `sanity dev` serverβno restart needed.
```bash
# Builds the test studio with devtools enabled, then starts the dev server
# (so there's a build session to inspect right away)
pnpm devtools:test-studio
```
Open `http://localhost:3333` and use the Vite DevTools dock to explore the recorded Rolldown build session. See the [DevTools for Rolldown features guide](https://devtools.vite.dev/rolldown/features.html) for how to use the module graph, chunk, asset, and plugin panels.
To inspect a **new** build after making changesβwhile `pnpm devtools:test-studio` is still runningβrun in a second terminal:
```bash
# Creates a fresh build session that shows up in the running DevTools dock
pnpm devtools:test-studio:build
```
Sessions can be compared against each other in the DevTools UI to diff bundle changes.
How it works:
- Both commands set `ENABLE_VITE_DEVTOOLS=true`, which makes `dev/test-studio/sanity.cli.ts` add the `DevTools()` Vite plugin and enable `build.rolldownOptions.devtools`
- Build sessions are written to `dev/test-studio/node_modules/.rolldown` (gitignored)
- The flag is declared in `dev/test-studio/turbo.json` so turbo-cached builds are invalidated when it changes
- Enabling devtools makes `sanity build` noticeably slower; that's why it's opt-in via the env flag
### Debugging presence with fake users
**Field action (test studio).** Open a `Presence debug` document (`presenceDebug`, under Debug in the structure). Every field has a `Presence` button in its header with **Fake presence here**: pick a user in the dialog and they show up as present at that field. Inside a Portable Text field the user is placed at your last cursor position in that editor, so different users can be put at different spots. **Remove all fake presence** clears them. The plugin lives in `dev/test-studio/plugins/presence-debug` and uses the presence store's `@internal` `debug` API (`fakePresence`, `removeFakePresence`, `ownLocation$`), so the fake sessions go through the real presence code path (user lookup, form routing, overlay docking).
**URL hash flags.** The presence store also reads two older debug flags from the URL hash (`packages/sanity/src/core/store/presence/presence-store.ts`), comma separated after `#_debug_presence=`: `fake_others` (random mock users at fixed paths in a document with `_id: "presence-debug"`) and `show_own` (also show your own session).
### Profiling studio re-renders with React DevTools (agent-react-devtools)
The test studio can register with a local [agent-react-devtools](https://github.com/callstackincubator/agent-react-devtools) daemon, which exposes the React component tree and render profiling over a CLI β made for AI agents to inspect props/state/hooks and hunt unnecessary re-renders. The `react-devtools` skill (`.agents/skills/react-devtools/SKILL.md`) documents the CLI; read it before profiling.
For `useObservable`, `useSyncObservable` and `useObservablePromise` call sites whose observable identity churns, the `react-rx-hook-audit` skill (`.agents/skills/react-rx-hook-audit/SKILL.md`) covers finding them, counting renders and subscriptions at runtime, and the `useValuePreview` refactor and test pattern.
```bash
# 1. Start the daemon (port 8097)
pnpm --filter sanity-test-studio exec agent-react-devtools start
# 2. Start the studio with the connect script injected (implies ENABLE_REACT_DEVTOOLS=true)
pnpm react-devtools:test-studio
# 3. Open http://localhost:3333 in a browser, then verify the app registered
pnpm --filter sanity-test-studio exec agent-react-devtools status
# 4. Profile an interaction
pnpm --filter sanity-test-studio exec agent-react-devtools profile start
# ... interact with the studio ...
pnpm --filter sanity-test-studio exec agent-react-devtools profile stop
pnpm --filter sanity-test-studio exec agent-react-devtools profile rerenders --limit 10
```
How it works and gotchas:
- `ENABLE_REACT_DEVTOOLS=true` makes `dev/test-studio/sanity.cli.ts` add the `reactDevtools()` Vite plugin, which injects an `agent-react-devtools/connect` script that wires `react-devtools-core` to the daemon's WebSocket. Dev-server only (`apply: 'serve'`) β it can never reach `sanity build` output.
- **The browser must be headed.** Headless Chromium does not execute the injected module script properly, so the app never registers with the daemon. On the Cloud VM, drive system Chrome via Playwright with `headless: false` (a display is available), and authenticate by appending `#token=$STUDIO_AUTH_TOKEN` to the URL from inside the script (keeps the secret out of logs).
- Profile with StrictMode off (`SANITY_STUDIO_REACT_STRICT_MODE=false`) so dev double-rendering doesn't inflate durations β production studios don't run StrictMode.
- Do not run `pnpm check:oxlint` while the dev studio and daemon are running (same memory constraint as noted in the Cursor Cloud gotchas).
### Analyzing the `sanity` package bundle
The `sanity` package tsdown build can emit a Rolldown [bundle analyzer](https://rolldown.rs/builtin-plugins/bundle-analyzer) markdown report (module/chunk breakdown for humans and coding agents) when `ENABLE_BUNDLE_ANALYZER=true`:
```bash
pnpm analyze:sanity
```
The report is written to `packages/sanity/lib/analyze-data.md` (gitignored with `lib/`). The flag is opt-in because analysis adds work to the package build; it is declared in `packages/sanity/turbo.json` so turbo-cached builds are invalidated when it changes. Wiring is `@sanity/tsdown-config`'s `bundleAnalyzer` option (`true` selects markdown).
### Auto-updating studio CSS (Lightning CSS `light-dark()`)
The CDN / auto-update bundle Vite config lives in `@repo/package.bundle` (`createDefaultConfig`). Vite 8 minifies that CSS with Lightning CSS against `baseline-widely-available` (Chrome 111 / Safari 16.4), which down-transpiles `light-dark()` into `--lightningcss-light` / `--lightningcss-dark` toggled only by `prefers-color-scheme`. That breaks Studio theme colors when OS appearance β Studio theme (ui5 sets `color-scheme` independently). The shared config excludes `Features.LightDark` so the function is left native β same workaround as Tailwind; see [lightningcss#873](https://github.com/parcel-bundler/lightningcss/issues/873). Do not re-enable that polyfill. The `sanity build` / `sanity preview` Vite config lives in `sanity-io/cli`, not this repo.
### Panda CSS
The static styles of the `sanity` package are [Panda CSS](https://panda-css.com) `css()` calls in
`.css.ts` modules next to the components, imported from the generated `#styled-system/css`
runtime (`panda codegen` writes it to the gitignored `packages/sanity/styled-system/` directory on
`pnpm install`, through the package's `prepare` script). `packages/sanity/panda.config.ts` is the
**only Panda project in the repository** β the `} satisfies Config)` pattern, no presets,
content-hashed class names with the `sanity-` prefix (`sanity-HgOzd`) and `strictPropertyValues`
β and `#styled-system` is mapped in that package's manifest alone; nothing outside `packages/sanity`
writes Panda CSS. The `migrate-styled-components-to-panda-css` skill
(`.agents/skills/migrate-styled-components-to-panda-css/SKILL.md`) is the authoring guide: the
`&&` specificity trick, the cascade model, descendant selectors and dynamic values. Outside the
package, vanilla-extract stays where it is for now β the `.css.ts` modules of `@sanity/vision`
(with its `vanillaExtract` tsdown option) and the test studio's
`plugins/style-outline/styleOutline.css.ts` β until
[#15406](https://github.com/sanity-io/sanity/pull/15406) moves them to CSS modules; the apps
that build those from source keep `@sanity/vanilla-extract-vite-plugin` (the test studio's
`sanity.cli.ts`, vision's vitest suite), wrapped as described below. Shared tooling versions live
in the pnpm catalog (`@pandacss/dev`, `@pandacss/vite`, `@sanity/pandacss-tsdown-plugin`); the
Vite apps that build the package from source get their Panda wiring from `@repo/pandacss`
(`packages/@repo/pandacss`, its README says when an app needs it) instead of declaring those
themselves.
How the CSS reaches a page:
- **The published package:** `panda: {minify: false, cascadeLayers: false}` in `tsdown.config.ts`
(the `@sanity/pandacss-tsdown-plugin` options of `@sanity/tsdown-config`; unminified so the
diff between two published versions stays readable, see below for the layers) rewrites every
`css()` call into its class names at build time, extracts the CSS into `lib/bundle.css`,
injects `import 'sanity/bundle.css'` into the entry chunks and publishes the conditional
`./bundle.css` export (`browser`/`style` β the stylesheet, `node`/`default` β a no-op shim).
The published code carries no Panda runtime, which is why the manifest resets `imports`
under `publishConfig`. A `css()` call the build cannot resolve statically (computed values,
loops) makes it warn and bundle the runtime β keep the style objects literal.
- **Vite apps that build the package from source** (the dev studios through `sanity.cli.ts`,
Storybook, the vitest browser suite, `dev/studio-diagnostics-viewer`) resolve the workspace
packages to their sources (the `monorepo` export condition), so they bundle the package's
`.css.ts` modules themselves and register `sanityPandaCss()` from `@repo/pandacss/vite`
(`packages/@repo/pandacss`): `pandacss({cwd: <packages/sanity>, transform: true})` from
`@pandacss/vite`, pointed at the package's Panda project (`cwd` resolves the config, its
`include` globs and the codegen `outdir` there), and
`removeCascadeLayers(['reset', 'base', 'tokens', 'recipes', 'utilities'])` from
`@sanity/pandacss-tsdown-plugin/vite`, which gives the generated CSS the shape of `bundle.css`
(below). `@pandacss/vite` appends the generated CSS to the CSS module of the build that declares
Panda's layers, which is the one-line `@repo/pandacss/panda.css` with the
`@layer reset, base, tokens, recipes, utilities;` statement β the dev counterpart of the
`sanity/bundle.css` import the built package carries. Storybook's `.storybook/preview.tsx` and
`packages/sanity/test/setup/browser.ts` import it directly; a studio's `sanity.cli.ts`
(`studioPandaCss()`) and the viewer's `vite.config.ts` (the `entry` option) have the import
injected into their entry (`.sanity/runtime/app.js`, `src/main.tsx`) by a tiny transform plugin,
registered for dev only. A `sanity build` (and the viewer's build) resolves the built package
and its `bundle.css`, so nothing Panda runs there β which is also why a studio that installs
`sanity` from npm needs none of this: `bundle.css` is a plain stylesheet that Vite's built-in CSS
support loads. The CDN bundle in `@repo/package.bundle` builds the package for publishing and
keeps its own `pandacss()` call, `panda.css` and import plugin (it imports the stylesheet from
every library entry). Where vanilla-extract `.css.ts` modules are built in the same graph (the
test studio's `sanity.cli.ts`: its own style-outline plugin under both commands and vision's
modules under `sanity dev`; vision's vitest suite), `@sanity/vanilla-extract-vite-plugin` is
wrapped so that its transform's hook filter excludes `packages/sanity/src/` β it would otherwise
compile the Panda modules as vanilla-extract and fail on the missing `@vanilla-extract/css`
import; the wrapper throws if the plugin's name or hook shape changes. The jsdom suites of `sanity` and `@sanity/vision` register `@pandacss/vite`
directly, with the same `cwd` and `transform: true`, but load no stylesheet: no jsdom test
asserts on styles.
- **No cascade layers.** Panda writes its rules into `@layer` blocks (`reset`, `base`, `tokens`,
`recipes`, `utilities`), and the studio's CSS ships without them: CSS outside any layer beats
every layered rule regardless of specificity, so a layered stylesheet loses its hover, focus and
selected states to any unlayered rule that also matches β styled-components output, a reset
like Tailwind's preflight, the studio's own `GlobalStyle` (the splash tests and the ui5 team's
write-up confirmed it). `cascadeLayers: false` removes the blocks and statements after
Lightning CSS has run and orders the rules the way the layers did (a layer's sublayers before
its own rules, unlayered rules last), so every selector stays as specific as Panda wrote it and
the studio's rules cascade like the vanilla-extract output did: by specificity, then source
order. The same pass runs on the dev stylesheets and the CDN bundle through
`removeCascadeLayers()`, which removes the named layers (and their sublayers) from every CSS
module after `@pandacss/vite` has filled `@repo/pandacss/panda.css` (the CDN bundle's own
`panda.css`), and leaves other layers alone β which is why that file declares Panda's default
layer names (how `@pandacss/vite` recognizes its root stylesheet) and still ends up without a
single `@layer`. Panda's `polyfill` option is
not an alternative: it emulates the layer order with `:not(#\#β¦)` selector chains that inflate
every rule's specificity. The studio declares no `@layer` statement of its own either: with
`bundle.css` unlayered and `@sanity/ui` β₯ 4.4.8 (the catalog version) shipping its static styles
unlayered too ([ui#3235](https://github.com/sanity-io/ui/pull/3235)), the only layer left on a
studio page is `ui5`'s `sui`, which unlayered rules beat whatever the order, so there is nothing
to order it against β the same situation as before the migration. What this means when
authoring: the studio's rules beat `@sanity/ui`'s on the same element by specificity and order
alone β a plain class beats a styled-components base rule that loads later only when it is more
specific, which is what the `&&` doubling is for.
- **Atomic rules are ordered by value, not by declaration.** `bundle.css` holds each distinct
`property: value` pair once, grouped by property with the values sorted; a modifier class that
must override a base class on the same element doubles itself (`css({'&&': {opacity: 0.8}})`)
instead of relying on being declared later, and parent-state selectors key on data attributes
the parent renders (`'[data-change-bar-wrapper][data-focused] &'` in
`ElementWithChangeBar.css.ts`), never on a class that exists only to be targeted.
### Studio performance benchmarks (perf/bench β No Auth Required)
The `perf/bench` suite benchmarks a built studio against a **local mock** of the Sanity API β fully hermetic, no tokens, no network:
```bash
pnpm build:bench # build packages + bench studio (required first)
pnpm bench help # list all bench CLI commands
pnpm bench run --scenario singleString # absolute interaction benchmark
pnpm bench run --mode pageload --scenario singleString # load vitals + bundle size
pnpm bench:unit # mock-contract + stats unit tests
pnpm bench dev # mock + `sanity dev` for interactive debugging
```
See `perf/bench/README.md` for A/B comparisons, scenarios, and CI details. (The legacy `dev/efps` suite has been decommissioned; perf/bench replaces it.)
Three skills cover this area: `sanity-bench` (`.agents/skills/sanity-bench/SKILL.md`, running and dispatching the suite, the `trigger:perf-bench` label, dispatch inputs), `sanity-radar` (`.agents/skills/sanity-radar/SKILL.md`, the Studio Radar dashboard in `dev/radar` and how to query its dataset) and `sanity-radar-investigate` (`.agents/skills/sanity-radar-investigate/SKILL.md`, the regression-hunting procedure from a drift flag or investigation prompt to a confirmed culprit).
### Engine confidence score (No Auth Required locally)
The Engine confidence score measures how well the tests guard the document engine (`@sanity/mutator`, the document store, form patching, Portable Text sync). It is Stryker's mutation score across two mutation runs, stored as `score.ratio`. It is one field, so reporting that reads the dataset directly needs no understanding of how it is computed. A catalogue of known-bug patches is also replayed and stored as `knownBugs`, shown in Radar as supporting detail ("X of N known bugs caught"), but it never feeds the score. The producer lives in `engine-confidence/` and `engine-confidence/README.md` carries the detail.
```bash
pnpm engine-confidence stryker --target mutator --full # also: --target sanity; omit --full for an incremental run
pnpm engine-confidence catalogue # --suites unit:mutator,unit:engine skips fuzz; --only <ids>; --check-only
pnpm engine-confidence score # writes engine-confidence/reports/engine-confidence-run.json
pnpm engine-confidence store <file> --dry-run # prints the document id and body, needs no token
pnpm engine-confidence pin # recomputes config.json's pinnedHash and bumps version when it changed
pnpm engine-confidence unit # the package's unit tests
```
- **Run it in a disposable checkout.** Stryker's in-place mode rewrites the tier-1 sources until it finishes, and the catalogue applies patches to real sources. A killed run leaves them mutated; the next run refuses a dirty tree. Recover with `git checkout -- packages/sanity/src packages/@sanity/mutator/src`. Run the two Stryker targets one after the other, never at once.
- **Local recipe:** `pnpm build`, then `stryker --target mutator --full`, `stryker --target sanity --full`, `catalogue`, `score`, and `store reports/engine-confidence-run.json --dry-run` (`pnpm engine-confidence` runs from `engine-confidence/`, so paths are relative to it). `git status --short packages/` must be empty afterwards.
- **`config.version` is the series-break marker.** Any change to `config.json`, `tier1.json`, `exclusions.json`, `engine-confidence/stryker/`, the vitest-runner patch (it works around a runner bug where tests inside a `describe` never run against a mutant under Vitest 5) or the four `@stryker-mutator/*` specifiers changes the pinned hash. Run `pin` and commit the result; a unit test fails on the pull request when the hash is stale. Catalogue growth is not hashed.
- **Workflows.** `Engine confidence` (`.github/workflows/engine-confidence.yml`) runs Thursdays 03:00 UTC on `main` only, plus manual dispatch, and stores one document per ISO week. It never runs on pull requests, and only its `store` job holds `RADAR_SANITY_WRITE_TOKEN`. `Engine confidence catalogue` (`.github/workflows/engine-confidence-catalogue.yml`) runs on pull requests that touch engine files or the catalogue and checks that every patch still applies. It is not a required check.
- **The exclusion register** (`engine-confidence/exclusions.json`) holds reviewed mutants that no test could ever catch. `score` exits 8 when an entry is stale (matches no mutant) or matches a mutant a test detected; fix the entry, never the check. Unreachable code and assumption-based equivalence do not qualify. The README has the rules.
- **Refreshing a patch the PR check flagged:** rebase the patch onto the changed engine code, confirm with `git apply --check`, then run `catalogue --only <id>` to confirm the verdict. `engine-confidence/README.md` has the steps.
- **Where it shows:** the Radar "Engine confidence" tab (`dev/radar`, `/trends?tab=engine`). `?source=demo` renders it offline. To see a real local run there, copy `engine-confidence/reports/engine-confidence-run.json` into the gitignored `dev/radar/tools/trends/__local__/`; demo mode uses it as the newest week.
### E2E Tests (Token Required)
E2E tests require authentication tokens. Add these to `.env.local` in the repo root:
```bash
SANITY_E2E_SESSION_TOKEN=<your-token>
SANITY_E2E_PROJECT_ID=<project-id>
SANITY_E2E_DATASET=<dataset-name>
```
**How to get a token:**
```bash
# Option 1: Use your CLI token
sanity login
sanity debug --secrets # Look for "Auth token"
# Option 2: Create a project token at https://sanity.io/manage
# Navigate to: Project Settings β API β Tokens β Add API token
```
Then run E2E tests:
```bash
pnpm e2e:build # Build E2E studio
pnpm test:e2e # Run E2E tests
pnpm test:e2e --ui # Interactive mode
```
**Note:** E2E tests are typically run in CI, not locally during development. Most changes can be verified with unit tests.
When CI e2e fails, the hosted Playwright report also serves a machine-readable digest at `<report-url>/agent-report.md` (error messages, code snippets, and Playwright `error-context` page snapshots). The PR comment includes a **Share with an AI agent** fenced prompt pointing at that URL (GitHub's copy button copies the whole prompt).
#### Diagnosing e2e flake: failure diagnostics and the flake report
Every failed or timed-out e2e test attempt attaches `studio-diagnostics.json` β the same JSON the Studio Diagnostics dialog's "Copy output" produces (API latency probes, listen tests, the `x-sanity-shard` id, and the request-timing history recorded while the test ran). It is captured by the `_failureDiagnostics` auto fixture in `e2e/studio-test.ts` through `window.__sanityStudioDiagnostics`, which the e2e studio installs via the `StudioDiagnosticsBridge` component (`dev/studio-e2e-testing/diagnosticsBridge.tsx`). When the studio shell never mounted, `studio-diagnostics-fallback.json` holds plain-fetch probes instead, and `studio-request-error.txt` records the studio's request error dialog ("Too many requests", server error, network error) when it is showing β the request history only covers `/data/*` traffic, so this is how a 429 on `/users/me` becomes visible. Attachments show up per test in the Playwright HTML report and paste straight into `dev/studio-diagnostics-viewer`.
To turn those captures into numbers ("how many failed runs were platform-caused?"), run the flake report:
```bash
pnpm e2e:flake-report --days 7 --out flake-report.md # needs GITHUB_TOKEN / GH_TOKEN or `gh auth login`
pnpm e2e:flake-report --help
pnpm --filter e2e test:unit # classifier unit tests
```
It lists recent `End-to-End Tests` runs, downloads the blob reports of failed shards (cached under the OS temp dir), classifies each failed attempt as degraded / healthy / unknown from its capture, attributes every failed run to `platform`, `test-side`, `mixed`, or `unknown`, reads failed setup-job logs for rate-limit and network signatures, and flags correlated failure windows across unrelated branches. Thresholds and the verdict rules live in `e2e/scripts/flakeReport/classify.ts` and are restated in the report's Method section. The `E2E flake report` workflow (`.github/workflows/e2e-flake-report.yml`) runs it weekly and on demand, publishing the markdown as the job summary plus an artifact.
Staging rate-limits per IP, so `pnpm e2e:setup` retries 429/5xx responses with backoff (`e2e/scripts/rateLimitRetry.ts`, honoring `Retry-After`) and sends the `x-sanity-ratelimit-bypass` header when `SANITY_CLI_API_RATE_LIMIT_BYPASS` is set, as the `dataset-setup` CI job does.
### Important Note for AI Agents
**What requires authentication:**
- Running the dev studio (`pnpm dev`)
- E2E tests (`pnpm test:e2e`)
- Any command that connects to Sanity APIs
**What does NOT require authentication:**
- Building packages (`pnpm build`)
- Running unit tests (`pnpm test`)
- Linting, formatting, and type checking (`pnpm lint`, `pnpm lint:fix`, `pnpm check:oxlint`)
**Recommendation:** For most code changes, use `pnpm build && pnpm test` to verify correctness. This covers the vast majority of development tasks without any auth setup. Only use the dev studio when visual verification is specifically needed.
## Coding Standards
Coding standards are enforced by **oxlint** (native Rust rules, type-aware rules via tsgolint, TypeScript type checking via `options.typeCheck`, and a few ESLint plugins loaded through oxlint's `jsPlugins`). TypeScript type checking is included in `pnpm lint` / `pnpm check:oxlint` β no separate `tsc` step. Check your code with:
```bash
pnpm lint # Check for issues (oxlint, includes type checking)
pnpm lint:fix # Auto-fix issues (oxfmt + oxlint --fix)
```
All packages use **ESM** (`"type": "module"`). TypeScript strict mode is enabled.
Rules that the linter already enforces (restricted imports, type-aware rules, React Compiler rules, i18n rules, module boundaries) are not repeated in this guide β run `pnpm lint` and follow the reported messages, which explain the expected pattern.
### Do Not Weaken the Linter
Fix the reported problem instead of silencing it. In order of preference:
1. **Fix the code** so the rule passes. This is almost always the right answer.
2. **Suppress the single line** as a last resort, when the rule is genuinely wrong for that one spot: `// oxlint-disable-next-line <rule> -- <why>`. Always name the specific rule and explain the exception after `--`. Never suppress a rule merely to make CI green.
3. **Change `.oxlintrc.json` only when a human explicitly asks.** Do not turn rules off, downgrade severity, add `overrides` entries, or widen `ignorePatterns` on your own initiative β an override silences the rule for every current and future file it matches. If you think a rule is wrong, leave it failing and raise it in your summary or the PR description.
File-wide `/* oxlint-disable <rule> */` is reserved for files that are an exception as a whole β vendored code, the `packages/sanity/src/ui-components` wrappers around raw `@sanity/ui`, CLI scripts that print to `console`. Follow that existing precedent rather than reaching for it to clear a handful of errors.
`options.reportUnusedDisableDirectives` is `error`, so a suppression that stops being necessary fails CI β drop suppressions when the code underneath them changes.
### `sanity/_dangerously_use_private_internals_that_do_not_follow_semver`
Every declaration that `sanity`, `sanity/structure` or `sanity/router` exports under the
`@internal` TSDoc release tag is also exported from
`sanity/_dangerously_use_private_internals_that_do_not_follow_semver`
(`packages/sanity/src/_exports/_dangerously_use_private_internals_that_do_not_follow_semver.ts`),
an entry whose name spells out its contract: anything on it can change or disappear in any
release. The public entries still export those symbols today; the entry is the migration target
for consumers that depend on them, ahead of the public entries dropping them in a future major.
`packages/@repo/test-dts-exports/test/internals-entry-completeness.test.ts` (part of
`pnpm test:exports`) fails when an `@internal` export of a public entry is missing from the entry,
when the two entries export different declarations under one name, or when the entry exports
something that is not tagged `@internal`.
Rules that follow from this:
- A new `@internal` export on a public barrel needs a matching line in the internals barrel
(grouped by source module, `type` specifiers kept). Consumers outside `packages/sanity`
(`@sanity/vision`, the dev studios, plugins) import internals from the entry, never from
`sanity`. Inside the package, `src/core`, `src/structure` and `src/router` keep importing
relatively: the entry re-exports modules from all three areas, so importing it from any of them
is an import cycle, and a lazily loaded module that imports the entry drags the whole barrel in
with it. The boundaries rules in `.oxlintrc.json` only allow the entry itself to import from the
three areas.
- The entry is generated from the tags, not curated: whether a symbol belongs there is decided by
its `@internal` tag. Retagging a symbol (`@internal` β `@beta`/`@public`, or the other way) is
the API decision; check sanity.io/docs and the published first-party plugins before demoting a
symbol, and remember that `@sanity/cli`, `@sanity/cli-core` and `@sanity/cli-build` load the
studio's local `sanity` package at runtime (`resolveLocalPackage('sanity')`) and destructure
`renderStudio`, `resolveConfig`, `createSchema`, `SchemaError`, `createDefaultIcon`,
`generateStudioManifest`, `uploadSchema` and `validateDocument` from the root. The one
deliberate exception is `createAuthStore` (deprecated since v3.15.0): it keeps its
`@internal @deprecated` tags because those tell customers to stop using it, it stays off the
entry so the deprecated `sanity` import remains the only one, and it is listed in
`PUBLIC_BY_USAGE` in the completeness test; its removal is governed by the deprecation, not by
the internals moving. Do not retag it.
- The CDN auto-update bundle (`packages/sanity/package.bundle.ts`) lists every entry explicitly;
a new entry must be added there too, otherwise the `sanity/` import-map prefix resolves it to a
404 on the module host. `pnpm generate:dts-exports` in `packages/@repo/test-dts-exports`
regenerates the d.ts fixture for the entry after its export list changes.
- A change to any entry's export list also changes `packages/sanity/test/__snapshots__/exports.test.ts.snap`,
a unit test in the `sanity` project rather than part of `pnpm test:exports`. It imports the
entries from `src`, so `pnpm vitest run --project=sanity packages/sanity/test/exports.test.ts -u`
regenerates it without a build.
### An entry's type surface is what its barrel exports, nothing more
A bundled `.d.ts` can only name declarations the entry barrel actually exports. Until
`rolldown-plugin-dts` 0.28.7 that was not true: a bundled `.d.ts` with no bare `export {β¦}` clause
hit TypeScript's rule that every top-level declaration of such a declaration file is implicitly
exported, so private helpers pulled in for a signature became part of the published type surface β
including `const`s and `function`s that the emitted `.js` never exported, so importing them
type-checked and then failed to link. 0.28.7 appends an `export {}` scope marker instead
(["prevent implicit exports of private declarations"](https://github.com/sxzz/rolldown-plugin-dts/commit/004032f)),
so only explicit exports are public.
That makes `@repo/test-dts-exports` fixtures the authority on the surface, and a fixture listing a
name no barrel exports is the bug, not the build. When a dts-tooling bump shrinks a fixture, check
each dropped name before regenerating:
- A **type** the declaring module exports and tags `@public` belongs on the barrel. Add it there as
a type-only re-export (`export {type Foo} from 'β¦'`), which leaves the emitted `.js` untouched.
- A declaration the module keeps private stays private, and a `Name$1` suffix is a bundler dedup
artifact that was never API β `sanity/media-library` emitted one because `VideoDefinition` is
declared in both `plugin/schemas/index` and `plugin/schemas/types`. When a name is declared
twice, pick which one is public by convention, not by which one the old output left unsuffixed:
that was decided by bundler ordering. Here the public one is `plugin/schemas/types`, shaped like
`ImageDefinition` and `FileDefinition` in `@sanity/types` and accepted by `defineVideoField`,
while `plugin/schemas` declares a reduced one for the `IntrinsicDefinitions` augmentation.
- A dropped **value** (`const`, `function`) was never importable: the emitted `.js` never exported
it, so `import {name}` failed to link. Making it a real export is an API decision, and it can cost
more than it looks β re-exporting `videoTypeName` pulled a 44 kB chunk of form and React code into
`sanity/media-library`, which `sideEffects: true` keeps a consumer from tree-shaking away.
- Load-bearing exceptions exist. `@sanity/util`'s `Options` and `Parser` are exported from
`createSafeJsonParser` because `@sanity/migrate` imports `Parser` in its own `.d.ts`, and
`lib-check.test.ts` runs with `skipLibCheck: false`.
Regenerating needs a full `pnpm build` first: the generator reads each package's built
`lib/**/*.d.ts`.
### Effect events: use `use-effect-event`, not React's native hook
Import `useEffectEvent` from `use-effect-event`, never from `react`. On React 19.2 the native hook
returns first-render values when the calling component is wrapped in `forwardRef` or `memo`
([facebook/react#34818](https://github.com/facebook/react/issues/34818), fixed in 19.3 canaries).
`eslint/no-restricted-imports` in `.oxlintrc.json` enforces this. The bug reaches any dependency that
wraps the native hook, so check the implementation before trusting one.
### react-rx: stable observables, explicit initial values
`react-rx` v7 never subscribes during render. `useObservable` / `useSyncObservable` render the
`initialValue` (required β pass `undefined` explicitly when there is nothing better) until the
subscription started on commit delivers a value, and a synchronous emission arrives one pass later.
Two rules follow:
- **The observable identity must be stable across renders**: build it with `useMemo`, keep it on a
store, or hoist it to module scope, and key memos on primitives (a path string, an id) rather than
on arrays or objects recreated every render. An observable rebuilt each render is torn down and
re-subscribed each commit; when it synchronously replays a value that differs from the
`initialValue`, React aborts with "Maximum update depth exceeded"
(`useDocumentValuesRenderLoop.repro.test.tsx` guards one such case).
- **Never rely on a synchronous first emission.** `useSyncObservable(obs$, undefined)!` is a
first-render crash. Pass the value the observable emits first (`GUARDED`, the releases store's
exported `INITIAL_RELEASES_STATE`, `useVariantsStore().initialState`), or derive it per render
when it depends on the observable's parameters (`useEditState`), since react-rx captures
`initialValue` once per hook instance.
### Translate: never define `components` inline
Components passed to `<Translate>`'s `components` map must be stable, module-scope components β
defining them inline during render creates a new component identity every render and remounts
the subtree. Pass render-scoped data through the `componentProps` prop instead, and map plain
HTML wrappers as strings (eg `{Code: 'code'}`). The in-repo oxlint rule
`@repo/i18n/no-inline-translate-components` (see `packages/@repo/oxlint-plugin-i18n`) enforces
this for object literals in the JSX attribute; maps built during render via `useMemo` or factory
functions are equally wrong even though the rule cannot see them. See the `sanity-i18n-translate`
skill (`.agents/skills/sanity-i18n-translate/SKILL.md`) for the full conversion patterns.
### Locale namespaces: plugin strings stay inside the plugin
Only `studio`, `validation`, `copy-paste` and `feedback` (plus `comments`, whose plugin is always
included) are registered in every studio. Every other namespace (`releases`, `tasks`, `canvas`,
`structure`, ...) is registered by a plugin that a studio can leave out, and a component that
reads it while that plugin is not loaded renders raw keys. Strings for code that renders outside
a plugin (core components, form inputs, previews, the navbar) go in the `studio` bundle. Two
in-repo oxlint rules check this: `@repo/i18n/no-plugin-namespace-outside-plugin` and
`@repo/i18n/no-missing-translation-key` (keys must exist in the en-US resources of the namespace
they are read from). Both read the namespace table in
`packages/@repo/oxlint-plugin-i18n/src/namespaces.mjs`; update it when adding a namespace or
changing which plugin registers one.
### `<Activity mode="hidden">`: effects are torn down, DOM and state stay
React's `<Activity>` is used for closed `@sanity/ui` overlays, collapsed document lists, and the
top-level tools when `beta.reactActivityMode` is on (`StudioLayoutComponent`). Hiding runs
every effect cleanup in the subtree and showing runs the effects again, without a remount, so:
- Anything that lives only in an effect-created object is lost on reveal unless the effect can
rebuild it from render-time data. `@uiw/react-codemirror` destroys its `EditorView` on cleanup
and re-creates it from `value`; `VisionCodeMirror` mirrors the latest document into state for
that reason. Mount-only effects (`useEffect(..., [])`) run again on every reveal.
- Loading states come back on reveal unless the code remembers it already loaded. A comlink
channel recreated by an effect handshakes as a brand new connection (the presentation machine
tracks `overlaysHaveConnected` so that reads as a reconnect, not a first connect); a
`startWith(loading)` observable re-emits its initial value when `useObservable` re-subscribes
it (`useDocumentLocations` skips the `startWith` once the resolver has emitted); and a context
populated by registering in an effect empties out while hidden, unmounting whatever renders
from it (`PresentationDocumentProvider` lists its own options at render time).
- Never reorder keyed siblings that hold an `<iframe>`. React moves the DOM node when the key
order changes, and a re-inserted iframe reloads. `useMountedTools` renders tools in workspace
order for this reason even though eviction is least-recently-used.
- `@sanity/ui`'s `useElementSize` (4.2.4) stops reporting after a reveal. Its shared
`_elementSizeObserver` keeps one `ResizeObserver` per element and disconnects it when the last
subscriber leaves, but never forgets the element, so the effect re-run after a reveal finds the
cached entry and creates no new observer: the hook keeps the size from before hiding, also for
later resizes. Any layout derived from it inside a tool (Vision's column/stacked breakpoints,
`PaneLayout`, `DocumentLayout`, the PTE toolbar) freezes. Vision's redesign measures with its
own `useContentSize`, which observes afresh on every effect run; the fix belongs in `@sanity/ui`.
- Hidden content keeps `display: none` on its topmost host nodes only, and portals under the
boundary are hidden too (`hideOrUnhideNearestPortals`); descendants report their own `display`.
### Refs: use `props.ref`, not `forwardRef`
React 19 passes `ref` as a regular prop. Do not use `forwardRef` β destructure `ref` from props
(so it is not left in a `...rest` spread) and forward it like any other prop.
`eslint/no-restricted-imports` bans importing `forwardRef` from `react`.
Prefer a named function declaration over `const X = function β¦` / arrow wrappers:
```ts
// preferred
export function MyComponent(props: Props & RefAttributes<HTMLDivElement>) {
const {ref, ...rest} = props
return <div ref={ref} {...rest} />
}
// avoid
export const MyComponent = function MyComponent(props: β¦) { β¦ }
export const MyComponent = (props: β¦) => { β¦ }
```
When wrapping with `memo`, declare the component as a function first, then memoize:
```ts
function MyComponent(props: β¦) { β¦ }
export const MyComponentMemo = memo(MyComponent)
```
Do not assign `.displayName` on components, HOCs, styled components, or `createContext`
results. That property write is a module-level side effect and keeps the export from being
tree-shaken. Named function declarations already give React DevTools a name via
`function.name`. Exception (temporary): document/badge hooks prefixed with `use` and the
HookState collection still set `displayName` β leave those until that API is reworked.
For typings, include `ref` on the props type: stop omitting `'ref'` from `HTMLProps` /
`ComponentProps`, or intersect with `RefAttributes<T>`. Avoid `PropsWithRef` β in `@types/react`
19 it is a deprecated identity alias and trips `typescript/no-deprecated`.
## Testing
### Unit Tests (Vitest)
```bash
pnpm test # Run all tests
pnpm test -- --watch # Watch mode
pnpm test -- -u # Update snapshots
pnpm test -- --project=sanity # Run specific project
```
Tests require a build first because some tests use compiled output:
```bash
pnpm build && pnpm test
```
On macOS, use `TMPDIR=/private/tmp pnpm test` if the E2E summary reporter test fails with a
`/var` versus `/private/var` path mismatch. The test changes its working directory, which resolves
the symlink; using a canonical temporary path keeps its expected and actual paths consistent.
#### Types in tests
Applies to every package's `*.test.ts` / `*.test.tsx` (and `*.browser.test.tsx`) files only.
Do not use `as unknown as T` to force a value into a narrower public type. If a helper's
return type is not in the union the function accepts, cover that behaviour through a surface
that already accepts the value, or drop that case from the typed call. Do not widen a public
union solely so a test type-checks. A single `as T` on a deliberately incomplete object is
fine when the test is the error path for a missing field. This rule does not apply to
production source.
#### Prefer real implementations over test-file doubles
Drive the unit through its production API and the same factories production uses.
Do not add mock functions, stub components, or a parallel compiler to the test file
just to reach a branch.
- Compile schemas with Studio `createSchema` plus `defineType` / `defineField`
(`packages/sanity/src/core/schema/createSchema.ts`). That path validates, parents
the builtin schema, and runs `inferValidation`. Do not hand-roll
`@sanity/schema` `Schema.compile({types: [...]})` unless the test is about that
compiler.
- Call the production function (`createPrepareFormState()`, an exported mapper)
without `vi.fn` decorator spies unless the suite is about call counts.
- Typed lookups of a production union (`fieldMember`, `errorMember`) are accessors,
not doubles. A schema-construction helper for a depth fixture is fine. A second
fake form tree, or a local `vi.mock` of the module under test, is not.
- A harness that needs a mock client and a page of `vi.fn` callbacks is the wrong
seam when the unit is a pure functio
<!-- [TRUNCATED AT 50KB] -->