diff --git a/.changeset/add-rhf-plugin.md b/.changeset/add-rhf-plugin.md new file mode 100644 index 00000000..764a4c1f --- /dev/null +++ b/.changeset/add-rhf-plugin.md @@ -0,0 +1,5 @@ +--- +'@rozenite/rhf-plugin': minor +--- + +Add React Hook Form plugin. Inspect form state, field values, errors, dirty/touched fields, and validation status in real time from React Native DevTools. diff --git a/apps/playground/package.json b/apps/playground/package.json index 9c2b9875..b7c1a470 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -33,6 +33,7 @@ "@rozenite/overlay-plugin": "workspace:*", "@rozenite/performance-monitor-plugin": "workspace:*", "@rozenite/plugin-bridge": "workspace:*", + "@rozenite/rhf-plugin": "workspace:*", "@rozenite/react-navigation-plugin": "workspace:*", "@rozenite/redux-devtools-plugin": "workspace:*", "@rozenite/require-profiler-plugin": "workspace:*", @@ -75,6 +76,7 @@ "react-native-svg-web": "~1.0.9", "react-native-web": "~0.21.0", "react-native-worklets": "0.7.2", + "react-hook-form": "^7.54.2", "react-redux": "^9.2.0", "zustand": "^5.0.6" }, diff --git a/apps/playground/src/app/App.tsx b/apps/playground/src/app/App.tsx index e17a84ea..b1ec7791 100644 --- a/apps/playground/src/app/App.tsx +++ b/apps/playground/src/app/App.tsx @@ -29,6 +29,7 @@ import { ReduxTestScreen } from './screens/ReduxTestScreen'; import { RequestBodyTestScreen } from './screens/RequestBodyTestScreen'; import { RequireProfilerTestScreen } from './screens/RequireProfilerTestScreen'; import { FileSystemTestScreen } from './screens/FileSystemTestScreen'; +import { ReactHookFormPluginScreen } from './screens/ReactHookFormPluginScreen'; import { StoragePluginScreen } from './screens/StoragePluginScreen'; import { storagePluginAdapters } from './storage-plugin-adapters'; import { sqlitePluginAdapters } from './sqlite-plugin-databases'; @@ -77,6 +78,7 @@ const Wrapper = () => { > + diff --git a/apps/playground/src/app/navigation/types.ts b/apps/playground/src/app/navigation/types.ts index c732336c..ab4be96d 100644 --- a/apps/playground/src/app/navigation/types.ts +++ b/apps/playground/src/app/navigation/types.ts @@ -3,6 +3,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'; export type RootStackParamList = { Landing: undefined; ControlsPlugin: undefined; + ReactHookFormPlugin: undefined; StoragePlugin: undefined; NetworkTest: undefined; RequestBodyTest: undefined; diff --git a/apps/playground/src/app/screens/LandingScreen.tsx b/apps/playground/src/app/screens/LandingScreen.tsx index 88010af3..28849ce4 100644 --- a/apps/playground/src/app/screens/LandingScreen.tsx +++ b/apps/playground/src/app/screens/LandingScreen.tsx @@ -44,6 +44,13 @@ export const LandingScreen = () => { Storage Plugin + navigation.navigate('ReactHookFormPlugin' as never)} + > + React Hook Form + + navigation.navigate('NetworkTest' as never)} diff --git a/apps/playground/src/app/screens/ReactHookFormPluginScreen.tsx b/apps/playground/src/app/screens/ReactHookFormPluginScreen.tsx new file mode 100644 index 00000000..d09df0b3 --- /dev/null +++ b/apps/playground/src/app/screens/ReactHookFormPluginScreen.tsx @@ -0,0 +1,537 @@ +import { useRozeniteRHFPlugin } from '@rozenite/rhf-plugin'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { + Alert, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +type ProfileFormValues = { + firstName: string; + lastName: string; + email: string; + age: string; + bio: string; + newsletter: boolean; + address: { + street: string; + city: string; + zip: string; + }; +}; + +type LoginFormValues = { + username: string; + password: string; +}; + +function ProfileForm() { + const { control, handleSubmit, reset, formState } = useForm({ + defaultValues: { + firstName: '', + lastName: '', + email: '', + age: '', + bio: '', + newsletter: false, + address: { + street: '', + city: '', + zip: '', + }, + }, + mode: 'onChange', + }); + + useRozeniteRHFPlugin({ control, id: 'profile-form' }); + + const onSubmit = (data: ProfileFormValues) => { + Alert.alert('Submitted', JSON.stringify(data, null, 2)); + }; + + return ( + + Profile Form + + ( + + First Name * + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + ( + + Last Name * + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + ( + + Email * + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + ( + + Age + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + ( + + Bio + + + )} + /> + + Address + + ( + + Street * + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + + ( + + City * + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + ( + + ZIP + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + + ( + + Subscribe to newsletter + + + )} + /> + + + reset()} + > + Reset + + + Submit + + + + ); +} + +function LoginForm() { + const { control, handleSubmit, formState } = useForm({ + defaultValues: { username: '', password: '' }, + mode: 'onBlur', + }); + + useRozeniteRHFPlugin({ control, id: 'login-form' }); + + const onSubmit = (data: LoginFormValues) => { + Alert.alert('Login', `Welcome, ${data.username}!`); + }; + + return ( + + Login Form + + ( + + Username * + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + ( + + Password * + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + + + {formState.isSubmitting ? 'Signing in...' : 'Sign In'} + + + + ); +} + +export function ReactHookFormPluginScreen() { + const [activeForm, setActiveForm] = useState<'profile' | 'login'>('profile'); + const insets = useSafeAreaInsets(); + + return ( + + React Hook Form Plugin + + Open the DevTools panel to inspect form state in real time + + + + setActiveForm('profile')} + > + + Profile Form + + + setActiveForm('login')} + > + + Login Form + + + + + {activeForm === 'profile' ? : } + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0a0a0a', + }, + content: { + padding: 20, + }, + screenTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#ffffff', + marginBottom: 8, + }, + screenSubtitle: { + fontSize: 14, + color: '#888', + marginBottom: 24, + }, + tabRow: { + flexDirection: 'row', + marginBottom: 20, + backgroundColor: '#1a1a1a', + borderRadius: 8, + padding: 4, + }, + tab: { + flex: 1, + paddingVertical: 10, + alignItems: 'center', + borderRadius: 6, + }, + tabActive: { + backgroundColor: '#8232FF', + }, + tabText: { + color: '#888', + fontWeight: '500', + fontSize: 14, + }, + tabTextActive: { + color: '#ffffff', + }, + formSection: { + backgroundColor: '#1a1a1a', + borderRadius: 12, + padding: 20, + }, + formTitle: { + fontSize: 18, + fontWeight: '600', + color: '#ffffff', + marginBottom: 16, + }, + fieldContainer: { + marginBottom: 16, + }, + label: { + color: '#ccc', + fontSize: 13, + marginBottom: 6, + fontWeight: '500', + }, + input: { + backgroundColor: '#2a2a2a', + borderWidth: 1, + borderColor: '#333', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + color: '#ffffff', + fontSize: 15, + }, + inputError: { + borderColor: '#ef4444', + }, + textArea: { + minHeight: 80, + textAlignVertical: 'top', + }, + errorText: { + color: '#ef4444', + fontSize: 12, + marginTop: 4, + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + sectionLabel: { + color: '#8232FF', + fontSize: 11, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.8, + marginBottom: 12, + marginTop: 4, + }, + row: { + flexDirection: 'row', + gap: 12, + }, + flex: { + flex: 1, + }, + zipField: { + width: 90, + }, + buttonRow: { + flexDirection: 'row', + gap: 12, + marginTop: 8, + }, + button: { + flex: 1, + paddingVertical: 12, + borderRadius: 8, + alignItems: 'center', + }, + buttonPrimary: { + backgroundColor: '#8232FF', + }, + buttonSecondary: { + backgroundColor: '#2a2a2a', + borderWidth: 1, + borderColor: '#444', + }, + buttonDisabled: { + opacity: 0.5, + }, + buttonPrimaryText: { + color: '#ffffff', + fontWeight: '600', + fontSize: 15, + }, + buttonSecondaryText: { + color: '#ccc', + fontWeight: '500', + fontSize: 15, + }, +}); diff --git a/commitlint.config.js b/commitlint.config.js index 29bc3819..9b2357ed 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -33,6 +33,7 @@ export default { 'agent-sdk', 'file-system-plugin', 'sqlite-plugin', + 'rhf-plugin', '', ], ], diff --git a/commitlint.config.mjs b/commitlint.config.mjs index 29bc3819..9b2357ed 100644 --- a/commitlint.config.mjs +++ b/commitlint.config.mjs @@ -33,6 +33,7 @@ export default { 'agent-sdk', 'file-system-plugin', 'sqlite-plugin', + 'rhf-plugin', '', ], ], diff --git a/packages/rhf-plugin/README.md b/packages/rhf-plugin/README.md new file mode 100644 index 00000000..e4f0999b --- /dev/null +++ b/packages/rhf-plugin/README.md @@ -0,0 +1,67 @@ +![rozenite-banner](https://www.rozenite.dev/rozenite-banner.jpg) + +### A Rozenite plugin for inspecting React Hook Form in React Native DevTools. + +The React Hook Form plugin streams live form snapshots to DevTools: field values (including nested paths), errors, dirty and touched flags, submit and validation state, and inferred field types where available. + +![React Hook Form Plugin](https://rozenite.dev/rhf-plugin.png) + +## Installation + +```bash +npm install @rozenite/rhf-plugin +``` + +Peer dependency: + +```bash +npm install react-hook-form +``` + +`react-hook-form` **^7.33.1** is required alongside `react` and `react-native`. + +## Usage + +Call `useRozeniteRHFPlugin` in any component that has access to your form `control` (typically next to `useForm`). + +```ts +import { useForm } from 'react-hook-form'; +import { useRozeniteRHFPlugin } from '@rozenite/rhf-plugin'; + +type FormValues = { + email: string; + profile: { name: string }; +}; + +function SignUpScreen() { + const { control, handleSubmit } = useForm({ + defaultValues: { email: '', profile: { name: '' } }, + }); + + useRozeniteRHFPlugin({ control }); + + return ( + // ...your fields registered with this control + ); +} +``` + +### Multiple forms + +When more than one form is mounted at once, pass a stable `id` so each instance is listed separately in DevTools: + +```ts +useRozeniteRHFPlugin({ control, id: 'checkout-shipping' }); +``` + +If you omit `id`, the hook uses React’s `useId()` for a dev-only identifier. + +## Web (React Native for Web) + +When you use [Rozenite for Web](https://rozenite.dev/docs/rozenite-for-web) in development, this plugin loads in the browser like on native. It follows the same `control` wiring as in your React Native app, as long as `react-hook-form` is available in the bundle. + +## Notes + +- The hook is **development-only**: in production (`NODE_ENV === 'production'`) and on the server, the exported hook is a no-op. +- Snapshots are deduplicated with deep equality; unchanged form state does not spam the bridge. +- Nested field names (dot paths) are grouped in the DevTools panel for easier scanning. diff --git a/packages/rhf-plugin/package.json b/packages/rhf-plugin/package.json new file mode 100644 index 00000000..099f48bd --- /dev/null +++ b/packages/rhf-plugin/package.json @@ -0,0 +1,64 @@ +{ + "name": "@rozenite/rhf-plugin", + "version": "1.8.1", + "description": "React Hook Form Inspector for Rozenite.", + "type": "module", + "main": "./dist/react-native/index.cjs", + "module": "./dist/react-native/index.js", + "types": "./dist/react-native/index.d.ts", + "homepage": "https://github.com/callstackincubator/rozenite#readme", + "bugs": { + "url": "https://github.com/callstackincubator/rozenite/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/callstackincubator/rozenite.git" + }, + "scripts": { + "build": "rozenite build", + "dev": "rozenite dev", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint .", + "test": "vitest --run --passWithNoTests" + }, + "dependencies": { + "@rozenite/plugin-bridge": "workspace:*", + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@rozenite/vite-plugin": "workspace:*", + "@types/lodash": "^4.17.0", + "@types/react": "catalog:", + "autoprefixer": "^10.4.21", + "lucide-react": "^0.263.1", + "postcss": "^8.5.6", + "react": "catalog:", + "react-dom": "catalog:", + "react-hook-form": "^7.33.1", + "react-native": "catalog:", + "react-native-web": "^0.21.2", + "rozenite": "workspace:*", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "~5.9.3", + "vite": "catalog:" + }, + "peerDependencies": { + "react": "*", + "react-hook-form": "^7.33.1", + "react-native": "*" + }, + "license": "MIT", + "exports": { + ".": { + "types": "./dist/react-native/index.d.ts", + "import": "./dist/react-native/index.js", + "require": "./dist/react-native/index.cjs" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/rhf-plugin/postcss.config.js b/packages/rhf-plugin/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/packages/rhf-plugin/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/rhf-plugin/react-native.ts b/packages/rhf-plugin/react-native.ts new file mode 100644 index 00000000..5bd05b07 --- /dev/null +++ b/packages/rhf-plugin/react-native.ts @@ -0,0 +1,14 @@ +export type { UseRozeniteRHFPluginOptions } from './src/react-native/useRozeniteRHFPlugin'; +export type { FieldError, FormSnapshot } from './src/shared/types'; + +export let useRozeniteRHFPlugin: typeof import('./src/react-native/useRozeniteRHFPlugin').useRozeniteRHFPlugin; + +const isDev = process.env.NODE_ENV !== 'production'; +const isServer = typeof window === 'undefined'; + +if (!isDev || isServer) { + useRozeniteRHFPlugin = () => undefined; +} else { + useRozeniteRHFPlugin = + require('./src/react-native/useRozeniteRHFPlugin').useRozeniteRHFPlugin; +} diff --git a/packages/rhf-plugin/rozenite.config.ts b/packages/rhf-plugin/rozenite.config.ts new file mode 100644 index 00000000..2ec27898 --- /dev/null +++ b/packages/rhf-plugin/rozenite.config.ts @@ -0,0 +1,8 @@ +export default { + panels: [ + { + name: 'React Hook Form', + source: './src/ui/panel.tsx', + }, + ], +}; diff --git a/packages/rhf-plugin/src/css-modules.d.ts b/packages/rhf-plugin/src/css-modules.d.ts new file mode 100644 index 00000000..3d673e2e --- /dev/null +++ b/packages/rhf-plugin/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/packages/rhf-plugin/src/react-native/useRozeniteRHFPlugin.ts b/packages/rhf-plugin/src/react-native/useRozeniteRHFPlugin.ts new file mode 100644 index 00000000..6c2a1dfa --- /dev/null +++ b/packages/rhf-plugin/src/react-native/useRozeniteRHFPlugin.ts @@ -0,0 +1,130 @@ +import get from 'lodash/get'; +import { useEffect, useId, useRef, useState } from 'react'; +import type { Control, FieldValues } from 'react-hook-form'; +import { useFormState, useWatch } from 'react-hook-form'; +import equal from 'fast-deep-equal'; +import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import type { RHFEventMap } from '../shared/messaging'; +import type { FieldError, FormSnapshot } from '../shared/types'; +import { nestToFlat, proxyToObject } from './utils'; + +const PLUGIN_ID = '@rozenite/rhf-plugin'; + +export type UseRozeniteRHFPluginOptions = { + control: Control; + id?: string; +}; + +export const useRozeniteRHFPlugin = ({ + control, + id: providedId, +}: UseRozeniteRHFPluginOptions) => { + const generatedId = useId(); + const id = providedId ?? generatedId; + + const nestedFormValues = useWatch({ control }); + const formState = useFormState({ control }); + + const client = useRozeniteDevToolsClient({ pluginId: PLUGIN_ID }); + + const previousSnapshotRef = useRef(null); + const [, forceUpdate] = useState(0); + + useEffect(() => { + if (!client) { + return; + } + + const { + errors: nestedErrors, + dirtyFields: nestedDirtyFields, + touchedFields: nestedTouchedFields, + ...formStatus + } = proxyToObject(formState as unknown as Record); + + const flatFieldNames = [...(control as unknown as { _names: { mount: Set } })._names.mount]; + + const formValues = nestToFlat(flatFieldNames, nestedFormValues as object, ''); + const dirtyFields = nestToFlat(flatFieldNames, nestedDirtyFields as object, false); + const touchedFields = nestToFlat(flatFieldNames, nestedTouchedFields as object, false); + const flatErrors = nestToFlat(flatFieldNames, nestedErrors as object); + + const errors = Object.entries(flatErrors).reduce( + (prev, [key, value]) => { + prev[key] = { + type: value?.type, + message: value?.message, + }; + return prev; + }, + {} as Record + ); + + const nativeFields = flatFieldNames.reduce( + (prev, name) => { + const field = get( + (control as unknown as { _fields: Record })._fields, + name + ) as { _f?: { ref?: { type?: string } } } | undefined; + // ref.type works for DOM inputs (web/RNW); for native RN components there + // is no type property, so fall back to the JS type of the current value. + const domType = field?._f?.ref?.type; + const value = formValues[name]; + prev[name] = domType ?? (value != null && value !== '' ? typeof value : undefined); + return prev; + }, + {} as Record + ); + + const snapshot: FormSnapshot = { + id, + formValues, + formState: { + errors, + dirtyFields, + touchedFields, + nativeFields, + submitCount: (formStatus.submitCount as number) ?? 0, + isSubmitted: (formStatus.isSubmitted as boolean) ?? false, + isSubmitting: (formStatus.isSubmitting as boolean) ?? false, + isSubmitSuccessful: (formStatus.isSubmitSuccessful as boolean) ?? false, + isValid: (formStatus.isValid as boolean) ?? false, + isValidating: (formStatus.isValidating as boolean) ?? false, + isDirty: (formStatus.isDirty as boolean) ?? false, + }, + }; + + if (equal(previousSnapshotRef.current, snapshot)) { + return; + } + + previousSnapshotRef.current = snapshot; + + client.send('update', { + type: 'update', + snapshot, + }); + }); + + useEffect(() => { + if (!client) { + return; + } + return () => { + client.send('unmount', { type: 'unmount', id }); + }; + }, [client, id]); + + useEffect(() => { + if (!client) { + return; + } + const sub = client.onMessage('init', () => { + previousSnapshotRef.current = null; + forceUpdate((n) => n + 1); + }); + return () => { + sub.remove(); + }; + }, [client]); +}; diff --git a/packages/rhf-plugin/src/react-native/utils.ts b/packages/rhf-plugin/src/react-native/utils.ts new file mode 100644 index 00000000..1af8f1d7 --- /dev/null +++ b/packages/rhf-plugin/src/react-native/utils.ts @@ -0,0 +1,22 @@ +import get from 'lodash/get'; + +export function proxyToObject>(proxy: T): T { + return Reflect.ownKeys(proxy).reduce((prev, key) => { + prev[key as keyof T] = proxy[key as keyof T]; + return prev; + }, {} as T); +} + +export function nestToFlat( + flatKeys: string[], + obj: object, + defaultValue?: V +): Record { + return flatKeys.reduce( + (prev, name) => { + prev[name] = (get(obj, name) || defaultValue) as V; + return prev; + }, + {} as Record + ); +} diff --git a/packages/rhf-plugin/src/shared/messaging.ts b/packages/rhf-plugin/src/shared/messaging.ts new file mode 100644 index 00000000..449a559a --- /dev/null +++ b/packages/rhf-plugin/src/shared/messaging.ts @@ -0,0 +1,21 @@ +import type { FormSnapshot } from './types'; + +export type RHFInitEvent = { + type: 'init'; +}; + +export type RHFUpdateEvent = { + type: 'update'; + snapshot: FormSnapshot; +}; + +export type RHFUnmountEvent = { + type: 'unmount'; + id: string; +}; + +export type RHFEventMap = { + init: RHFInitEvent; + update: RHFUpdateEvent; + unmount: RHFUnmountEvent; +}; diff --git a/packages/rhf-plugin/src/shared/types.ts b/packages/rhf-plugin/src/shared/types.ts new file mode 100644 index 00000000..22bf9215 --- /dev/null +++ b/packages/rhf-plugin/src/shared/types.ts @@ -0,0 +1,22 @@ +export type FieldError = { + type?: string; + message?: string; +}; + +export type FormSnapshot = { + id: string; + formValues: Record; + formState: { + errors: Record; + dirtyFields: Record; + touchedFields: Record; + nativeFields: Record; + submitCount: number; + isSubmitted: boolean; + isSubmitting: boolean; + isSubmitSuccessful: boolean; + isValid: boolean; + isValidating: boolean; + isDirty: boolean; + }; +}; diff --git a/packages/rhf-plugin/src/ui/globals.css b/packages/rhf-plugin/src/ui/globals.css new file mode 100644 index 00000000..4e8c91c6 --- /dev/null +++ b/packages/rhf-plugin/src/ui/globals.css @@ -0,0 +1,82 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #1f2937; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #374151; + border-radius: 4px; + border: 1px solid #1f2937; +} + +::-webkit-scrollbar-thumb:hover { + background: #4b5563; +} + +::-webkit-scrollbar-corner { + background: #1f2937; +} diff --git a/packages/rhf-plugin/src/ui/panel.tsx b/packages/rhf-plugin/src/ui/panel.tsx new file mode 100644 index 00000000..042bc1f5 --- /dev/null +++ b/packages/rhf-plugin/src/ui/panel.tsx @@ -0,0 +1,394 @@ +import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import { useEffect, useMemo, useState } from 'react'; +import { Search, ChevronDown, ChevronRight, AlertTriangle } from 'lucide-react'; +import type { RHFEventMap, RHFInitEvent, RHFUnmountEvent, RHFUpdateEvent } from '../shared/messaging'; +import type { FieldError, FormSnapshot } from '../shared/types'; +import './globals.css'; + +const PLUGIN_ID = '@rozenite/rhf-plugin'; + +// React's useId() generates ids like ":r0:", ":r1a:", etc. +const REACT_AUTO_ID = /^:r[0-9a-z]+:$/; + +// --- Field grouping --- + +type RootField = { kind: 'field'; name: string }; +type FieldGroup = { kind: 'group'; prefix: string; fields: string[] }; +type FieldEntry = RootField | FieldGroup; + +function groupFields(names: string[]): FieldEntry[] { + const groupMap = new Map(); + const result: FieldEntry[] = []; + + for (const name of names) { + const dot = name.indexOf('.'); + if (dot === -1) { + result.push({ kind: 'field', name }); + } else { + const prefix = name.slice(0, dot); + if (!groupMap.has(prefix)) groupMap.set(prefix, []); + groupMap.get(prefix)!.push(name); + } + } + + for (const [prefix, fields] of groupMap) { + result.push({ kind: 'group', prefix, fields }); + } + + return result; +} + +// --- Small components --- + +function Badge({ label, active, color }: { label: string; active: boolean; color: string }) { + if (!active) return null; + return ( + {label} + ); +} + +function ErrorCell({ error }: { error?: FieldError }) { + if (!error?.type && !error?.message) return null; + return ( +
+ {error.type && ( + + {error.type} + + )} + {error.message && ( + + {error.message} + + )} +
+ ); +} + +function formatValue(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return ''; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +// --- Form state bar --- + +function FormStateBar({ formState }: { formState: FormSnapshot['formState'] }) { + const badges = [ + { label: 'valid', active: formState.isValid, color: 'bg-green-800 text-green-200' }, + { label: 'invalid', active: !formState.isValid, color: 'bg-red-900 text-red-200' }, + { label: 'dirty', active: formState.isDirty, color: 'bg-yellow-800 text-yellow-200' }, + { label: 'submitting', active: formState.isSubmitting, color: 'bg-blue-800 text-blue-200' }, + { label: 'submitted', active: formState.isSubmitted, color: 'bg-purple-800 text-purple-200' }, + { label: 'submitSuccessful', active: formState.isSubmitSuccessful, color: 'bg-green-900 text-green-200' }, + { label: 'validating', active: formState.isValidating, color: 'bg-orange-800 text-orange-200' }, + ]; + + return ( +
+ {badges.map((b) => ( + + ))} + submits: {formState.submitCount} +
+ ); +} + +// --- Table rows --- + +const COL_FIELD = 'px-3 py-1.5 text-gray-200 font-mono text-xs align-middle'; +const COL_TYPE = 'px-3 py-1.5 text-gray-400 font-mono text-xs w-20 align-middle'; +const COL_VALUE = 'px-3 py-1.5 text-gray-300 font-mono text-xs align-middle max-w-[220px]'; +const COL_STATE = 'px-3 py-1.5 w-28 align-middle'; +const COL_ERROR = 'px-3 py-1.5 align-middle'; + +function FieldRow({ + name, + snapshot, + indent = false, +}: { + name: string; + snapshot: FormSnapshot; + indent?: boolean; +}) { + const value = snapshot.formValues[name]; + const error = snapshot.formState.errors[name]; + const dirty = snapshot.formState.dirtyFields[name]; + const touched = snapshot.formState.touchedFields[name]; + const type = snapshot.formState.nativeFields[name]; + + return ( + + + + {indent ? name.slice(name.indexOf('.') + 1) : name} + + + {type ?? } + + {formatValue(value)} + + + + + + + + + + + + ); +} + +function GroupSection({ + group, + snapshot, + defaultOpen, +}: { + group: FieldGroup; + snapshot: FormSnapshot; + defaultOpen: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + + const hasError = group.fields.some((f) => snapshot.formState.errors[f]?.type); + const isDirty = group.fields.some((f) => snapshot.formState.dirtyFields[f]); + + return ( + + setOpen((o) => !o)} + > + +
+ {open ? ( + + ) : ( + + )} + {group.prefix} + {group.fields.length} fields + {isDirty && } + {hasError && } +
+ + + {open && + group.fields.map((name) => ( + + ))} + + ); +} + +// --- Field table --- + +function FieldTable({ snapshot, searchTerm }: { snapshot: FormSnapshot; searchTerm: string }) { + const allNames = Object.keys(snapshot.formValues); + + const filtered = useMemo(() => { + if (!searchTerm) return allNames; + const lower = searchTerm.toLowerCase(); + return allNames.filter((n) => n.toLowerCase().includes(lower)); + }, [allNames, searchTerm]); + + const entries = useMemo(() => groupFields(filtered), [filtered]); + + if (filtered.length === 0) { + return ( +
+

