Skip to content

feat(forms-rjsf): accept @rjsf 6.x and make the theme injectable - #58

Merged
thorwhalen merged 2 commits into
mainfrom
rjsf-6-peer-range
Aug 26, 2026
Merged

thorwhalen merged 2 commits into
mainfrom
rjsf-6-peer-range

Conversation

@thorwhalen

@thorwhalen thorwhalen commented Aug 26, 2026 •

Copy link
Copy Markdown
Member

Closes #57.

The problem

@rjsf/shadcn is published on the 6.x line only (6.8.0, peering @rjsf/core@^6.8.0). acture-forms-rjsf@1.0.0 peered @rjsf/core@^5.20.0. The two could not be installed together — an npm peer conflict at install time, i.e. before anyone wrote a line of form code. The adapter was unusable by exactly the consumer it was written for.

There was a second half to that, not named in the issue: even with the range fixed, there was no way to hand the theme to the component. rjsf-form.tsx imported Form from @rjsf/core and rendered it directly. The README's "Theming" section told readers to "pass your own Form from the themed package" and no prop accepted one.

What changed

1. Peer range → ^5.20.0 || ^6.0.0 for @rjsf/core, @rjsf/utils, @rjsf/validator-ajv8. Widened, not moved to 6-only — see Why dual below.

2. <RjsfForm /> takes an optional form prop — ComponentType<FormProps>, which is the type every RJSF theme's default export already has — defaulting to @rjsf/core's unstyled Form. Also exports the RjsfFormComponent type.

import ShadcnForm from '@rjsf/shadcn';
<RjsfForm form={ShadcnForm} command={cmd} onSubmit={run} onCancel={close} />;

This is hard-don't #8's prescribed fix ("Adapter packages expose unstyled components OR a config slot"), not a violation of it: @rjsf/shadcn is a devDependency for the smoke test only — never a runtime dependency, never a peer. No UI kit is bundled. That claim now has a test (below); it did not before.

3. Both halves of the declared peer range run in CI — a new rjsf5 job. See The 5.x matrix below.

4. Docs updated in the same pass — package README (install + a rewritten Theming section with the palette-adapter binding and the form-stability note), the index.ts module docstring, the npm description, and the acture-palette-design skill's Form adapters section.

Adapter code needed no change to span the two majors

Checked at source, from the published .d.ts of @rjsf/core@5.24.13 and @rjsf/core@6.8.0. Everything this adapter touches is shape-identical:

5.24.13 6.8.0
@rjsf/core default export Form Form
FormProps exported yes yes
@rjsf/validator-ajv8 default export validator instance validator instance
schema / validator / formData / children same same
onSubmit (data: IChangeEvent, event) => void identical
liveValidate boolean 'onChange' | 'onBlur' | boolean

The only signature that moved widened, so the false we pass is still valid. @rjsf/utils is a declared peer but is never imported by this package's source.

Why dual rather than 6-only

The 5.x matrix (review repair — the first draft of this PR got this wrong)

The first draft shipped a hand-run-once 5.x check plus an argument that CI could not do better. The argument was overstated and the gap it left was real. Both are now fixed.

The gap, demonstrated. On the previous commit, change rjsf-form.tsx's liveValidate={false} to liveValidate="onChange" — a legal, documented 6.x value (the widening in the table above). pnpm typecheck exits 0, all tests pass, CI is fully green, and the package publishes with "@rjsf/core": "^5.20.0 || ^6.0.0". The same source against real @rjsf/core@5.24.13 declarations:

src/rjsf-form.tsx(78,9): error TS2769: No overload matches this call.
    Type 'string' is not assignable to type 'boolean | undefined'.

A consumer who installs acture-forms-rjsf alongside @rjsf/core@5 — which the peer range invites — gets a hard compile error, and nothing in this repo could have caught it. Acceptance criterion 2 of #57 was unmet.

Where the impossibility argument went wrong. The premise is true: pnpm matches peer dependencies by package name, so an npm:-aliased 5.x @rjsf/core silently binds the 6.x @rjsf/utils, and packageExtensions is not honoured by pnpm 11.1.1 from either pnpm-workspace.yaml or package.json#pnpm. But that rules out two alias trees inside one install. A CI matrix is two installs, and the second one needs no aliases at all.

