[DS-2] Install and integrate React Hook Form #7

Merged
chris merged 11 commits from feature/DS-2 into main 2026-09-02 21:21:06 -06:00
Owner

Ticket

DS-2 Install and integrate React Hook Form

Summary

  • Installs react-hook-form + @hookform/resolvers as regular dependencies (not peerDependencies), guaranteeing a single RHF instance across consumers.
  • Adds src/form.ts re-exporting the RHF consumer surface (useForm, FormProvider, useFieldArray, useWatch, zodResolver, plus SubmitHandler/UseFormReturn/Path/RegisterOptions types) — consuming apps never install react-hook-form directly.
  • Builds Text as the reference field component (Text.tsx web / Text.native.tsx native), wired directly to RHF via useController/useFormContext — single-layer component, no separate Form* wrapper, rules prop as an escape hatch alongside schema-first Zod validation.
  • Ships a real dual-target build (dist/ web, dist/native/ native, each with its own entry point) plus a react-native exports condition, so Metro-based consumers get the RN build and web bundlers get the DOM build.
  • Adds component-test tooling (@testing-library/react, @testing-library/react-native, a happy-dom preload, a react-native mock for bun test since there's no Metro) and shared test utilities (RenderWithForm, renderNative) for this and future field-component tickets.
  • CI now also runs bun run build, gating the dual-target build itself.
## Ticket [DS-2](http://192.168.2.100:7123/home/browse/DS-2/) Install and integrate React Hook Form ## Summary - Installs `react-hook-form` + `@hookform/resolvers` as regular dependencies (not peerDependencies), guaranteeing a single RHF instance across consumers. - Adds `src/form.ts` re-exporting the RHF consumer surface (`useForm`, `FormProvider`, `useFieldArray`, `useWatch`, `zodResolver`, plus `SubmitHandler`/`UseFormReturn`/`Path`/`RegisterOptions` types) — consuming apps never install `react-hook-form` directly. - Builds `Text` as the reference field component (`Text.tsx` web / `Text.native.tsx` native), wired directly to RHF via `useController`/`useFormContext` — single-layer component, no separate `Form*` wrapper, `rules` prop as an escape hatch alongside schema-first Zod validation. - Ships a real dual-target build (`dist/` web, `dist/native/` native, each with its own entry point) plus a `react-native` `exports` condition, so Metro-based consumers get the RN build and web bundlers get the DOM build. - Adds component-test tooling (`@testing-library/react`, `@testing-library/react-native`, a `happy-dom` preload, a `react-native` mock for `bun test` since there's no Metro) and shared test utilities (`RenderWithForm`, `renderNative`) for this and future field-component tickets. - CI now also runs `bun run build`, gating the dual-target build itself.
Also fixes react-native as a peerDependency (was dev-only), required so
tsup/esbuild externalize it from the dist/native build instead of trying
to bundle its Flow-syntax source (step 4's build wiring).

Native component tests mock 'react-native' (bun:test has no Metro, so the
real package's Flow syntax and native-module bindings can't load) with
plain string host tags plus a working StyleSheet.flatten -- the minimal
surface both Text.native.tsx and @testing-library/react-native's own
internals need; RNTL's host-component checks match on the string element
type, not a real component reference.
HIGH: dist/native/index.d.ts was generated from the web Text.tsx, not
Text.native.tsx -- tsup's dts resolver ignores esbuildOptions.resolveExtensions.
Fixed at the root by giving the native build its own real entry point
(src/index.native.ts, importing Text.native directly) instead of the shared
web entry plus a resolveExtensions override.

MEDIUM: scoped the web build's clean glob to spare dist/native/** (tsup runs
array configs concurrently, so an unscoped clean could delete the other
build's output); widened the react-native peerDependency floor to >=0.78
(covers both real consumers, budget-tracker/app 0.79.6 and irrigo/app 0.85.3,
and matches @testing-library/react-native's own floor) and marked it optional;
decoupled the native build's react-native externalization from that peerDep
via explicit `external: ["react-native"]`; added `bun run build` to CI.

Test coverage: added tests (both platforms) for the `rules` prop's non-Zod
validation path, onBlur/touched wiring, the absent-error-element branch, and
the field.value undefined guard. Tightened src/form.test.ts to assert the
exact re-export key list, not just typeof-function per key.

zod moved to devDependencies (nothing in shipped src/ imports it -- only
tests do); removed the unused react-test-renderer devDependency (RNTL v14
uses the separate `test-renderer` package). Moved react-native-mock.ts and
happydom.ts into src/test-utils/ so they get typecheck coverage, and added a
renderNative() helper encoding the RNTL global-`screen` workaround so future
native field-component tests don't have to rediscover it.

LOW: extracted the duplicated TextProps to src/Text.types.ts and narrowed
`rules` to what useController actually honors; re-exported RegisterOptions;
added aria-invalid/aria-describedby (web) and accessibilityLabel (native);
switched the web input's id to useId()+name to avoid collisions.
[DS-2] Address scoped re-review residuals.
All checks were successful
ci / check (pull_request) Successful in 21s
8930ae05f9
1. The web field.value ?? "" undefined-guard test asserted on the input's
   `.value` DOM property, which reads back "" for an uncontrolled input too
   (i.e. even with the guard removed) -- so it never actually exercised the
   guard. Assert on the attribute instead (`getAttribute("value")`), which
   is `null` for an uncontrolled input and only "" once React actually sets
   a controlled `value` prop. Verified by temporarily removing the guard and
   confirming the test now fails, then restoring it.

2. tsup's dts output has its own separate clean step (cleanDtsFiles) that
   always wipes "**/*.d.{ts,mts,cts}" under a config's own outDir and never
   sees the other config's exclude pattern -- so the prior `clean:
   ["!native/**"]` fix only ever protected the JS output, not the .d.ts
   files, from the concurrent-build race. Set `clean: false` on both tsup
   configs and clean once up front instead, via `rm -rf dist` prefixed onto
   `package.json`'s `build` and `prepare` scripts (prepare gets the same
   fix as build since it runs the identical tsup config and faces the same
   race).

Re-verified after both fixes: full `rm -rf dist && bun run build` still
produces a dist/index.d.ts and dist/native/index.d.ts that differ correctly
(native's Text carries Text.native.tsx's own docstring), all four dist/ and
dist/native/ files are present, and bun test/typecheck/lint/build are green.
[DS-2] Restructure Text into a component folder (index.tsx/index.test.tsx).
All checks were successful
ci / check (pull_request) Successful in 16s
4460dc009b
Move src/Text.tsx, Text.native.tsx, Text.test.tsx, Text.native.test.tsx, and
Text.types.ts into src/Text/ as index.tsx, index.native.tsx, index.test.tsx,
index.native.test.tsx, and types.ts. This is the file layout future
field-component tickets should follow, so it's documented in CLAUDE.md.

Update every import that pointed at the old flat filenames (barrels, tests,
and comments); no behavioral change. Re-verified after the move: typecheck,
lint, and the full test suite are clean, and a clean rebuild still produces
correctly platform-specific dist/index.d.ts vs dist/native/index.d.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The exports["."].react-native condition (already listed first) is the
primary mechanism, but it only applies when a consumer's bundler actually
requests that condition — Metro does this by default on Expo SDK 53+, but an
older or customized Metro config could silently fall through to "main" (the
web build), shipping a DOM <input> into a native app with no warning.

A root-level "react-native" field is checked by non-exports-aware resolvers
before "main", giving a second, independent line of defense. Documented the
whole resolution mechanism (single import path, condition-based, not a
consumer choice) in README's "Consuming this package" section, and updated
the stale "Form field components land in later tickets" status line now
that Text has landed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
[DS-2] Switch to single quotes for JS/TS string literals.
All checks were successful
ci / check (pull_request) Successful in 15s
plane-sync / sync (pull_request) Successful in 1s
850a5aa086
Added @/quotes (typescript-eslint's quotes rule, single + avoidEscape) and
jsx-quotes (prefer-double, so JSX attributes keep the HTML-convention double
quotes even though other strings are now single) to eslint.config.cjs, then
ran `eslint . --fix` to reformat every existing source file project-wide.
Documented the convention in CLAUDE.md's new "Code style" section.

No behavioral change: lint, typecheck, the full test suite, and a clean
rebuild (including the platform-specific dist/index.d.ts vs
dist/native/index.d.ts check) all still pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chris merged commit a710056865 into main 2026-09-02 21:21:06 -06:00
chris deleted branch feature/DS-2 2026-09-02 21:21:06 -06:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
chris/design-system!7
No description provided.