No fields found

+

+ {searchTerm ? 'Try adjusting your search' : 'No registered fields'} +

+
+ ); + } + + return ( + + + + + + + + + + + {entries.map((entry) => + entry.kind === 'field' ? ( + + + + ) : ( + f.toLowerCase().includes(searchTerm.toLowerCase()))} + /> + ) + )} +
FieldTypeValueStateError
+ ); +} + +// --- Form selector --- + +function FormSelector({ + options, + selectedId, + staleIds, + onSelect, +}: { + options: { id: string; label: string; fieldCount: number }[]; + selectedId: string | null; + staleIds: Set; + onSelect: (id: string) => void; +}) { + const selectedIsStale = selectedId ? staleIds.has(selectedId) : false; + + return ( +
+ + + {selectedIsStale && ( + + disconnected + + )} +
+ ); +} + +// --- Panel --- + +export default function ReactHookFormPanel() { + const [snapshots, setSnapshots] = useState>(new Map()); + const [staleIds, setStaleIds] = useState>(new Set()); + const [selectedFormId, setSelectedFormId] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + const client = useRozeniteDevToolsClient({ pluginId: PLUGIN_ID }); + + useEffect(() => { + if (!client) return; + + client.send('init', { type: 'init' } satisfies RHFInitEvent); + + const updateSub = client.onMessage('update', (event: RHFUpdateEvent) => { + const { snapshot } = event; + setSnapshots((prev) => { + const next = new Map(prev); + next.set(snapshot.id, snapshot); + return next; + }); + setStaleIds((prev) => { + if (!prev.has(snapshot.id)) return prev; + const next = new Set(prev); + next.delete(snapshot.id); + return next; + }); + setSelectedFormId((prev) => prev ?? snapshot.id); + }); + + const unmountSub = client.onMessage('unmount', (event: RHFUnmountEvent) => { + setStaleIds((prev) => { + const next = new Set(prev); + next.add(event.id); + return next; + }); + }); + + return () => { + updateSub.remove(); + unmountSub.remove(); + }; + }, [client]); + + const selectedSnapshot = selectedFormId ? snapshots.get(selectedFormId) ?? null : null; + + const formOptions = useMemo( + () => + [...snapshots.keys()].map((id, index) => ({ + id, + label: REACT_AUTO_ID.test(id) ? `Form ${index + 1}` : id, + fieldCount: Object.keys(snapshots.get(id)!.formValues).length, + })), + [snapshots] + ); + + return ( +
+ {/* Header */} +
+ React Hook Form +
+ {formOptions.length > 0 && ( + + )} +
+ + {/* Form state bar */} + {selectedSnapshot && ( +
+ +
+ )} + + {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + className="h-8 w-full pl-8 pr-3 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + {/* Content */} +
+ {selectedSnapshot ? ( + + ) : ( +
+

React Hook Form Inspector

+

+ Call{' '} + useRozeniteRHFPlugin({'{ control }'}){' '} + in your form component +

+
+ )} +
+
+ ); +} diff --git a/packages/rhf-plugin/tailwind.config.ts b/packages/rhf-plugin/tailwind.config.ts new file mode 100644 index 00000000..d0cdccb0 --- /dev/null +++ b/packages/rhf-plugin/tailwind.config.ts @@ -0,0 +1,52 @@ +import type { Config } from 'tailwindcss'; + +const config: Config = { + darkMode: ['class'], + content: ['./src/ui/**/*.{js,ts,jsx,tsx,mdx}'], + theme: { + extend: { + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [require('tailwindcss-animate')], +}; +export default config; diff --git a/packages/rhf-plugin/tsconfig.json b/packages/rhf-plugin/tsconfig.json new file mode 100644 index 00000000..9bf92648 --- /dev/null +++ b/packages/rhf-plugin/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "ESNext", + "moduleResolution": "bundler", + "paths": { + "@rozenite/plugin-bridge": ["../plugin-bridge/src/index.ts"] + }, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*", "react-native.ts", "rozenite.config.ts"], + "exclude": ["node_modules", "dist", "build"], + "references": [ + { + "path": "../plugin-bridge" + }, + { + "path": "../cli" + }, + { + "path": "../vite-plugin" + } + ] +} diff --git a/packages/rhf-plugin/vite.config.ts b/packages/rhf-plugin/vite.config.ts new file mode 100644 index 00000000..2e7ae6d8 --- /dev/null +++ b/packages/rhf-plugin/vite.config.ts @@ -0,0 +1,23 @@ +/// +import { defineConfig } from 'vite'; +import { rozenitePlugin } from '@rozenite/vite-plugin'; + +export default defineConfig({ + root: __dirname, + plugins: [rozenitePlugin()], + test: { + passWithNoTests: true, + }, + base: './', + build: { + outDir: './dist', + emptyOutDir: false, + reportCompressedSize: false, + minify: true, + sourcemap: false, + }, + server: { + port: 3000, + open: true, + }, +}); diff --git a/plugin-directory.json b/plugin-directory.json index fb150065..99e4c971 100644 --- a/plugin-directory.json +++ b/plugin-directory.json @@ -47,6 +47,10 @@ "npmUrl": "https://www.npmjs.com/package/@rozenite/file-system-plugin", "githubUrl": "https://github.com/callstackincubator/rozenite/tree/main/packages/file-system-plugin" }, + { + "npmUrl": "https://www.npmjs.com/package/@rozenite/rhf-plugin", + "githubUrl": "https://github.com/callstackincubator/rozenite/tree/main/packages/rhf-plugin" + }, { "npmUrl": "https://www.npmjs.com/package/@rozenite/require-profiler-plugin", "githubUrl": "https://github.com/callstackincubator/rozenite/tree/main/packages/require-profiler-plugin" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8575071..921a603a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: '@rozenite/require-profiler-plugin': specifier: workspace:* version: link:../../packages/require-profiler-plugin + '@rozenite/rhf-plugin': + specifier: workspace:* + version: link:../../packages/rhf-plugin '@rozenite/sqlite-plugin': specifier: workspace:* version: link:../../packages/sqlite-plugin @@ -240,6 +243,9 @@ importers: react-dom: specifier: 'catalog:' version: 19.2.0(react@19.2.0) + react-hook-form: + specifier: ^7.54.2 + version: 7.75.0(react@19.2.0) react-native: specifier: 'catalog:' version: 0.83.1(@babel/core@7.29.0)(@react-native/metro-config@0.76.9)(@types/react@19.2.14)(react@19.2.0) @@ -1239,6 +1245,67 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@22.17.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1) + packages/rhf-plugin: + dependencies: + '@rozenite/plugin-bridge': + specifier: workspace:* + version: link:../plugin-bridge + fast-deep-equal: + specifier: ^3.1.3 + version: 3.1.3 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + devDependencies: + '@rozenite/vite-plugin': + specifier: workspace:* + version: link:../vite-plugin + '@types/lodash': + specifier: ^4.17.0 + version: 4.17.20 + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + autoprefixer: + specifier: ^10.4.21 + version: 10.4.21(postcss@8.5.6) + lucide-react: + specifier: ^0.263.1 + version: 0.263.1(react@19.2.0) + postcss: + specifier: ^8.5.6 + version: 8.5.6 + react: + specifier: 'catalog:' + version: 19.2.0 + react-dom: + specifier: 'catalog:' + version: 19.2.0(react@19.2.0) + react-hook-form: + specifier: ^7.33.1 + version: 7.75.0(react@19.2.0) + react-native: + specifier: 'catalog:' + version: 0.83.1(@babel/core@7.29.0)(@react-native/metro-config@0.76.9)(@types/react@19.2.14)(react@19.2.0) + react-native-web: + specifier: ^0.21.2 + version: 0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + rozenite: + specifier: workspace:* + version: link:../cli + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.17) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@22.17.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1) + packages/runtime: dependencies: tslib: @@ -5615,6 +5682,7 @@ packages: '@testing-library/react-native@12.9.0': resolution: {integrity: sha512-wIn/lB1FjV2N4Q7i9PWVRck3Ehwq5pkhAef5X5/bmQ78J/NoOsGbVY2/DG5Y9Lxw+RfE+GvSEh/fe5Tz6sKSvw==} + deprecated: React Native Testing Library v12 is no longer maintained. Please upgrade to v13 or v14. peerDependencies: jest: '>=28.0.0' react: '>=16.8.0' @@ -6037,6 +6105,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unhead/react@2.1.4': resolution: {integrity: sha512-3DzMi5nJkUyLVfQF/q78smCvcSy84TTYgTwXVz5s3AjUcLyHro5Z7bLWriwk1dn5+YRfEsec8aPkLCMi5VjMZg==} @@ -11092,6 +11161,12 @@ packages: peerDependencies: react: '>=17.0.0' + react-hook-form@7.75.0: + resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-icons@5.5.0: resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} peerDependencies: @@ -26540,6 +26615,10 @@ snapshots: dependencies: react: 19.2.0 + react-hook-form@7.75.0(react@19.2.0): + dependencies: + react: 19.2.0 + react-icons@5.5.0(react@19.2.0): dependencies: react: 19.2.0 diff --git a/website/src/docs/official-plugins/_meta.json b/website/src/docs/official-plugins/_meta.json index b7b9f621..508b7ab2 100644 --- a/website/src/docs/official-plugins/_meta.json +++ b/website/src/docs/official-plugins/_meta.json @@ -59,6 +59,11 @@ "name": "file-system", "label": "File System" }, + { + "type": "file", + "name": "react-hook-form", + "label": "React Hook Form" + }, { "type": "file", "name": "controls", diff --git a/website/src/docs/official-plugins/react-hook-form.mdx b/website/src/docs/official-plugins/react-hook-form.mdx new file mode 100644 index 00000000..aa540953 --- /dev/null +++ b/website/src/docs/official-plugins/react-hook-form.mdx @@ -0,0 +1,65 @@ +import { PackageManagerTabs } from '@rspress/core/theme'; + +# React Hook Form Plugin + +![](/rhf-plugin.png) + +The React Hook Form plugin lets you inspect your forms in real time from React Native DevTools. For every mounted form you can see field values, validation errors, dirty and touched states, native input types, and overall form status — without adding any logging or breakpoints. + +## Installation + +Make sure to go through the [Getting Started guide](/docs/getting-started) before installing the plugin. + + + +## Setup + +Call `useRozeniteRHFPlugin` inside any component that has access to a `react-hook-form` `control` object: + +```typescript title="MyForm.tsx" +import { useForm } from 'react-hook-form'; +import { useRozeniteRHFPlugin } from '@rozenite/rhf-plugin'; + +function MyForm() { + const { control, handleSubmit } = useForm(); + useRozeniteRHFPlugin({ control }); + + return ( + // your form JSX + ); +} +``` + +That's all. The panel appears automatically in React Native DevTools under **React Hook Form**. + +## Multiple Forms + +Each `useRozeniteRHFPlugin` call registers an independent entry in the panel. By default the panel uses React's internal ID to label each form. Pass an explicit `id` to make the label readable: + +```typescript +useRozeniteRHFPlugin({ control, id: 'checkout-form' }); +``` + +When a form unmounts it stays visible in the panel marked as **disconnected**, so you can still inspect its last snapshot. + +## What the Panel Shows + +| Column | Description | +|--------|-------------| +| **Field** | Registered field name. Nested objects are grouped into collapsible sections. | +| **Type** | Native input type (`text`, `email`, …) when available. | +| **Value** | Current field value. | +| **State** | `dirty` and `touched` badges. | +| **Error** | Validation error type and message. | + +The header bar shows global form status: `valid`, `invalid`, `dirty`, `submitting`, `submitted`, `submitSuccessful`, `validating`, and the submit count. + +Use the search box to filter fields by name. + +## Peer Dependencies + +This plugin requires `react-hook-form` v7: + +```json +"react-hook-form": "^7.33.1" +``` diff --git a/website/src/public/rhf-plugin.png b/website/src/public/rhf-plugin.png new file mode 100644 index 00000000..ccabc83f Binary files /dev/null and b/website/src/public/rhf-plugin.png differ