What was built. scripts/pin-rjsf-5x.mjs rewrites the three @rjsf devDependencies to the 5.x clause read off the declared peer range, drops the 6.x-only @rjsf/shadcn and its smoke test, and then a plain pnpm install --no-frozen-lockfile resolves a plain 5.x tree. The rjsf5 CI job runs it, then --verify (a job that silently tests the wrong major is this same bug again), then builds acture, typechecks and tests. Verified end to end in a throwaway worktree:

pin-rjsf-5x: pinned @rjsf/core, @rjsf/utils, @rjsf/validator-ajv8 to "^5.20.0", dropped @rjsf/shadcn and its smoke test.
pin-rjsf-5x --verify: @rjsf/core@5.24.13 — a real 5.x tree.
$ tsc --noEmit
 ✓ src/rjsf-form.test.tsx (4 tests) 72ms
      Tests  4 passed (4)

…and red on the mutation that motivated it:

$ (liveValidate="onChange")  →  src/rjsf-form.tsx(87,9): error TS2769 … Exit status 2

The script reads the 5.x clause rather than hardcoding it, so narrowing the published range to ^6.0.0 makes the job no-op instead of fail — the matrix retires together with the promise it exists to check (verified: both pin and --verify exit 0 with "no 5.x clause. Nothing to check — skipping.").

The one thing that genuinely stays 6.x-only is the @rjsf/shadcn smoke test, because the theme has no 5.x release. That is why it lives in its own file.

Guards and their mutation tests

Every number below re-measured on the current branch state (17 tests in packages/forms-rjsf).

Mutation Result
Narrow peerDependencies to ^6.0.0 3 failed | 14 passed
Declared range drifts off the installed major (^5.20.0 || ^7.0.0) 3 failed | 14 passed
Adapter ignores the injected form (form ?? Form → Form) 2 failed | 15 passed
@rjsf/shadcn promoted to dependencies 2 failed | 15 passed
A workspace package declares a tailwindcss peer 1 failed | 16 passed
ignoreMissing: [tailwindcss] deleted from pnpm-workspace.yaml 1 failed | 15 passed | 1 skipped
(restored) 17 passed

Three of those guards are new in the repair commit, and each closes a claim this PR made with nothing behind it:

  • Hard-don't feat: Phase 4 (v1.0) + v1.1 — tier system, CLI, devtools, snapshot, tier warnings #8 had no test. Moving "@rjsf/shadcn" from devDependencies to dependencies — the obvious "make the theme just work for consumers" edit — pulled radix + lucide-react + tailwind-merge + tailwindcss-animate into every consumer's install while all 11 tests stayed green. Now asserted absent from dependencies and peerDependencies, plus the stronger form the sibling adapters already satisfy: this package declares no runtime dependencies at all.
  • The tailwindcss suppression had no compensating check. peerDependencyRules.ignoreMissing: [tailwindcss] is workspace-wide and permanent, and pnpm honours no scoped form (tailwindcss-animate>tailwindcss and @rjsf/shadcn>tailwindcss were both tried; both still report the unmet peer). So the day a workspace package genuinely peers on tailwindcss, its missing peer would go unreported. The check asserts no workspace package declares that peer while the rule is in place — and asserts the rule is still there, so the block retires with it.
  • One of the two theme tests did not guard the theme. "submits through the theme" passed on an adapter that ignored form entirely, because @rjsf/core's own form submits identically. Both tests now assert theme identity through one expectThemedInput helper, keyed on @rjsf/core's form-control class (a discriminator that survives Tailwind class churn) rather than on rounded-md alone. The form ?? Form → Form mutation went from 1 failed to 2 failed.

The form prop must be referentially stable

FormImpl is recomputed on every render, so an inline theme — form={withTheme(myTheme)} in JSX, or an un-hoisted adapter arrow — is a new component type each render. React reconciles by element type, so the form unmounts, remounts, and whatever the user had typed is silently discarded and reset to defaults.

Memoizing cannot fix this, which is worth stating because it is the obvious remedy: the changed identity arrives as the prop, so useMemo(() => form ?? Form, [form]) invalidates on exactly the renders that matter. The requirement is documented instead — on the prop's JSDoc, in the README's Theming section (both snippets there are safe precisely because they bind at module level), and in the acture-palette-design skill.

Test delta

before this PR first draft now
acture-forms-rjsf 3 11 17
workspace 587 / 21 packages 595 / 21 packages 601 / 21 packages

