Skip to content

refactor(form): add useHeadlessForm and move contract details and basic information onto it - #1433

Merged
gabrielseco merged 23 commits into
mainfrom
refactor/use-headless-form
Oct 5, 2026
Merged

gabrielseco merged 23 commits into
mainfrom
refactor/use-headless-form

Conversation

@gabrielseco

@gabrielseco gabrielseco commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds one shared, tested way for the embeddable flows to build and validate their forms, and moves the Onboarding contract details and basic information steps onto it.

Why

  • Each flow builds and validates its forms with its own hand-written copy of the same logic. Each copy can break on its own, as the money-in-cents bug fixed in fix(form): restore money conversion in createHeadlessForm when options are passed  #1429 did in one of them, and no shared test notices.
  • Testing every schema situation in every flow doesn't scale. With one shared piece, schema situations are tested once and each flow only needs one smoke test proving it uses that piece.
  • The shared piece offers both behaviours flows use today: rebuild the form on every change (old), or build it once and resolve conditional fields through validation (new). The goal is for every form to build once.
  • Contract details already uses both behaviours (older countries rebuild, jsf v1 countries build once), so moving it first proves both strategies against real flows. Basic information goes straight to building once, which shows a step can skip the in-between phase.

What changed

Toggle details
  • src/common/useHeadlessForm.ts (internal, not exported): returns { form, handleValidation, onValuesChange, parseFormValues }.
    • rebuild takes values: createHeadlessForm(schema, values, options), rebuilt when values change. Validation uses isPartialValidation: false.
    • buildOnce takes optional initialValues (saved values, in API units): built once per schema/options with transformMoneyFields: false, validation with isPartialValidation: true. The first build is seeded with initialValues, so visibility is right on the first render without a mounted step or a validation effect, which is what headless useOnboarding() consumers and a read-only employment jumping to review rely on. When jsfModify changes, the form is rebuilt from the last validated values, so fields the user opened or closed stay that way. A new schema starts again from initialValues. The comment explaining why invisible values are kept moved here from hooks.tsx.
    • options and initialValues are compared by value (fast-deep-equal, as useJSONSchemaForm already does), so objects created during render don't rebuild the form on every render.
  • Legacy contract details (useLegacyContractDetailsSchema in src/flows/Onboarding/api.ts, rebuild): only building moves to the hook. Validation and submit parsing for this step still go through the hand-written branches in hooks.tsx.
  • jsf v1 contract details (useContractDetailsSchema, buildOnce): building, validation and submit parsing all go through the hook. It is seeded with the saved contract details (onboardingInitialValues + employment.contract_details), already in cents, so nothing is converted twice. The fieldsCount state that forced a re-render after each validation is gone, since the hook re-renders itself. The unused transformMoneyFields option on this internal hook is removed.
  • Basic information (new useBasicInformationSchema, buildOnce): replaces the useJSONSchema wrapper, which no Onboarding step uses anymore. Building, validation, submit parsing and value changes all go through the hook, seeded with onboardingInitialValues + employment.basic_information. Behaviour change: like every buildOnce form, validation keeps invisible values until handleValidation resolves visibility, instead of dropping them first. The step's initialValues memos now share the same saved-values objects as the hooks.
  • Contract details query keys (both steps): the schema request sends employment_id, but the keys only held country and schema version, so one employment's schema was served to every other employment of the same country in the session. The keys now include the request query.
  • Rollout doc docs/USE_HEADLESS_FORM_ROLLOUT.md: phases, the per-step checklist from refactor(onboarding): build the basic information schema once #1430, and every call site with its status. Basic information is done, legacy contract details is phase 1.
  • Tests
    • src/common/tests/jsfEngineContract.test.tsx renders the real useJSONSchemaForm + JSONSchemaFormFields, so ForcedValueField's setValue loop runs, with real clicks and typing. Grid: situations × engines (jsf v0, jsf v1, no meta) × strategies × options. It starts with a forced money value computed from another money field (the PRT allowance) and a radio that reveals a conditional money field (PRT signing bonus).
    • The same file's buildOnce lifecycle block runs on every engine: initialValues show the right fields on the first render and when they arrive after the schema, fields the user revealed or hid survive a jsfModify change (over initialValues), and a new schema starts from initialValues. Breaking the hook on purpose (ignoring initialValues, ignoring the last validated values, reusing them across schemas) fails the matching tests on all three engines.
    • Smoke tests per step: fix(form): restore money conversion in createHeadlessForm when options are passed  #1429's PRT extended-hours flow test fails when the hook's rebuild stops converting to cents. France's flow tests and useOnboardingJsfV1ContractDetails.test.tsx fail when the hook's buildOnce validation is broken.
    • useOnboardingJsonSchemaVersion.test.tsx: the mocked schema gains x-jsf-presentation, like every real schema (all 20 root fixture schemas have it). The hook builds during render, not inside a React Query select, so the money conversion's lookup no longer turns a malformed schema into a silent query error.

