diff --git a/.agents/skills/generate-frontend-forms/SKILL.md b/.agents/skills/generate-frontend-forms/SKILL.md
index 5fc77de1654f83..b4cd7ef79d84f3 100644
--- a/.agents/skills/generate-frontend-forms/SKILL.md
+++ b/.agents/skills/generate-frontend-forms/SKILL.md
@@ -223,9 +223,9 @@ All fields are accessed via the `field` render prop and follow consistent patter
### Radio Field
-Radio fields use a composable API with `Radio.Group` and `Radio.Item`. `Radio.Group` renders a `
` and provides group context, so the layout's label automatically renders as a `` for proper accessibility semantics.
+Radio fields use a composable API with `Radio.Group` and `Radio.Item`. `Radio.Group` provides group context that changes how the label is rendered for proper accessibility semantics.
-> **Important**: The layout (and its label) **must** be rendered _inside_ `Radio.Group`. The group context that makes the label render as a `` is provided by `Radio.Group`, so placing the layout outside will result in a plain `` instead of the correct `` element.
+> **Important**: The layout (and its label) **must** be rendered _inside_ `Radio.Group`. The group context is provided by `Radio.Group`, so placing the layout outside will result in incorrect accessibility semantics.
```tsx
@@ -263,17 +263,17 @@ import {Flex} from '@sentry/scraps/layout';
For one-off fields that don't have a built-in component (e.g. a color picker, or any custom input), use `field.Base`. It provides a render prop with all the necessary accessibility and form integration props (`ref`, `disabled`, `aria-invalid`, `aria-describedby`, `onBlur`, `name`, `id`) that you spread onto your native element.
```tsx
-
+
{field => (
-
+
>
{(baseProps, {indicator}) => (
field.handleChange(e.target.checked)}
+ type="color"
+ value={field.state.value}
+ onChange={e => field.handleChange(e.target.value)}
/>
{indicator}
diff --git a/eslint.config.ts b/eslint.config.ts
index 23fe72cc132fa4..525019ebd9be69 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -1291,6 +1291,7 @@ export default typescript.config([
'*.{ts,tsx}', // core/renderToString.tsx at the core root etc.
'*/index.{ts,tsx}', // core/form/index.tsx, core/alert/index.tsx etc.
'**/*.png', // needed for story-files
+ '**/__stories__/*.{ts,tsx}', // story demo helpers imported by .mdx files
],
},
{
diff --git a/package.json b/package.json
index f21ffadc5eb0e0..2a0710d1a51bd2 100644
--- a/package.json
+++ b/package.json
@@ -81,9 +81,7 @@
"@stripe/stripe-js": "^5.10.0",
"@swc/plugin-emotion": "14.3.0",
"@tanstack/query-async-storage-persister": "5.83.1",
- "@tanstack/react-devtools": "^0.9.3",
"@tanstack/react-form": "^1.28.0",
- "@tanstack/react-form-devtools": "^0.2.13",
"@tanstack/react-pacer": "^0.17.0",
"@tanstack/react-query": "5.85.0",
"@tanstack/react-query-devtools": "5.85.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 403ce6da3c7f84..d141421644b2f4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -231,15 +231,9 @@ importers:
'@tanstack/query-async-storage-persister':
specifier: 5.83.1
version: 5.83.1
- '@tanstack/react-devtools':
- specifier: ^0.9.3
- version: 0.9.3(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.11)
'@tanstack/react-form':
specifier: ^1.28.0
version: 1.28.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
- '@tanstack/react-form-devtools':
- specifier: ^0.2.13
- version: 0.2.13(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)
'@tanstack/react-pacer':
specifier: ^0.17.0
version: 0.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -3313,36 +3307,6 @@ packages:
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
- '@solid-primitives/event-listener@2.4.3':
- resolution: {integrity: sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg==}
- peerDependencies:
- solid-js: ^1.6.12
-
- '@solid-primitives/keyboard@1.3.3':
- resolution: {integrity: sha512-9dQHTTgLBqyAI7aavtO+HnpTVJgWQA1ghBSrmLtMu1SMxLPDuLfuNr+Tk5udb4AL4Ojg7h9JrKOGEEDqsJXWJA==}
- peerDependencies:
- solid-js: ^1.6.12
-
- '@solid-primitives/resize-observer@2.1.3':
- resolution: {integrity: sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ==}
- peerDependencies:
- solid-js: ^1.6.12
-
- '@solid-primitives/rootless@1.5.2':
- resolution: {integrity: sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ==}
- peerDependencies:
- solid-js: ^1.6.12
-
- '@solid-primitives/static-store@0.1.2':
- resolution: {integrity: sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw==}
- peerDependencies:
- solid-js: ^1.6.12
-
- '@solid-primitives/utils@6.3.2':
- resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==}
- peerDependencies:
- solid-js: ^1.6.12
-
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@@ -3369,14 +3333,6 @@ packages:
'@swc/plugin-emotion@14.3.0':
resolution: {integrity: sha512-BGlMPa73k2u01gXJHULC5Pvt0Mgjt+/34ceOxN5IGVGvZu8h4ryG4K6p/+JUpUojfNTEng/hpIcmCWtOHmg7Pw==}
- '@tanstack/devtools-client@0.0.5':
- resolution: {integrity: sha512-hsNDE3iu4frt9cC2ppn1mNRnLKo2uc1/1hXAyY9z4UYb+o40M2clFAhiFoo4HngjfGJDV3x18KVVIq7W4Un+zA==}
- engines: {node: '>=18'}
-
- '@tanstack/devtools-event-bus@0.4.0':
- resolution: {integrity: sha512-1t+/csFuDzi+miDxAOh6Xv7VDE80gJEItkTcAZLjV5MRulbO/W8ocjHLI2Do/p2r2/FBU0eKCRTpdqvXaYoHpQ==}
- engines: {node: '>=18'}
-
'@tanstack/devtools-event-client@0.3.4':
resolution: {integrity: sha512-eq+PpuutUyubXu+ycC1GIiVwBs86NF/8yYJJAKSpPcJLWl6R/761F1H4F/9ziX6zKezltFUH1ah3Cz8Ah+KJrw==}
engines: {node: '>=18'}
@@ -3385,39 +3341,6 @@ packages:
resolution: {integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==}
engines: {node: '>=18'}
- '@tanstack/devtools-ui@0.4.4':
- resolution: {integrity: sha512-5xHXFyX3nom0UaNfiOM92o6ziaHjGo3mcSGe2HD5Xs8dWRZNpdZ0Smd0B9ddEhy0oB+gXyMzZgUJb9DmrZV0Mg==}
- engines: {node: '>=18'}
- peerDependencies:
- solid-js: '>=1.9.7'
-
- '@tanstack/devtools-utils@0.3.0':
- resolution: {integrity: sha512-JgApXVrgtgSLIPrm/QWHx0u6c9Ji0MNMDWhwujapj8eMzux5aOfi+2Ycwzj0A0qITXA12SEPYV3HC568mDtYmQ==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/react': '>=17.0.0'
- preact: '>=10.0.0'
- react: '>=17.0.0'
- solid-js: '>=1.9.7'
- vue: '>=3.2.0'
- peerDependenciesMeta:
- '@types/react':
- optional: true
- preact:
- optional: true
- react:
- optional: true
- solid-js:
- optional: true
- vue:
- optional: true
-
- '@tanstack/devtools@0.10.4':
- resolution: {integrity: sha512-GR/HMWe+eAZgSm/mOeuWMs/cXy3pEcrdMBU+OH0c6Qv1IXYv/xqru4aCSJPe+2/eJXng5ioqCsoVt9MztyU1mg==}
- engines: {node: '>=18'}
- peerDependencies:
- solid-js: '>=1.9.7'
-
'@tanstack/eslint-plugin-query@5.83.1':
resolution: {integrity: sha512-tdkpPFfzkTksN9BIlT/qjixSAtKrsW6PUVRwdKWaOcag7DrD1vpki3UzzdfMQGDRGeg1Ue1Dg+rcl5FJGembNg==}
peerDependencies:
@@ -3426,11 +3349,6 @@ packages:
'@tanstack/form-core@1.28.0':
resolution: {integrity: sha512-MX3YveB6SKHAJ2yUwp+Ca/PCguub8bVEnLcLUbFLwdkSRMkP0lMGdaZl+F0JuEgZw56c6iFoRyfILhS7OQpydA==}
- '@tanstack/form-devtools@0.2.13':
- resolution: {integrity: sha512-1ulPyukzs28GFKuY5Qkic3MHCkLm6wG/4ofqgsruHe44VXeCBMqtlwgExaPHrBOdhtfcy26TSJVDvOVJ+igWBA==}
- peerDependencies:
- solid-js: '>=1.9.9'
-
'@tanstack/pacer-lite@0.1.1':
resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==}
engines: {node: '>=18'}
@@ -3451,20 +3369,6 @@ packages:
'@tanstack/query-persist-client-core@5.83.1':
resolution: {integrity: sha512-GPWt1tj8kmo3LA1WPpSmJA3JGCdQfaggb1LheFEfr3RuwbTchWd09xD/fZ40m9ai0pJupvyguLiWF8On8sQWPw==}
- '@tanstack/react-devtools@0.9.3':
- resolution: {integrity: sha512-SJTYWXWZkbWznwUwZ11awinPGB5StVIVyJXT0BFM1zUgjuajRwT8xRHl1oXVzVqqjJP5kfj89jkbFrcQPpq7Ng==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/react': '>=16.8'
- '@types/react-dom': '>=16.8'
- react: '>=16.8'
- react-dom: '>=16.8'
-
- '@tanstack/react-form-devtools@0.2.13':
- resolution: {integrity: sha512-ggDXw4Pyp5zkbz2j+wJqjTy462PvbHeY97az+cw2GDbtVM5VedpLH86QKAeZofnSgx5ZKPPPn0op4LSAYipDwQ==}
- peerDependencies:
- react: ^17.0.0 || ^18.0.0 || ^19.0.0
-
'@tanstack/react-form@1.28.0':
resolution: {integrity: sha512-ibLcf5QkTogV0Ly944CuqGxWTpHyreNA4Cy8Wtky7zE9wtE3HVapQt4/hUuXo51zihfTkv5URiXpoTSKF5Xosg==}
peerDependencies:
@@ -4843,9 +4747,6 @@ packages:
csstype@3.0.8:
resolution: {integrity: sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==}
- csstype@3.2.3:
- resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
-
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -4866,9 +4767,6 @@ packages:
resolution: {integrity: sha512-ZEhqxUtEZeGgg9eHNSOAJ8O9xqSgiJdrL0lzSSfMF54x6KXWJiOH/xntSJ9YomJPrYH/p08t6gWjGWq1SDJlSA==}
engines: {node: '>=0.11'}
- dayjs@1.11.19:
- resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
-
debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
@@ -5897,11 +5795,6 @@ packages:
globjoin@0.1.4:
resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==}
- goober@2.1.18:
- resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==}
- peerDependencies:
- csstype: ^3.0.10
-
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -8174,16 +8067,6 @@ packages:
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
- seroval-plugins@1.5.0:
- resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==}
- engines: {node: '>=10'}
- peerDependencies:
- seroval: ^1.0
-
- seroval@1.5.0:
- resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==}
- engines: {node: '>=10'}
-
serve-index@1.9.1:
resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==}
engines: {node: '>= 0.8.0'}
@@ -8287,9 +8170,6 @@ packages:
sockjs@0.3.24:
resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==}
- solid-js@1.9.11:
- resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==}
-
source-map-generator@2.0.2:
resolution: {integrity: sha512-unCl5BQhF/us51DiT7SvlSY3QUPhyfAdHJxd8l7FXdwzqxli0UDMV2dEuei2SeGp3Z4rB/AJ9zKi1mGOp2K2ww==}
engines: {node: '>=20'}
@@ -12613,40 +12493,6 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
- '@solid-primitives/event-listener@2.4.3(solid-js@1.9.11)':
- dependencies:
- '@solid-primitives/utils': 6.3.2(solid-js@1.9.11)
- solid-js: 1.9.11
-
- '@solid-primitives/keyboard@1.3.3(solid-js@1.9.11)':
- dependencies:
- '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.11)
- '@solid-primitives/rootless': 1.5.2(solid-js@1.9.11)
- '@solid-primitives/utils': 6.3.2(solid-js@1.9.11)
- solid-js: 1.9.11
-
- '@solid-primitives/resize-observer@2.1.3(solid-js@1.9.11)':
- dependencies:
- '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.11)
- '@solid-primitives/rootless': 1.5.2(solid-js@1.9.11)
- '@solid-primitives/static-store': 0.1.2(solid-js@1.9.11)
- '@solid-primitives/utils': 6.3.2(solid-js@1.9.11)
- solid-js: 1.9.11
-
- '@solid-primitives/rootless@1.5.2(solid-js@1.9.11)':
- dependencies:
- '@solid-primitives/utils': 6.3.2(solid-js@1.9.11)
- solid-js: 1.9.11
-
- '@solid-primitives/static-store@0.1.2(solid-js@1.9.11)':
- dependencies:
- '@solid-primitives/utils': 6.3.2(solid-js@1.9.11)
- solid-js: 1.9.11
-
- '@solid-primitives/utils@6.3.2(solid-js@1.9.11)':
- dependencies:
- solid-js: 1.9.11
-
'@standard-schema/spec@1.0.0': {}
'@stripe/react-stripe-js@3.9.2(@stripe/stripe-js@5.10.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
@@ -12676,55 +12522,10 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
- '@tanstack/devtools-client@0.0.5':
- dependencies:
- '@tanstack/devtools-event-client': 0.4.0
-
- '@tanstack/devtools-event-bus@0.4.0':
- dependencies:
- ws: 8.19.0
- transitivePeerDependencies:
- - bufferutil
- - utf-8-validate
-
'@tanstack/devtools-event-client@0.3.4': {}
'@tanstack/devtools-event-client@0.4.0': {}
- '@tanstack/devtools-ui@0.4.4(csstype@3.2.3)(solid-js@1.9.11)':
- dependencies:
- clsx: 2.1.1
- goober: 2.1.18(csstype@3.2.3)
- solid-js: 1.9.11
- transitivePeerDependencies:
- - csstype
-
- '@tanstack/devtools-utils@0.3.0(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)':
- dependencies:
- '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.11)
- optionalDependencies:
- '@types/react': 19.2.1
- react: 19.2.3
- solid-js: 1.9.11
- transitivePeerDependencies:
- - csstype
-
- '@tanstack/devtools@0.10.4(csstype@3.2.3)(solid-js@1.9.11)':
- dependencies:
- '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.11)
- '@solid-primitives/keyboard': 1.3.3(solid-js@1.9.11)
- '@solid-primitives/resize-observer': 2.1.3(solid-js@1.9.11)
- '@tanstack/devtools-client': 0.0.5
- '@tanstack/devtools-event-bus': 0.4.0
- '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.11)
- clsx: 2.1.1
- goober: 2.1.18(csstype@3.2.3)
- solid-js: 1.9.11
- transitivePeerDependencies:
- - bufferutil
- - csstype
- - utf-8-validate
-
'@tanstack/eslint-plugin-query@5.83.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/utils': 8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3)
@@ -12739,22 +12540,6 @@ snapshots:
'@tanstack/pacer-lite': 0.1.1
'@tanstack/store': 0.7.7
- '@tanstack/form-devtools@0.2.13(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)':
- dependencies:
- '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.11)
- '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)
- '@tanstack/form-core': 1.28.0
- clsx: 2.1.1
- dayjs: 1.11.19
- goober: 2.1.18(csstype@3.2.3)
- solid-js: 1.9.11
- transitivePeerDependencies:
- - '@types/react'
- - csstype
- - preact
- - react
- - vue
-
'@tanstack/pacer-lite@0.1.1': {}
'@tanstack/pacer@0.16.0':
@@ -12774,31 +12559,6 @@ snapshots:
dependencies:
'@tanstack/query-core': 5.83.1
- '@tanstack/react-devtools@0.9.3(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.11)':
- dependencies:
- '@tanstack/devtools': 0.10.4(csstype@3.2.3)(solid-js@1.9.11)
- '@types/react': 19.2.1
- '@types/react-dom': 19.2.1(@types/react@19.2.1)
- react: 19.2.3
- react-dom: 19.2.3(react@19.2.3)
- transitivePeerDependencies:
- - bufferutil
- - csstype
- - solid-js
- - utf-8-validate
-
- '@tanstack/react-form-devtools@0.2.13(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)':
- dependencies:
- '@tanstack/devtools-utils': 0.3.0(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)
- '@tanstack/form-devtools': 0.2.13(@types/react@19.2.1)(csstype@3.2.3)(react@19.2.3)(solid-js@1.9.11)
- react: 19.2.3
- transitivePeerDependencies:
- - '@types/react'
- - csstype
- - preact
- - solid-js
- - vue
-
'@tanstack/react-form@1.28.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@tanstack/form-core': 1.28.0
@@ -14324,8 +14084,6 @@ snapshots:
csstype@3.0.8: {}
- csstype@3.2.3: {}
-
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -14351,8 +14109,6 @@ snapshots:
date-fns@2.17.0: {}
- dayjs@1.11.19: {}
-
debounce@1.2.1: {}
debug@2.6.9:
@@ -15640,10 +15396,6 @@ snapshots:
globjoin@0.1.4: {}
- goober@2.1.18(csstype@3.2.3):
- dependencies:
- csstype: 3.2.3
-
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@@ -18627,12 +18379,6 @@ snapshots:
dependencies:
randombytes: 2.1.0
- seroval-plugins@1.5.0(seroval@1.5.0):
- dependencies:
- seroval: 1.5.0
-
- seroval@1.5.0: {}
-
serve-index@1.9.1:
dependencies:
accepts: 1.3.8
@@ -18789,12 +18535,6 @@ snapshots:
uuid: 8.3.2
websocket-driver: 0.7.4
- solid-js@1.9.11:
- dependencies:
- csstype: 3.2.3
- seroval: 1.5.0
- seroval-plugins: 1.5.0(seroval@1.5.0)
-
source-map-generator@2.0.2: {}
source-map-js@1.2.1: {}
diff --git a/static/app/components/core/button/buttonBar.mdx b/static/app/components/core/button/buttonBar.mdx
index ac162271f233f1..26246ab05822cf 100644
--- a/static/app/components/core/button/buttonBar.mdx
+++ b/static/app/components/core/button/buttonBar.mdx
@@ -1,7 +1,7 @@
---
title: ButtonBar
description: ButtonBar is a specialized component for creating pill-like button layouts where buttons are visually joined together.
-category: buttons
+category: controls
source: '@sentry/scraps/button'
resources:
js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/button/buttonBar.tsx
diff --git a/static/app/components/core/compactSelect/compactSelect.mdx b/static/app/components/core/compactSelect/compactSelect.mdx
index f9e0d60a96f4d7..0ac272818d7c5d 100644
--- a/static/app/components/core/compactSelect/compactSelect.mdx
+++ b/static/app/components/core/compactSelect/compactSelect.mdx
@@ -1,7 +1,7 @@
---
title: CompactSelect
description: A versatile dropdown select component for non-form contexts, supporting sections, search, multi-select, custom triggers, and performance optimizations.
-category: forms
+category: controls
source: '@sentry/scraps/compactSelect'
resources:
figma: https://www.figma.com/design/eTJz6aPgudMY9E6mzyZU0B/ChonkUI--App-Components--WIP-?node-id=384-2119&t=DdXZ7WIgTdURJlRv-4
diff --git a/static/app/components/core/compactSelect/composite.mdx b/static/app/components/core/compactSelect/composite.mdx
index da90b27a2e4a1c..c308a3f398e96b 100644
--- a/static/app/components/core/compactSelect/composite.mdx
+++ b/static/app/components/core/compactSelect/composite.mdx
@@ -1,7 +1,7 @@
---
title: CompositeSelect
description: A specialized dropdown component that combines multiple independent select sections, each with its own single or multi-select behavior.
-category: forms
+category: controls
source: '@sentry/scraps/compactSelect'
resources:
figma: https://www.figma.com/design/eTJz6aPgudMY9E6mzyZU0B/ChonkUI--App-Components--WIP-?node-id=384-2119&t=DdXZ7WIgTdURJlRv-4
diff --git a/static/app/components/core/form/__stories__/formDemos.tsx b/static/app/components/core/form/__stories__/formDemos.tsx
new file mode 100644
index 00000000000000..2f04b3abfff5ac
--- /dev/null
+++ b/static/app/components/core/form/__stories__/formDemos.tsx
@@ -0,0 +1,418 @@
+/**
+ * Demo components for form .mdx documentation.
+ *
+ * Extracted into a .tsx file because prettier's MDX parser flattens JSX
+ * indentation inside exported functions when dotted component names are used
+ * (e.g. form.AppForm, field.Layout.Row).
+ */
+import {z} from 'zod';
+
+import {
+ AutoSaveField,
+ defaultFormOptions,
+ FieldGroup,
+ useScrapsForm,
+} from '@sentry/scraps/form';
+import {Flex} from '@sentry/scraps/layout';
+
+import {t} from 'sentry/locale';
+
+// ──────────────────────────────────────────────
+// form.mdx demos
+// ──────────────────────────────────────────────
+
+const quickStartSchema = z.object({
+ email: z.email('Please enter a valid email'),
+ name: z.string().min(2, 'Name must be at least 2 characters'),
+});
+
+export function QuickStartDemo() {
+ const form = useScrapsForm({
+ ...defaultFormOptions,
+ defaultValues: {
+ email: '',
+ name: '',
+ },
+ validators: {
+ onDynamic: quickStartSchema,
+ },
+ onSubmit: ({value}) => {
+ // eslint-disable-next-line no-alert
+ alert(JSON.stringify(value, null, 2));
+ },
+ });
+
+ return (
+
+
+
+ {field => (
+
+
+
+ )}
+
+
+ {field => (
+
+
+
+ )}
+
+
+
+ Submit
+
+
+ );
+}
+
+export function CompactDemo() {
+ const form = useScrapsForm({
+ ...defaultFormOptions,
+ defaultValues: {field1: '', field2: '', field3: '', field4: ''},
+ });
+
+ return (
+
+
+
+ {field => (
+
+
+
+ )}
+
+
+ {field => (
+
+
+
+ )}
+
+
+
+
+ {field => (
+
+
+
+ )}
+
+
+ {field => (
+
+
+
+ )}
+
+
+
+ );
+}
+
+const conditionalSchema = z.object({
+ plan: z.string(),
+ billingEmail: z.string(),
+});
+
+export function ConditionalDemo() {
+ const form = useScrapsForm({
+ ...defaultFormOptions,
+ defaultValues: {plan: 'free', billingEmail: ''},
+ validators: {onDynamic: conditionalSchema},
+ onSubmit: ({value}) => {
+ // eslint-disable-next-line no-alert
+ alert(JSON.stringify(value, null, 2));
+ },
+ });
+
+ return (
+
+
+
+ {field => (
+
+
+
+ )}
+
+ state.values.plan === 'enterprise'}>
+ {showBilling =>
+ showBilling ? (
+
+ {field => (
+
+
+
+ )}
+
+ ) : null
+ }
+
+
+
+ Submit
+
+
+ );
+}
+
+// ──────────────────────────────────────────────
+// fields.mdx demos
+// ──────────────────────────────────────────────
+
+export function BaseFieldDemo() {
+ const form = useScrapsForm({
+ ...defaultFormOptions,
+ defaultValues: {color: '#3c74dd'},
+ validators: {
+ onDynamic: z.object({
+ color: z.string().min(1, 'Please select a color'),
+ }),
+ },
+ onSubmit: ({value}) => {
+ // eslint-disable-next-line no-alert
+ alert(JSON.stringify(value, null, 2));
+ },
+ });
+
+ return (
+
+
+
+ {field => (
+
+ >
+ {(baseProps, {indicator}) => (
+
+ field.handleChange(e.target.value)}
+ />
+ {indicator}
+
+ )}
+
+
+ )}
+
+
+
+ Submit
+
+
+ );
+}
+
+// ──────────────────────────────────────────────
+// autoSaveField.mdx demos
+// ──────────────────────────────────────────────
+
+const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+const basicSchema = z.object({
+ displayName: z.string().min(1, 'Display name is required'),
+});
+
+const basicMutationOptions = {
+ mutationFn: async (data: unknown) => {
+ await sleep(1000);
+ return data;
+ },
+};
+
+export function BasicAutoSaveDemo() {
+ return (
+
+
+ {field => (
+
+
+
+ )}
+
+
+ );
+}
+
+const fullSchema = z.object({
+ name: z.string().min(2, 'Name must be at least 2 characters'),
+ notifications: z.boolean().optional(),
+ priority: z.string().optional(),
+ bio: z.string().optional(),
+ volume: z.number().min(0).max(100).optional(),
+ tags: z.array(z.string()).optional(),
+});
+
+const TAG_OPTIONS = [
+ {value: 'bug', label: 'Bug'},
+ {value: 'feature', label: 'Feature'},
+ {value: 'enhancement', label: 'Enhancement'},
+];
+
+export function FullAutoSaveDemo() {
+ const fullMutationOptions = {
+ mutationFn: async (data: Record) => {
+ await sleep(1000);
+ return data;
+ },
+ };
+
+ return (
+
+
+ {field => (
+
+
+
+ )}
+
+
+
+ value
+ ? 'Are you sure you want to enable notifications?'
+ : 'Disabling notifications means you may miss important updates.'
+ }
+ >
+ {field => (
+
+
+
+ )}
+
+
+
+ {field => (
+
+
+
+ )}
+
+
+
+ {field => (
+
+
+
+ {t('Low')}
+ {t('Medium')}
+ {t('High')}
+
+
+
+ )}
+
+
+
+ {field => (
+
+
+
+ )}
+
+
+
+ {field => (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/static/app/components/core/form/autoSaveField.mdx b/static/app/components/core/form/autoSaveField.mdx
new file mode 100644
index 00000000000000..3a0ff4dbd05582
--- /dev/null
+++ b/static/app/components/core/form/autoSaveField.mdx
@@ -0,0 +1,147 @@
+---
+title: AutoSaveField
+description: A field component that automatically saves changes individually, for settings pages and forms where each field persists independently.
+category: forms
+source: '@sentry/scraps/form'
+resources:
+ js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/form/field/autoSaveField.tsx
+---
+
+import {Container} from '@sentry/scraps/layout';
+
+import {BasicAutoSaveDemo, FullAutoSaveDemo} from './__stories__/formDemos';
+
+import * as Storybook from 'sentry/stories';
+
+`AutoSaveField` is for settings pages where each field saves independently. Unlike `useScrapsForm`, each `AutoSaveField` creates its own form instance internally — there's no shared form, no submit button, and no form-level submission.
+
+For traditional submit-based forms, see [Form](./form.mdx).
+
+## Basic Usage
+
+
+
+
+
+
+
+```jsx
+import {z} from 'zod';
+
+import {AutoSaveField} from '@sentry/scraps/form';
+
+import {fetchMutation} from 'sentry/utils/queryClient';
+
+const schema = z.object({
+ displayName: z.string().min(1, 'Display name is required'),
+});
+
+ fetchMutation({url: '/user/', method: 'PUT', data}),
+ onSuccess: data => {
+ queryClient.setQueryData(['user'], old => ({...old, ...data}));
+ },
+ }}
+>
+ {field => (
+
+
+
+ )}
+ ;
+```
+
+## Props
+
+| Prop | Type | Required | Description |
+| ----------------- | ------------------------------------------ | -------- | --------------------------------------------------------------------------------------------- |
+| `name` | `string` | Yes | The field name — must be a key in the schema. |
+| `schema` | `ZodObject` | Yes | Zod schema for validation. |
+| `initialValue` | `SchemaType[name]` | Yes | The current value from server data. |
+| `mutationOptions` | `UseMutationOptions` | Yes | TanStack Query mutation options. `mutationFn` receives `{[name]: value}`. |
+| `children` | `(field) => ReactNode` | Yes | Render prop receiving the field API with all bound field components. |
+| `confirm` | `string \| (value) => string \| undefined` | No | Confirmation dialog message before saving. See [Confirmation Dialogs](#confirmation-dialogs). |
+
+## Save Behavior by Field Type
+
+| Field Type | When It Saves |
+| ----------------- | --------------------------------------------------------------- |
+| Input, TextArea | On blur (when user leaves the field) |
+| Select (single) | Immediately when selection changes |
+| Select (multiple) | When menu closes, or when clear is clicked while menu is closed |
+| Switch | Immediately when toggled |
+| Radio | Immediately when selection changes |
+| Range | When user releases the slider, or immediately with keyboard |
+
+## Status Indicators
+
+`AutoSaveField` automatically shows inline status indicators:
+
+- **Spinner** — while saving (mutation pending)
+- **Checkmark** — on success (fades after 2 seconds)
+- **Warning icon** — on validation or mutation error (with tooltip showing the error message)
+
+> [!NOTE]
+> Do NOT use toasts to communicate auto-save status. The built-in inline indicators are the correct feedback mechanism. Toasts are noisy and disruptive for fields that save frequently.
+
+## Confirmation Dialogs
+
+For dangerous operations (security settings, permissions), use the `confirm` prop to show a confirmation modal before saving. It accepts a string (always shown) or a function (conditionally shown).
+
+```jsx
+// Always confirm
+
+
+// Conditional — only confirm when enabling
+ value ? t('Are you sure you want to enable this?') : undefined}
+ {...otherProps}
+>
+
+// Different messages per direction
+
+ value
+ ? t('Enable 2FA requirement for all members?')
+ : t('Allow members without 2FA?')
+ }
+ {...otherProps}
+>
+```
+
+> [!NOTE]
+> Confirmation dialogs always focus the Cancel button for safety, preventing accidental confirmation of dangerous operations.
+
+## Cache Updates
+
+Always update the React Query cache (or invalidate it with `invalidateQueries`) in `onSuccess` so the UI stays in sync:
+
+```jsx
+mutationOptions={{
+ mutationFn: data =>
+ fetchMutation({url: '/user/', method: 'PUT', data}),
+ onSuccess: data => {
+ queryClient.setQueryData(['user'], old => ({...old, ...data}));
+ },
+}}
+```
+
+## Full Example
+
+A settings page with multiple field types.
+
+
+
+
+
+## See Also
+
+- [Form](./form.mdx) — Submit-based forms with `useScrapsForm`
+- [Fields Reference](./fields.mdx) — All available field components
diff --git a/static/app/components/core/form/field/autoSaveField.tsx b/static/app/components/core/form/field/autoSaveField.tsx
index eb17d4634fa932..c305a9027e812b 100644
--- a/static/app/components/core/form/field/autoSaveField.tsx
+++ b/static/app/components/core/form/field/autoSaveField.tsx
@@ -121,7 +121,7 @@ interface AutoSaveFieldProps<
mutationOptions: UseMutationOptions<
any, // it doesn't matter here what the mutation returns
Error,
- Record[TFieldName]>
+ NoInfer[TFieldName]>>
>;
/**
diff --git a/static/app/components/core/form/fields.mdx b/static/app/components/core/form/fields.mdx
new file mode 100644
index 00000000000000..c21a99fccb1a3e
--- /dev/null
+++ b/static/app/components/core/form/fields.mdx
@@ -0,0 +1,284 @@
+---
+title: Form Fields
+description: Reference for all available form field components and how to create custom fields.
+category: forms
+source: '@sentry/scraps/form'
+resources:
+ js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/form/field
+---
+
+import {Container} from '@sentry/scraps/layout';
+
+import {BaseFieldDemo} from './__stories__/formDemos';
+
+import * as Storybook from 'sentry/stories';
+
+All field components are accessed via the `field` render prop inside `form.AppField`. They follow a consistent pattern: pass `value` and `onChange` from the field state, and wrap in a layout component. They are built on top of the form primitives [Input](./input.mdx), [Select](./select.mdx), [Slider](./slider.mdx), [Switch](./switch.mdx) etc.
+
+```jsx
+
+ {field => (
+
+
+
+ )}
+
+```
+
+## Input
+
+Text input for single-line string values.
+
+```jsx
+
+ {field => (
+
+
+
+ )}
+
+```
+
+## Number
+
+Numeric input with optional min/max/step constraints.
+
+```jsx
+
+ {field => (
+
+
+
+ )}
+
+```
+
+## Password
+
+Password input with a built-in show/hide toggle.
+
+```jsx
+
+ {field => (
+
+
+
+ )}
+
+```
+
+## TextArea
+
+Multi-line text input. Use `rows` to control the default height.
+
+```jsx
+
+ {field => (
+
+
+
+ )}
+
+```
+
+## Select
+
+Single-value select dropdown.
+
+```jsx
+
+ {field => (
+
+
+
+ )}
+
+```
+
+### Multiple Selection
+
+Add the `multiple` prop for multi-select. The field value should be an array.
+
+```jsx
+
+ {field => (
+
+
+
+ )}
+
+```
+
+## SelectAsync
+
+Async search select that loads options from an API. Pass `queryOptions` to configure the data fetching.
+
+```jsx
+
+ {field => (
+
+ ({
+ queryKey: ['users', inputValue],
+ queryFn: () => fetchUsers({search: inputValue}),
+ })}
+ />
+
+ )}
+
+```
+
+## Switch
+
+Boolean toggle for on/off values. Uses `checked` instead of `value`.
+
+```jsx
+
+ {field => (
+
+
+
+ )}
+
+```
+
+## Range
+
+Slider input for numeric values within a range.
+
+```jsx
+
+ {field => (
+
+
+
+ )}
+
+```
+
+## Radio
+
+Radio fields use a composable API with `Radio.Group` and `Radio.Item`.
+
+> [!WARNING]
+> The layout **must** be rendered inside `Radio.Group`. The group provides context that changes how the label is rendered for proper accessibility semantics.
+
+```jsx
+
+ {field => (
+
+
+ {t('Low')}
+ {t('Medium')}
+
+ {t('High')}
+
+
+
+ )}
+
+```
+
+For horizontal arrangement, wrap the items in a `Flex`:
+
+```jsx
+import {Flex} from '@sentry/scraps/layout';
+
+
+
+
+ {t('Low')}
+ {t('High')}
+
+
+ ;
+```
+
+## Custom Fields with `field.Base`
+
+For one-off fields that don't have a built-in component (color picker, date picker, etc.), use `field.Base`. It provides a render prop with all the accessibility and form integration props you need.
+
+
+
+
+
+
+
+```jsx
+
+ {field => (
+
+ >
+ {(baseProps, {indicator}) => (
+
+ field.handleChange(e.target.value)}
+ />
+ {indicator}
+
+ )}
+
+
+ )}
+
+```
+
+The render prop receives two arguments:
+
+1. **`baseProps`** — Props to spread onto your element: `ref`, `disabled`, `aria-invalid`, `aria-describedby`, `onBlur`, `name`, `id`.
+2. **`{indicator}`** — The auto-save status indicator (spinner/checkmark) as a React node. Place it wherever makes sense in your layout.
+
+The element type is inferred from the passed `ref`. If you don't pass one, annotate it manually: `>`.
+
+`field.Base` automatically handles:
+
+- Merging refs (for scroll-to-hash and external ref forwarding)
+- Disabling the field when auto-save is pending
+- Setting `aria-invalid` based on validation state
+- Linking to hint text via `aria-describedby`
+
+## See Also
+
+- [Form](./form.mdx) — Main form documentation
+- [AutoSaveField](./autoSaveField.mdx) — Auto-save fields for settings pages
diff --git a/static/app/components/core/form/form.mdx b/static/app/components/core/form/form.mdx
new file mode 100644
index 00000000000000..f38914a7cbc94c
--- /dev/null
+++ b/static/app/components/core/form/form.mdx
@@ -0,0 +1,361 @@
+---
+title: Form
+description: A form system built on TanStack React Form and Zod for building validated forms with consistent patterns.
+category: forms
+source: '@sentry/scraps/form'
+resources:
+ js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/form/scrapsForm.tsx
+---
+
+import {Container} from '@sentry/scraps/layout';
+
+import {CompactDemo, ConditionalDemo, QuickStartDemo} from './__stories__/formDemos';
+
+import * as Storybook from 'sentry/stories';
+
+Sentry's form system is built on top of [TanStack Form](https://tanstack.com/form/latest), a headless, type-safe form library for React. Because TanStack Form is headless, it handles form state, validation, and submission without imposing any UI — our system layers Sentry's field components, layouts, and accessibility patterns on top.
+
+Validation is schema-driven with [Zod](https://zod.dev/). You define a single schema for your form, and the system takes care of field-level validation, error messages, and type inference — your `mutationFn`, field values, and default values are all typed from the schema automatically.
+
+The goal is to stay out of your way when you need flexibility while giving you consistency and accessibility out of the box. Standard fields, layouts, error handling, and auto-save patterns all work the same across the app without extra wiring.
+
+> [!TIP]
+> TanStack provides an [MCP server](https://tanstack.com/form/latest/docs/mcp) that gives your AI editor access to up-to-date TanStack Form documentation. It's worth setting up if you're building forms frequently.
+
+The form system provides two modes:
+
+- **Submit-based forms** — Use `useScrapsForm` for traditional forms with a submit button. Best for creation flows, dialogs, and multi-field submissions.
+- **Auto-save fields** — Use `AutoSaveField` for settings pages where each field saves independently on blur or change. See [AutoSaveField](./autoSaveField.mdx).
+
+For a reference of all available field components, see [Fields](./fields.mdx).
+
+## Quick Start
+
+A minimal form with validation and submission.
+
+
+
+
+
+
+
+```jsx
+import {z} from 'zod';
+
+import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form';
+
+const schema = z.object({
+ email: z.email('Please enter a valid email'),
+ name: z.string().min(2, 'Name must be at least 2 characters'),
+});
+
+function MyForm() {
+ const form = useScrapsForm({
+ ...defaultFormOptions,
+ defaultValues: {email: '', name: ''},
+ validators: {onDynamic: schema},
+ onSubmit: ({value}) => {
+ console.log(value);
+ },
+ });
+
+ return (
+
+
+ {field => (
+
+
+
+ )}
+
+ Submit
+
+ );
+}
+```
+
+## Form Hook: `useScrapsForm`
+
+Create a form by calling `useScrapsForm` with your configuration. Always spread `defaultFormOptions` first — it configures validation to run on submit initially, then revalidate on every change after the first submission attempt, and it also makes sure that the first erroneous field is automatically focused.
+
+```jsx
+const form = useScrapsForm({
+ ...defaultFormOptions,
+ defaultValues: {email: '', name: ''},
+ validators: {onDynamic: schema},
+ onSubmit: ({value, formApi}) => {
+ // Handle submission
+ },
+});
+```
+
+### Returned Properties
+
+| Property | Description |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `AppForm` | Root wrapper component. Provides form context and renders a `
+ {field => (
+
+
+
+ )}
+
+```
+
+## Layouts
+
+Two layout options position labels relative to fields.
+
+### Stack Layout (Vertical)
+
+Label above, field below. Best for forms with longer labels or when vertical space is available.
+
+```jsx
+
+
+
+```
+
+### Row Layout (Horizontal)
+
+Label on the left (~50%), field on the right. Compact layout for settings pages.
+
+```jsx
+
+
+
+```
+
+### Compact Variant
+
+Both layouts support `variant="compact"`, which moves hint text into a tooltip on the label instead of displaying it inline. This saves vertical space.
+
+
+
+
+
+```jsx
+// Default: hint text appears below the label
+
+
+
+
+// Compact: hint text appears in tooltip when hovering the label
+
+
+
+```
+
+### Layout Props
+
+| Prop | Type | Description |
+| ---------- | ----------- | --------------------------------------------------------------------- |
+| `label` | `ReactNode` | Field label text |
+| `hintText` | `ReactNode` | Helper text. Below label by default, or in a tooltip in compact mode. |
+| `required` | `boolean` | Shows a required indicator next to the label |
+| `variant` | `"compact"` | Moves hint text into a tooltip instead of displaying it inline |
+
+### Custom Layouts
+
+You can skip the built-in layouts and use `field.Meta.Label` and `field.Meta.HintText` directly:
+
+```jsx
+
+ {field => (
+
+ {t('First Name')}
+
+
+ )}
+
+```
+
+## Field Groups
+
+Group related fields into titled sections using `form.FieldGroup`. It renders a `Panel` with a header and body.
+
+```jsx
+
+ {/* ... */}
+ {/* ... */}
+
+
+
+ {/* ... */}
+ {/* ... */}
+
+```
+
+## Disabled State
+
+Fields accept `disabled` as a boolean or a string. When a string is provided, it displays as a tooltip explaining why the field is disabled.
+
+```jsx
+// Boolean — disabled without explanation
+
+
+// String — disabled with a reason shown as tooltip
+
+```
+
+> [!TIP]
+> Prefer using a string for `disabled` so users understand why the field is not editable.
+
+## Conditional Fields
+
+Use `form.Subscribe` to show or hide fields based on other field values:
+
+
+
+
+
+```jsx
+ state.values.plan === 'enterprise'}>
+ {showBilling =>
+ showBilling ? (
+
+ {field => (
+
+
+
+ )}
+
+ ) : null
+ }
+
+```
+
+## Error Handling
+
+### Client-Side Errors
+
+Client-side validation is automatic when using a Zod schema. Errors display as a warning icon with a tooltip in the field's trailing area.
+
+### Server-Side Errors
+
+Use `setFieldErrors` to display backend validation errors on specific fields:
+
+```jsx
+import {setFieldErrors} from '@sentry/scraps/form';
+
+onSubmit: async ({value, formApi}) => {
+ try {
+ await mutation.mutateAsync(value);
+ } catch (error) {
+ setFieldErrors(formApi, {
+ email: {message: 'This email is already registered'},
+ 'address.city': {message: 'City not found'},
+ });
+ }
+},
+```
+
+> [!NOTE]
+> `setFieldErrors` supports nested paths with dot notation, e.g. `'address.city'`.
+
+## Form Submission
+
+Always use TanStack Query mutations (`useMutation`) for form submissions. Return the promise from `onSubmit` so that `form.isSubmitting` stays accurate, and add `.catch(() => {})` to avoid unhandled promise rejections — error handling is done by TanStack Query.
+
+```jsx
+import {useMutation} from '@tanstack/react-query';
+
+import {fetchMutation} from 'sentry/utils/queryClient';
+
+function MyForm() {
+ const mutation = useMutation({
+ mutationFn: (data: FormData) =>
+ fetchMutation({url: '/endpoint/', method: 'POST', data}),
+ });
+
+ const form = useScrapsForm({
+ ...defaultFormOptions,
+ defaultValues: {...},
+ validators: {onDynamic: schema},
+ onSubmit: ({value}) => {
+ return mutation.mutateAsync(value).catch(() => {});
+ },
+ });
+
+ return (
+
+ {/* fields */}
+
+ form.reset()}>Reset
+ Save Changes
+
+
+ );
+}
+```
+
+`SubmitButton` automatically disables while submission is pending and triggers form validation before submitting.
+
+## See Also
+
+- [Fields Reference](./fields.mdx) — All available field components
+- [AutoSaveField](./autoSaveField.mdx) — Auto-save fields for settings pages
diff --git a/static/app/components/core/form/form.stories.tsx b/static/app/components/core/form/form.stories.tsx
deleted file mode 100644
index 823502deaab895..00000000000000
--- a/static/app/components/core/form/form.stories.tsx
+++ /dev/null
@@ -1,615 +0,0 @@
-import {Fragment} from 'react';
-import {TanStackDevtools} from '@tanstack/react-devtools';
-import {formDevtoolsPlugin} from '@tanstack/react-form-devtools';
-import {
- mutationOptions,
- queryOptions,
- useQuery,
- useQueryClient,
- type QueryClient,
-} from '@tanstack/react-query';
-import {z} from 'zod';
-
-import {Button} from '@sentry/scraps/button';
-import {
- AutoSaveField,
- defaultFormOptions,
- FieldGroup,
- setFieldErrors,
- useScrapsForm,
-} from '@sentry/scraps/form';
-import {Flex, Stack} from '@sentry/scraps/layout';
-
-import * as Storybook from 'sentry/stories';
-
-const COUNTRY_OPTIONS = [
- {value: 'US', label: 'United States'},
- {value: 'CA', label: 'Canada'},
- {value: 'AT', label: 'Austria'},
-];
-
-const TAG_OPTIONS = [
- {value: 'bug', label: 'Bug'},
- {value: 'feature', label: 'Feature'},
- {value: 'enhancement', label: 'Enhancement'},
- {value: 'docs', label: 'Documentation'},
-];
-
-const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
-
-const baseUserSchema = z.object({
- age: z.number('Age is required').gte(13, 'You must be 13 to make an account'),
- firstName: z.string().optional(),
- lastName: z.string().min(2, 'Last name must be at least 2 characters'),
- secret: z.string().optional(),
- notifications: z.boolean().optional(),
- volume: z.number().min(0).max(100).optional(),
- bio: z.string().optional(),
- priority: z.string().optional(),
- tags: z.array(z.string()).optional(),
- address: z.object({
- street: z.string().min(1, 'Street is required'),
- city: z.string().min(1, 'City is required'),
- country: z.string().min(1, 'Country is required'),
- }),
-});
-
-const userSchema = baseUserSchema.refine(
- data => {
- if (data.age === 42) {
- return !!data.secret && data.secret.length > 0;
- }
- return true;
- },
- {
- message: 'Secret is required when age is 42',
- path: ['secret'],
- }
-);
-
-const userQuery = queryOptions({
- queryKey: ['user', 'example'],
- queryFn: async () => {
- await sleep(500);
- return userSchema.parse({
- firstName: 'John',
- lastName: 'Doe',
- age: 23,
- priority: 'medium',
- address: {
- street: '123 Main St',
- city: 'Anytown',
- country: 'US',
- },
- });
- },
-});
-
-type User = z.infer;
-
-const addressMutationOptions = (client: QueryClient) =>
- mutationOptions({
- mutationFn: async (variables: Partial): Promise => {
- // eslint-disable-next-line no-console
- console.log('saving address', variables);
- await sleep(1000);
- return {
- street: '123 Main St',
- city: 'Anytown',
- country: 'US',
- ...variables,
- };
- },
- onSuccess: data => {
- client.setQueryData(['user', 'example'], oldData => {
- if (!oldData) {
- return oldData;
- }
- return {
- ...oldData,
- address: data,
- };
- });
- },
- });
-
-const userMutationOptions = (client: QueryClient) =>
- mutationOptions({
- mutationFn: async (variables: Partial): Promise => {
- // eslint-disable-next-line no-console
- console.log('saving user', variables);
- await sleep(1000);
- return {
- firstName: 'John',
- lastName: 'Doe',
- age: 23,
- priority: 'medium',
- address: {
- street: '123 Main St',
- city: 'Anytown',
- country: 'US',
- },
- ...variables,
- };
- },
- onSuccess: data => {
- client.setQueryData(['user', 'example'], data);
- },
- });
-
-function AutoSaveExample() {
- const user = useQuery(userQuery);
- const client = useQueryClient();
-
- if (user.isPending) {
- return Loading...
;
- }
-
- return (
-
-
- {field => (
-
-
-
- )}
-
-
-
- {field => (
-
-
-
- )}
-
-
-
- {field => (
-
-
-
- )}
-
-
-
- {field => (
-
-
-
- )}
-
-
-
- value
- ? 'Are you sure you want to enable email notifications?'
- : 'Disabling notifications means you may miss important updates.'
- }
- >
- {field => (
-
-
-
- )}
-
-
-
- {field => (
-
-
-
- )}
-
-
-
- {field => (
-
-
-
- )}
-
-
-
- {field => (
-
-
-
- Low
- Medium
-
- High
-
-
-
-
- )}
-
-
- );
-}
-
-function BasicForm() {
- const user = useQuery(userQuery);
-
- const form = useScrapsForm({
- ...defaultFormOptions,
- defaultValues: user.data,
- validators: {
- onDynamic: baseUserSchema,
- },
- onSubmit: ({value, formApi}) => {
- // eslint-disable-next-line no-alert
- alert(JSON.stringify(value));
- setFieldErrors(formApi, {
- firstName: {message: 'This name is already taken'},
- });
- },
- });
-
- if (user.isPending) {
- return Loading...
;
- }
-
- return (
-
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- Low
- Medium
-
- High
-
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
- state.values.age === 42}>
- {showSecret =>
- showSecret ? (
-
- {field => (
-
-
-
- )}
-
- ) : null
- }
-
-
-
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
-
-
-
- form.reset()}>Reset
- Submit
-
-
- );
-}
-
-function BaseFieldExample() {
- const form = useScrapsForm({
- ...defaultFormOptions,
- defaultValues: {
- acceptTerms: false,
- },
- validators: {
- onDynamic: z.object({
- acceptTerms: z.literal(true, 'You must accept the terms'),
- }),
- },
- onSubmit: ({value}) => {
- // eslint-disable-next-line no-alert
- alert(JSON.stringify(value));
- },
- });
-
- return (
-
-
-
- {field => (
-
- >
- {(baseProps, {indicator}) => (
-
- field.handleChange(e.target.checked)}
- />
- {indicator}
-
- )}
-
-
- )}
-
-
-
-
- Submit
-
-
- );
-}
-
-function CompactExample() {
- const form = useScrapsForm({
- ...defaultFormOptions,
- defaultValues: {
- field1: '',
- field2: '',
- field3: '',
- field4: '',
- },
- });
-
- return (
-
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
-
-
-
-
- {field => (
-
-
-
- )}
-
-
- {field => (
-
-
-
- )}
-
-
-
- );
-}
-
-export default Storybook.story('Form', story => {
- story('Basic', () => {
- return (
-
-
-
-
- );
- });
-
- story('AutoSave', () => {
- return (
-
-
-
- );
- });
-
- story('Compact Variant', () => {
- return ;
- });
-
- story('Custom Field (BaseField)', () => {
- return ;
- });
-});
diff --git a/static/app/components/core/form/index.ts b/static/app/components/core/form/index.ts
index 176055b2f4a29f..ed6fd8fda4bf94 100644
--- a/static/app/components/core/form/index.ts
+++ b/static/app/components/core/form/index.ts
@@ -1,3 +1,4 @@
+/** @public */
export {useScrapsForm, defaultFormOptions, setFieldErrors} from './scrapsForm';
export {AutoSaveField} from './field/autoSaveField';
export {FieldGroup} from './layout/fieldGroup';
diff --git a/static/app/components/core/form/scrapsForm.tsx b/static/app/components/core/form/scrapsForm.tsx
index c000b217f9748e..aa0118287302b9 100644
--- a/static/app/components/core/form/scrapsForm.tsx
+++ b/static/app/components/core/form/scrapsForm.tsx
@@ -99,6 +99,7 @@ function FormWrapper({children}: {children: React.ReactNode}) {