pnpm build → 0, pnpm typecheck → 0, pnpm test → 0 (exit code checked, not inferred). pnpm install --frozen-lockfile clean. pnpm peers check → No peer dependency issues found.

packages/forms-rjsf test:  Test Files  2 passed (2)
packages/forms-rjsf test:       Tests  17 passed (17)
TOTAL: 601 tests passed across 21 packages, 0 failed

One incidental

pnpm peers check was clean before this PR and is clean after. Keeping it that way needed peerDependencyRules.ignoreMissing: [tailwindcss] in pnpm-workspace.yaml: @rjsf/shadcn pulls tailwindcss-animate@1.0.7, whose peer range is the malformed ">=3.0.0 || insiders". insiders is not a semver range, so pnpm treats the whole range as unsatisfiable and reports the peer unmet no matter which tailwindcss is installed — verified by installing tailwindcss@4.3.3, which did not silence it. Nothing here compiles CSS. Commented in place, and now covered by the compensating test above.

Deliberately not done

minor changeset: .changeset/rjsf-6-peer-range-and-theme-prop.md.

https://claude.ai/code/session_0191cTtmnhFbC2ZKNtgxHXye

`@rjsf/shadcn` — the theme every frontend in this ecosystem wants — is
published on the 6.x line only, peering `@rjsf/core@^6`. The adapter's
peer range was `^5.20.0`, so the two could not be installed together:
an npm peer conflict at install time, before a line of form code got
written. The adapter was unusable by exactly the consumer it was
written for.

Peers move to `^5.20.0 || ^6.0.0` rather than to 6-only. No adapter code
was needed to span the two majors, because every API it touches is
shape-identical across them: `@rjsf/core`'s default export and
`FormProps`, `@rjsf/validator-ajv8`'s default export, and the `schema` /
`formData` / `validator` / `liveValidate` / `onSubmit` / children props.
The one signature that did change widened rather than moved —
`liveValidate` went from `boolean` to `'onChange' | 'onBlur' | boolean`,
so the `false` we pass is still valid. This follows the convention set
by #55, which declared `ai@^5 || ^6 || ^7` while dev-testing only the
newest.

Reaching the shadcn theme needed a seam that did not exist. The README
already told readers to "pass your own `Form` from the themed package",
and there was no prop that accepted one — the core `Form` was imported
and rendered directly. `<RjsfForm />` now takes an optional `form`
(`ComponentType<FormProps>`, the type every RJSF theme's default export
already has), defaulting to `@rjsf/core`'s. That is hard-don't #8's
prescribed slot API: `@rjsf/shadcn` is a devDependency for the smoke
test only, never a runtime or peer dependency, so no UI kit is bundled.

Verification, since a peer range is a promise:

- 6.x is what the dev tree installs and CI tests. New smoke test renders
  and submits through the real `@rjsf/shadcn` Form and asserts the theme
  actually rendered (its Tailwind classes, not `@rjsf/core`'s bare
  `form-control`).
- 5.x was exercised by hand, once: `@rjsf/{core,utils,validator-ajv8}`
  pinned to `^5.24.0`, `pnpm install`, `@rjsf/core@5.24.13` resolved —
  `rjsf-form.test.tsx` 4/4 green and `tsc --noEmit` clean over the
  source. It is not re-checked per commit, and the tests say so.
- It cannot be, in one dev tree: pnpm matches peers by package NAME, so
  an `npm:`-aliased 5.x install silently binds the 6.x `@rjsf/utils`
  (measured — `@rjsf+core@5.24.13_@rjsf+utils@6.8.0_...` appears in
  `node_modules/.pnpm`) and the "5.x matrix" would be 6.x wearing a 5.x
  label. `packageExtensions`, the documented fix, is not honoured by
  pnpm 11.1.1 from either `pnpm-workspace.yaml` or `package.json#pnpm`.
- A guard pins the declared range against the major actually installed
  and against the major `@rjsf/shadcn` peers on, so this drift has to be
  deliberate next time. Mutation-tested three ways (narrow the peer
  range / ignore the injected form / drift the declared range) — each
  turns it red.

`peerDependencyRules.ignoreMissing: [tailwindcss]` keeps `pnpm peers
check` clean: `@rjsf/shadcn` pulls `tailwindcss-animate`, whose peer
range is the malformed `">=3.0.0 || insiders"` and is therefore unmet
whatever is installed.