Known behaviour on the legacy (rebuild) step, unchanged by this PR and shared with #1429: on the first render of a prefilled step, server values already in cents are converted again, so a probe computed the PRT allowance as 8125770 before it settled on 81257 once the step mounted. Users never see it. Anything that reads the form before the step mounts could, and that isn't verified. It goes away once that step switches to buildOnce.

No public API change: nothing new is exported from src/index.tsx or internals. useContractDetailsSchema, useBasicInformationSchema and useJsfV1ContractDetails are internal.

Screenshots

N/A

Related Resources

Testing

  • npx vitest run src/common/tests/jsfEngineContract.test.tsx

  • npx vitest run src/flows/Onboarding/tests/OnboardingFlow.test.tsx -t "extended work hours allowance" (legacy step)

  • npx vitest run src/flows/Onboarding/tests/OnboardingFlowFrance.test.tsx src/flows/Onboarding/tests/useOnboardingJsfV1ContractDetails.test.tsx (jsf v1 step)

  • Tested against the example/ app in a browser

  • Feature flag: N/A

🤖 Generated with Claude Code


Note

Medium Risk
Touches onboarding contract details and basic information validation/money handling and query caching keys; behaviour shifts for buildOnce steps (partial validation, initialValues seeding) though covered by new contract tests.

Overview
Introduces an internal useHeadlessForm hook that centralizes JSON Schema form build/validate/parse with two strategies: rebuild (re-create the form on value changes) and buildOnce (single build, partial validation for conditionals, stable initialValues/jsfModify via deep equality).

Onboarding moves onto this hook: legacy contract details use rebuild via useLegacyContractDetailsSchema; jsf v1 contract details and basic information use buildOnce with dedicated schema hooks, wiring validation/parse (and value-change for basic info) through the hook instead of duplicated hooks.tsx logic. Contract-details React Query keys now include the request query (e.g. employment_id) so schemas are not shared across employments.

Adds schema situation unit tests (jsfEngineSituations.ts + jsfEngineContract.test.tsx) run across jsf engines, strategies, and options, plus buildOnce lifecycle cases. Documents the rollout in docs/USE_HEADLESS_FORM_ROLLOUT.md and expands testing guidance in ARCHITECTURE.md, CLAUDE.md, and BUGBOT.md (form bugs need a failing situation). Tweaks a jsonSchemaVersion test mock to include x-jsf-presentation.

No new public exports from the package entry points.

Reviewed by Cursor Bugbot for commit 0cafa51. Bugbot is set up for automated code reviews on this repo. Configure here.

cammellos and others added 11 commits September 29, 2026 18:32
Portugal's extended work hours allowance is a forced money value computed
from the salary. Onboarding built its render-time form without converting
money fields to cents, so the allowance was computed from the salary in
major units (100x too small), and the forced value was then written into
the form state in cents while the form keeps money in major units.
Validation recomputed the correct value and rejected the submission on a
field with no input to show the error, blocking the step.

- Onboarding's useJSONSchemaForm passes transformMoneyFields: true, since
  createHeadlessForm only defaults it on when no options are passed.
- Forced money values are converted from cents before being set in the form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Default transformMoneyFields to true whenever it's not explicitly set,
instead of only when the whole options object is missing. Callers that
pass options without transformMoneyFields (e.g. only jsfModify) now get
money fields converted to cents too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tails path

The flow regression test only covers the legacy path while PRT is outside
JSF_V1_CONTRACT_DETAILS_COUNTRIES. These test the Onboarding useJSONSchemaForm
hook and JSONSchemaFormFields directly, so they keep covering the fix
whichever countries are on v1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Submitting a forced value already uses the field's `const` in cents
(parseFormValuesToAPI overwrites the form value with it), and the default
forced value component doesn't render `value`. The `transformMoneyFields`
change alone fixes the PRT allowance; getForcedValue would only change
what custom forcedValue components receive in `fieldData.value`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Correct submission of forced money values relies on parseFormValuesToAPI
overwriting the form value with the field's const. Covers a top-level field
and one nested in a fieldset; both fail with 100x the amount without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createHeadlessForm now defaults transformMoneyFields per key (#1426), so
the explicit override in useJSONSchemaForm is redundant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gies

Every flow wires createHeadlessForm, parseJSFToValidate and handleValidation
by hand, and each copy can drift on its own (the money unit regression lived
in one of them). useHeadlessForm puts that wiring behind one interface with
the two strategies the codebase uses today:

- rebuild: rebuild the form whenever values change (current behaviour of
  most call sites).
- buildOnce: build once per schema/options and resolve conditionals through
  handleValidation. It validates once after every build, so visibility is
  right without a mounted step and an inline jsfModify reference does not
  reset it; a replaced form's pending validation is cancelled before it
  mutates the old fields.

The engine contract test renders the real useJSONSchemaForm +
JSONSchemaFormFields (so ForcedValueField's setValue loop runs) and runs
each schema situation across jsf v0, jsf v1, no meta and both strategies.
New situations are one row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssForm

First call site on the shared hook, with the rebuild strategy so behaviour
is unchanged. useLegacyContractDetailsSchema fetches the raw schema and
leaves building to the hook; the shared useJSONSchema stays as it is for the
other steps. mergedFormValues moves out of that wrapper so both can use it.

The existing PRT extended hours allowance flow test covers this step: it
fails when the hook's rebuild stops converting money to cents.

The jsonSchemaVersion test's mocked schema gains x-jsf-presentation, like
every real schema has: the hook builds during render instead of inside a
React Query select, so the money conversion's lookup of it now surfaces
instead of turning into a silent query error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

Metric Current Previous Change Status
Total (gzip) 223.31 kB 222.56 kB +746 B (+0.3%) 🔴
Total (raw) 624.77 kB 622.83 kB +1.94 kB (+0.3%) 🔴
CSS (gzip) 21.94 kB 21.94 kB 0 B (0%) 🟢
CSS (raw) 114.43 kB 114.43 kB 0 B (0%) 🟢

Size Limits

  • ✅ Total gzipped: 223.31 kB / 350 kB (63.8%)
  • ✅ Total raw: 624.77 kB / 850 kB (73.5%)
  • ✅ CSS gzipped: 21.94 kB / 25 kB (87.8%)

Largest Files (Top 5)

  1. index.esm-wIkXrqU6.js - 11.4 kB (0 B (0%))
  2. styles.css - 10.97 kB (0 B (0%))
  3. index.css - 10.97 kB (0 B (0%))
  4. internals-D0_df-Qx.js - 6.14 kB (new)
  5. hooks-FsZb16ry.js - 5.81 kB (new)
View All Files (283 total)
File Size (gzip) Change
index.esm-wIkXrqU6.js 11.4 kB 0 B (0%)
styles.css 10.97 kB 0 B (0%)
index.css 10.97 kB 0 B (0%)
internals-D0_df-Qx.js 6.14 kB new
hooks-FsZb16ry.js 5.81 kB new
sdk.gen-hikpofx9.js 5.48 kB 0 B (0%)
index.js 5.45 kB 0 B (0%)
flows/Onboarding/hooks.js 4.33 kB -93 B (-2.1%)
utils-qfe-oQBs.js 4.07 kB 0 B (0%)
FieldSetField-Bds5UIuj.js 4.03 kB 0 B (0%)

✅ Bundle size check passed

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Deploy preview for remote-flows ready!

Project:remote-flows
Status: ✅  Deploy successful!
Preview URL:https://remote-flows-5s4qrezw1-remotecom.vercel.app
Latest Commit:0cafa51

Deployed with vercel-action

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Deploy preview for adp-cost-calculator ready!

Project:adp-cost-calculator
Status: ✅  Deploy successful!
Preview URL:https://adp-cost-calculator-hbf5j7u26-remotecom.vercel.app
Latest Commit:0cafa51

Deployed with vercel-action

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📊 Coverage Report

✅ Coverage increased! 🎉

Metric Current Previous Change Status
Lines 86.46% 86.41% +0.06% 🟢
Statements 86.02% 85.97% +0.05% 🟢
Functions 85.04% 84.92% +0.12% 🟢
Branches 77.97% 77.95% +0.02% 🟢

Detailed Breakdown

Lines Coverage
  • Covered: 4899 / 5666
  • Coverage: 86.46%
  • Change: +0.06% (43 lines)
Statements Coverage
  • Covered: 4985 / 5795
  • Coverage: 86.02%
  • Change: +0.05% (45 statements)
Functions Coverage
  • Covered: 1302 / 1531
  • Coverage: 85.04%
  • Change: +0.12% (12 functions)
Branches Coverage
  • Covered: 3040 / 3899
  • Coverage: 77.97%
  • Change: +0.02% (28 branches)

✅ Coverage check passed

gabrielseco and others added 2 commits September 30, 2026 10:42
… options

buildOnce rebuilt the form whenever the options object changed identity, and
every build validates and re-renders. A caller that creates options during
render (as the Onboarding contract details hooks do) looped forever: 132
renders in 500ms in a probe, "Too many re-renders" in the test.

Options are now compared by value with fast-deep-equal, the helper
useJSONSchemaForm already uses. Functions and components inside jsfModify
keep their identity across our own renders, so they compare equal by
reference. React elements cannot reach jsfModify: the jsf engine
deep-clones it and rejects them, so the React-aware variant is not needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssForm

The v1 contract details (FRA, ITA, DEU, ESP) already built the form once and
resolved conditionals through handleValidation with invisible values kept,
which is the hook's buildOnce strategy. useContractDetailsSchema now hands
building, validation and submit parsing to the hook, so contract details
covers both strategies: the legacy step with rebuild, v1 with buildOnce.

The fieldsCount state that forced a re-render after each v1 validation goes
away, since the hook re-renders itself. The comment on why invisible values
are kept moves with that decision into the hook. The replay after a rebuild
uses the step's form values, not the server employment data, so money is
never converted from cents a second time on this path.

France's flow tests and useOnboardingJsfV1ContractDetails fail when the
hook's buildOnce validation is broken, so they cover this step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gabrielseco gabrielseco changed the title refactor(form): add useHeadlessForm and move legacy contract details onto it refactor(form): add useHeadlessForm and move contract details onto it Sep 30, 2026
Both contract details schema requests send employment_id, but their query
keys only held the country and schema version, so React Query served the
first employment's schema to every other employment of the same country in
the session, and never refetched once an employment was created mid-flow.
The keys now include the request query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gabrielseco
gabrielseco changed the base branch from refactor/drop-get-forced-value to main September 30, 2026 09:00
gabrielseco and others added 3 commits September 30, 2026 11:14
The contract only built forms without options, which is the one shape where
createHeadlessForm's money default is irrelevant, so reverting that default to
the old `options || { transformMoneyFields: true }` stayed green here and was
only caught by flow and hook tests. Run every situation with no options, with
an options object carrying no jsfModify (what Onboarding passes when the
consumer gives none), and with a real jsfModify whose effect is asserted.

Mutation checked: the old default fails the 6 rebuild money rows, and dropping
options inside useHeadlessForm fails the 6 jsfModify rows.

The Onboarding useJSONSchemaForm money forced value test is removed: its
engine check is now in the contract and its wiring is covered end to end by
the OnboardingFlow allowance-in-cents test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gabrielseco and others added 4 commits October 5, 2026 12:35
…c information onto useHeadlessForm

useHeadlessForm's buildOnce strategy now takes initialValues and resolves
conditional visibility at build time instead of replaying values in a
post-mount effect. Rebuilds triggered by jsfModify reuse the last validated
values for the same schema, while a new schema starts from initialValues.

Onboarding's basic_information step now goes through useBasicInformationSchema
(buildOnce) and contract details receives saved employment values as
initialValues rather than the live fieldValues.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…contract test

The buildOnce rebuild/seed rules (initialValues on first render and when they
arrive late, last validated values surviving a jsfModify change and beating
initialValues, a new schema starting from initialValues) now run through the
real form on jsf v0, jsf v1 and no meta, instead of a renderHook test on the
jsf v1 fixture only.

The render-count guard is dropped: the hook no longer validates after each
build, so recreated options cannot cause a render loop anymore.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…llout

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@gabrielseco gabrielseco changed the title refactor(form): add useHeadlessForm and move contract details onto it refactor(form): add useHeadlessForm and move contract details and basic information onto it Oct 5, 2026
gabrielseco added a commit that referenced this pull request Oct 5, 2026
…orm guidance

buildOnce now takes initialValues instead of values (#1433), so the CLAUDE.md,
Cursor rule and Bugbot examples would have produced a type error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nt when to add one (#1443)

* test(form): move jsf engine situations into their own file and document when to add one

Form behaviour bugs (visibility, computed values, money conversion,
initialValues, jsfModify) should be reproduced as a situation in the jsf
engine contract test instead of a per-country integration test. The rule
now lives in CLAUDE.md, the BUGBOT review guide and the useHeadlessForm
rollout doc, and situations live in jsfEngineSituations.ts so adding one
doesn't touch the test logic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(form): replace the Portugal fixture with minimal inline schemas in the jsf engine situations

Each situation now uses a schema that only contains the fields the
behaviour needs, matching the guidance in CLAUDE.md and the rollout doc,
and gives the next contributor an inline example to copy that works on
jsf v0, v1 and schemas without meta.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ke tests (#1444)

Adds a Test Layers section to the testing chapter: what unit tests and
schema situations, integration, E2E and smoke tests each catch, where a
bug's test belongs, and how failures found by the slow layers turn into
situations. Smoke tests are marked as planned (headless, scheduled,
against sandbox), with what to copy from Platform's FE smoke tests and
what to avoid.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@gabrielseco
gabrielseco merged commit 9b6a302 into main Oct 5, 2026
13 checks passed
@gabrielseco
gabrielseco deleted the refactor/use-headless-form branch October 5, 2026 16:30
gabrielseco added a commit that referenced this pull request Oct 5, 2026
…mports (#1439)

* fix(onboarding): submit computed money forced values in the right unit

Portugal's extended work hours allowance is a forced money value computed
from the salary. Onboarding built its render-time form without converting
money fields to cents, so the allowance was computed from the salary in
major units (100x too small), and the forced value was then written into
the form state in cents while the form keeps money in major units.
Validation recomputed the correct value and rejected the submission on a
field with no input to show the error, blocking the step.

- Onboarding's useJSONSchemaForm passes transformMoneyFields: true, since
  createHeadlessForm only defaults it on when no options are passed.
- Forced money values are converted from cents before being set in the form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(forms): default transformMoneyFields per key

Default transformMoneyFields to true whenever it's not explicitly set,
instead of only when the whole options object is missing. Callers that
pass options without transformMoneyFields (e.g. only jsfModify) now get
money fields converted to cents too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(invoice-schedules): drop redundant money flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(onboarding): cover money forced values on the legacy contract details path

The flow regression test only covers the legacy path while PRT is outside
JSF_V1_CONTRACT_DETAILS_COUNTRIES. These test the Onboarding useJSONSchemaForm
hook and JSONSchemaFormFields directly, so they keep covering the fix
whichever countries are on v1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(form): drop getForcedValue

Submitting a forced value already uses the field's `const` in cents
(parseFormValuesToAPI overwrites the form value with it), and the default
forced value component doesn't render `value`. The `transformMoneyFields`
change alone fixes the PRT allowance; getForcedValue would only change
what custom forcedValue components receive in `fieldData.value`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(form): pin that forced values are submitted as their const

Correct submission of forced money values relies on parseFormValuesToAPI
overwriting the form value with the field's const. Covers a top-level field
and one nested in a fieldset; both fail with 100x the amount without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(onboarding): drop explicit transformMoneyFields

createHeadlessForm now defaults transformMoneyFields per key (#1426), so
the explicit override in useJSONSchemaForm is redundant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(form): drop parseSubmitValues forced value test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(form): add useHeadlessForm with rebuild and buildOnce strategies

Every flow wires createHeadlessForm, parseJSFToValidate and handleValidation
by hand, and each copy can drift on its own (the money unit regression lived
in one of them). useHeadlessForm puts that wiring behind one interface with
the two strategies the codebase uses today:

- rebuild: rebuild the form whenever values change (current behaviour of
  most call sites).
- buildOnce: build once per schema/options and resolve conditionals through
  handleValidation. It validates once after every build, so visibility is
  right without a mounted step and an inline jsfModify reference does not
  reset it; a replaced form's pending validation is cancelled before it
  mutates the old fields.

The engine contract test renders the real useJSONSchemaForm +
JSONSchemaFormFields (so ForcedValueField's setValue loop runs) and runs
each schema situation across jsf v0, jsf v1, no meta and both strategies.
New situations are one row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(onboarding): build legacy contract details through useHeadlessForm

First call site on the shared hook, with the rebuild strategy so behaviour
is unchanged. useLegacyContractDetailsSchema fetches the raw schema and
leaves building to the hook; the shared useJSONSchema stays as it is for the
other steps. mergedFormValues moves out of that wrapper so both can use it.

The existing PRT extended hours allowance flow test covers this step: it
fails when the hook's rebuild stops converting money to cents.

The jsonSchemaVersion test's mocked schema gains x-jsf-presentation, like
every real schema has: the hook builds during render instead of inside a
React Query select, so the money conversion's lookup of it now surfaces
instead of turning into a silent query error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(form): stop buildOnce from rebuilding on every render with inline options

buildOnce rebuilt the form whenever the options object changed identity, and
every build validates and re-renders. A caller that creates options during
render (as the Onboarding contract details hooks do) looped forever: 132
renders in 500ms in a probe, "Too many re-renders" in the test.

Options are now compared by value with fast-deep-equal, the helper
useJSONSchemaForm already uses. Functions and components inside jsfModify
keep their identity across our own renders, so they compare equal by
reference. React elements cannot reach jsfModify: the jsf engine
deep-clones it and rejects them, so the React-aware variant is not needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(onboarding): build jsf v1 contract details through useHeadlessForm

The v1 contract details (FRA, ITA, DEU, ESP) already built the form once and
resolved conditionals through handleValidation with invisible values kept,
which is the hook's buildOnce strategy. useContractDetailsSchema now hands
building, validation and submit parsing to the hook, so contract details
covers both strategies: the legacy step with rebuild, v1 with buildOnce.

The fieldsCount state that forced a re-render after each v1 validation goes
away, since the hook re-renders itself. The comment on why invisible values
are kept moves with that decision into the hook. The replay after a rebuild
uses the step's form values, not the server employment data, so money is
never converted from cents a second time on this path.

France's flow tests and useOnboardingJsfV1ContractDetails fail when the
hook's buildOnce validation is broken, so they cover this step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(onboarding): refetch the contract details schema per employment

Both contract details schema requests send employment_id, but their query
keys only held the country and schema version, so React Query served the
first employment's schema to every other employment of the same country in
the session, and never refetched once an employment was created mid-flow.
The keys now include the request query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(form): cover options shapes in the jsf engine contract

The contract only built forms without options, which is the one shape where
createHeadlessForm's money default is irrelevant, so reverting that default to
the old `options || { transformMoneyFields: true }` stayed green here and was
only caught by flow and hook tests. Run every situation with no options, with
an options object carrying no jsfModify (what Onboarding passes when the
consumer gives none), and with a real jsfModify whose effect is asserted.

Mutation checked: the old default fails the 6 rebuild money rows, and dropping
options inside useHeadlessForm fails the 6 jsfModify rows.

The Onboarding useJSONSchemaForm money forced value test is removed: its
engine check is now in the contract and its wiring is covered end to end by
the OnboardingFlow allowance-in-cents test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(form): add useHeadlessForm rollout plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(lint): enforce useHeadlessForm over direct createHeadlessForm imports

Adds a no-restricted-imports rule that bans importing createHeadlessForm
from the kit or the common wrapper. Legacy call sites are allowlisted in
an override; each migration PR removes its file from the list.

Rewrites the CLAUDE.md forms guidance, the json-schema-form-usage Cursor
rule and the BUGBOT.md forms section around the useHeadlessForm pattern,
and moves the lint rule out of the rollout doc's phase 3.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(lint): mark legacy createHeadlessForm imports inline instead of an allowlist

The rule now always fails. Each legacy import carries an
oxlint-disable-next-line comment with a TODO, so the remaining debt sits
next to the code and is greppable. The overrides only keep permanent
exceptions: the wrapper, the hook, tests and scripts.

Lint now reports unused disable directives as errors, so migrating a file
forces removing its comment. That surfaced three directives that no
longer suppressed anything, which are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(bugbot): drop unclear useHeadlessForm flag line

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* remove no effect

* chore(lint): fail example lint on unused disable directives

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(lint): catch relative createHeadlessForm imports

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(form): seed buildOnce forms from initialValues and move basic information onto useHeadlessForm

useHeadlessForm's buildOnce strategy now takes initialValues and resolves
conditional visibility at build time instead of replaying values in a
post-mount effect. Rebuilds triggered by jsfModify reuse the last validated
values for the same schema, while a new schema starts from initialValues.

Onboarding's basic_information step now goes through useBasicInformationSchema
(buildOnce) and contract details receives saved employment values as
initialValues rather than the live fieldValues.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(form): fold useHeadlessForm lifecycle cases into the jsf engine contract test

The buildOnce rebuild/seed rules (initialValues on first render and when they
arrive late, last validated values surviving a jsfModify change and beating
initialValues, a new schema starting from initialValues) now run through the
real form on jsf v0, jsf v1 and no meta, instead of a renderHook test on the
jsf v1 fixture only.

The render-count guard is dropped: the hook no longer validates after each
build, so recreated options cannot cause a render loop anymore.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(form): mark basic information as migrated to buildOnce in the rollout

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(onboarding): drop the contract details employment_id query key test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(form): pass saved initialValues to buildOnce in the useHeadlessForm guidance

buildOnce now takes initialValues instead of values (#1433), so the CLAUDE.md,
Cursor rule and Bugbot examples would have produced a type error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(lint): exempt __tests__ and colocated test files from the createHeadlessForm ban

Tests also live in __tests__ folders and next to source as *.test.ts(x),
not only under tests/, so the override now covers them too, matching the
guidance that tests may use the engine directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(onboarding): build benefits and engagement agreement details once through useHeadlessForm (#1442)

* refactor(onboarding): build benefits and engagement agreement details once through useHeadlessForm

Both schema hooks now fetch the raw schema and hand it to useHeadlessForm with
the buildOnce strategy, seeded with the saved values (partner initialValues
plus the saved benefit offers / engagement agreement details). Validation,
submit parsing and value changes for both steps go through the hook instead
of the hand-written parseJSFToValidate branches in hooks.tsx, and the forms
are no longer rebuilt on every keystroke from the live field values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(form): mark benefits and engagement agreement details as migrated to buildOnce

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* add tests to verify values

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Andrea Maria Piana <andrea.maria.piana@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@gabrielseco gabrielseco mentioned this pull request Oct 6, 2026
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.

3 participants