forms-rjsf 3 -> 11 tests. Workspace: 595 passed across 21 packages,
build + typecheck clean. `minor` changeset.

Closes #57

Claude-Session: https://claude.ai/code/session_0191cTtmnhFbC2ZKNtgxHXye
Review repair. The PR claimed a 5.x matrix was impossible; it is not, and
the gap it left was demonstrable.

`packages/forms-rjsf` publishes `@rjsf` peers at `^5.20.0 || ^6.0.0` but
nothing automated exercised the 5.x half, so a value that is legal on 6.x
and illegal on 5.x shipped green. Reproduced: change `liveValidate={false}`
to `liveValidate="onChange"` (6.x widened the type to
`'onChange' | 'onBlur' | boolean`) and typecheck + all 17 tests + CI stay
green, while the same source against real @rjsf/core@5.24.13 declarations
gives `TS2769 ... Type 'string' is not assignable to type
'boolean | undefined'`. A consumer the peer range invites gets a hard
compile error nothing here could catch.

The impossibility claim was overstated. pnpm matching peers by package name
rules out two alias trees inside ONE install; a CI matrix is two INSTALLS.
`scripts/pin-rjsf-5x.mjs` rewrites the three `@rjsf` devDependencies to the
5.x clause READ OFF the declared peer range, drops the 6.x-only
`@rjsf/shadcn` and its smoke test, and a plain `pnpm install` resolves a
plain 5.x tree — no aliases, no packageExtensions. The new `rjsf5` CI job
runs it, asserts a real 5.x tree resolved (`--verify` — a job silently
testing the wrong major is this bug again), then typechecks and tests.
Verified end to end in a throwaway worktree: @rjsf/core@5.24.13 resolved,
`tsc --noEmit` clean, `rjsf-form.test.tsx` 4/4 — and red on the
`liveValidate` mutation above. The script no-ops if the range ever narrows
to 6-only, so the matrix retires with the promise it checks.

Three guards added, one strengthened; each mutation-tested:

- Hard-don't #8 had no test. Promoting `@rjsf/shadcn` from `devDependencies`
  to `dependencies` — the obvious "make the theme just work" edit — pulled a
  UI kit into every consumer's install with nothing red. Now
  `@rjsf/shadcn` is asserted absent from `dependencies` and
  `peerDependencies`, and the package is asserted to declare no runtime
  dependencies at all. Mutation: 2 failed | 15 passed.
- `peerDependencyRules.ignoreMissing: [tailwindcss]` disables missing-peer
  detection workspace-wide and permanently (pnpm honours no scoped form —
  `tailwindcss-animate>tailwindcss` and `@rjsf/shadcn>tailwindcss` were both
  tried). Compensating check: no workspace package may declare a
  `tailwindcss` peer while the suppression is in place, and the block
  retires itself if the rule is removed. Both halves mutation-tested.
- "submits through the theme" passed on an adapter that ignored the injected
  `form` — @rjsf/core's form submits identically, so only one of the two
  theme tests guarded the feature. Both now assert theme identity through
  one `expectThemedInput` helper keyed on @rjsf/core's `form-control` class
  rather than on a Tailwind class that can move between releases. Mutation
  (`form ?? Form` -> `Form`): 1 failed -> 2 failed.

`FormImpl` is recomputed per render, so a non-stable `form` prop remounts
the form and discards in-progress input. Memoizing cannot fix it — the
changed identity arrives as the prop, and React reconciles by element type —
so the requirement is documented on the prop, in the README and in the
palette-design skill instead.

README, test docstrings and the skill no longer state the impossibility;
they say both majors run in CI, which is now true.

forms-rjsf 11 -> 17 tests. Workspace: 601 passed across 21 packages,
`pnpm build` / `pnpm typecheck` / `pnpm test` all exit 0, `pnpm peers check`
clean.

Claude-Session: https://claude.ai/code/session_0191cTtmnhFbC2ZKNtgxHXye
@thorwhalen
thorwhalen merged commit ea386c7 into main Aug 26, 2026
2 checks passed
@thorwhalen
thorwhalen deleted the rjsf-6-peer-range branch August 26, 2026 23:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Widen acture-forms-rjsf's @rjsf/* peer range to 6.x — @rjsf/shadcn ships on 6 only, so shadcn consumers cannot install it

1 participant