Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions docs/USE_HEADLESS_FORM_ROLLOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ Every flow builds its JSON Schema forms through one shared hook, [`src/common/us
## Strategies

- **`rebuild`** (old): `createHeadlessForm(schema, values, options)` again whenever values change. Money values are converted to cents at build time.
- **`buildOnce`** (new): built once per `schema`/`options`. Conditionals are resolved by `handleValidation` with `isPartialValidation: true`, and the latest values are replayed after each build so visibility is right without a mounted step.
- **`buildOnce`** (new): built once per `schema`/`options`. Conditionals are resolved by `handleValidation` with `isPartialValidation: true`, and the latest values are replayed after each build so visibility is right without a mounted step. `form` stays `null` (`isBuilding: true`) until that first replay finishes, so nothing ever reads a form with default visibility. After a `jsfModify` rebuild the previous form is kept until the new one is ready; a new `schema` goes back to `null`.

### Adopting `buildOnce` in a step

1. Pass `values` holding only that step's values, in form units: partner `initialValues`, then the saved employment's data for that step, then `stepState.values` for the step, then the live `fieldValues` while the step is mounted. Values from another step change visibility in the wrong form. Saved values in API units (money in cents) have to be converted to form units first.
2. Add `isBuilding` to the step's loading state, so the step never mounts with an unresolved form (its `defaultValues` would be computed from empty fields).
3. Route the step's `handleValidation` and `parseFormValues` through the hook, and call `onValuesChange` from `checkFieldUpdates` while the step is current.

## Phases

Expand Down Expand Up @@ -47,7 +53,6 @@ Status: `todo`, `phase 1` (on the hook, old behaviour), `done` (on the hook with
| Call site | Status | Notes |
| ------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Onboarding `useLegacyContractDetailsSchema` | phase 1 | #1433. Only building moved; validation and submit parsing still go through the hand-written branches in `Onboarding/hooks.tsx` |
| Onboarding `useJSONSchemaForm` (basic information and other steps) | todo | #1430 tried `buildOnce` here and was closed |
| Onboarding `useBenefitOffersSchema` | todo | |
| Onboarding `useEngagementAgreementDetailsSchema` | todo | |
| Contractor contract details `useContractorOnboardingDetailsSchema` | todo | Money-sensitive |
Expand All @@ -65,16 +70,17 @@ Status: `todo`, `phase 1` (on the hook, old behaviour), `done` (on the hook with

### Already build once (no values)

| Call site | Status | Notes |
| ----------------------------------------------------------- | ------ | --------------------------------------------------------- |
| Onboarding `useContractDetailsSchema` (jsf v1) | done | #1433 |
| Onboarding `useCountriesSchemaField` | todo | |
| ContractorOnboarding `useCountriesSchemaField` | todo | |
| ContractorOnboarding `useContractorSubscriptionSchemaField` | todo | |
| ContractorOnboarding `useGetInvoiceScheduleSchema` | todo | |
| CreateCompany `useCountriesSchemaField` (basic information) | todo | |
| CostCalculator `useStaticSchema` + `useRegionFields` | todo | Combines several forms; needs a design per sub-form first |
| Termination full schema in `useTermination` | todo | Combines several forms; needs a design per sub-form first |
| Call site | Status | Notes |
| ----------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Onboarding `useContractDetailsSchema` (jsf v1) | done | #1433. Replays only the mounted step's `fieldValues`, so a read-only review doesn't see saved values yet (needs cents converted to form units) |
| Onboarding `useBasicInformationSchema` | done | Was `rebuild` through `useJSONSchemaForm`; #1430 was the first attempt |
| Onboarding `useCountriesSchemaField` | todo | |
| ContractorOnboarding `useCountriesSchemaField` | todo | |
| ContractorOnboarding `useContractorSubscriptionSchemaField` | todo | |
| ContractorOnboarding `useGetInvoiceScheduleSchema` | todo | |
| CreateCompany `useCountriesSchemaField` (basic information) | todo | |
| CostCalculator `useStaticSchema` + `useRegionFields` | todo | Combines several forms; needs a design per sub-form first |
| Termination full schema in `useTermination` | todo | Combines several forms; needs a design per sub-form first |

## Known behaviour until phase 2

Expand Down
74 changes: 61 additions & 13 deletions src/common/tests/useHeadlessForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,45 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useHeadlessForm } from '@/src/common/useHeadlessForm';
import { JSFModify } from '@/src/flows/types';
import { contractDetailsSchemaV1Portugal } from '@/src/flows/Onboarding/tests/fixtures';
import {
basicInformationSchemaV3Portugal,
contractDetailsSchemaV1Portugal,
} from '@/src/flows/Onboarding/tests/fixtures';
import { $TSFixMe } from '@/src/types/remoteFlows';

const { 'x-rmt-meta': _meta, ...schema } = contractDetailsSchemaV1Portugal.data;

const findField = (fields: $TSFixMe[] | undefined, name: string) =>
fields?.find((field) => field.name === name);

const isVisible = (fields: $TSFixMe[] | undefined, name: string) =>
fields?.find((field) => field.name === name)?.isVisible;
findField(fields, name)?.isVisible;

const renamedBonus = (title: string): JSFModify => ({
fields: { signing_bonus_amount: { title } },
});

describe('useHeadlessForm buildOnce', () => {
it('replays the latest values into a rebuilt form and leaves the replaced one untouched', async () => {
it('hands out the form only once its visibility matches the values', async () => {
const { result } = renderHook(() =>
useHeadlessForm({
schema,
values: { has_signing_bonus: 'yes' },
strategy: 'buildOnce',
}),
);

expect(result.current.form).toBeNull();
expect(result.current.isBuilding).toBe(true);

await waitFor(() => expect(result.current.form).not.toBeNull());
expect(result.current.isBuilding).toBe(false);
expect(isVisible(result.current.form?.fields, 'signing_bonus_amount')).toBe(
true,
);
});

it('keeps the resolved form while a changed jsfModify rebuilds, then replays the latest values into the new one', async () => {
const { result, rerender } = renderHook(
({ jsfModify }) =>
useHeadlessForm({
Expand All @@ -25,17 +50,44 @@ describe('useHeadlessForm buildOnce', () => {
}),
{ initialProps: { jsfModify: renamedBonus('Bonus') } },
);
await waitFor(() => expect(result.current.form).not.toBeNull());
const replacedForm = result.current.form;

rerender({ jsfModify: renamedBonus('Welcome bonus') });

expect(result.current.form).toBe(replacedForm);
expect(result.current.isBuilding).toBe(false);
await waitFor(() => expect(result.current.form).not.toBe(replacedForm));
const rebuiltFields = result.current.form?.fields;
expect(findField(rebuiltFields, 'signing_bonus_amount')?.label).toBe(
'Welcome bonus',
);
expect(isVisible(rebuiltFields, 'signing_bonus_amount')).toBe(true);
});

it('stops handing out the previous form when the schema changes', async () => {
const { 'x-rmt-meta': _basicMeta, ...otherSchema } =
basicInformationSchemaV3Portugal.data;
const { result, rerender } = renderHook(
({ currentSchema }) =>
useHeadlessForm({
schema: currentSchema,
values: {},
strategy: 'buildOnce',
}),
{ initialProps: { currentSchema: schema as Record<string, unknown> } },
);
await waitFor(() => expect(result.current.form).not.toBeNull());

rerender({ currentSchema: otherSchema });

expect(result.current.form).toBeNull();
expect(result.current.isBuilding).toBe(true);
await waitFor(() =>
expect(
isVisible(result.current.form?.fields, 'signing_bonus_amount'),
).toBe(true),
findField(result.current.form?.fields, 'has_seniority_date'),
).toBeDefined(),
);
expect(result.current.form).not.toBe(replacedForm);
expect(isVisible(replacedForm?.fields, 'signing_bonus_amount')).toBe(false);
});

it('keeps the form when options are recreated with the same content on every render', async () => {
Expand All @@ -53,13 +105,9 @@ describe('useHeadlessForm buildOnce', () => {
strategy: 'buildOnce',
});
});
const builtForm = result.current.form;

await waitFor(() =>
expect(
isVisible(result.current.form?.fields, 'signing_bonus_amount'),
).toBe(true),
);
await waitFor(() => expect(result.current.form).not.toBeNull());
const builtForm = result.current.form;
await new Promise((resolve) => setTimeout(resolve, 100));

expect(result.current.form).toBe(builtForm);
Expand Down
53 changes: 40 additions & 13 deletions src/common/useHeadlessForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

export type HeadlessForm = {
form: JSONSchemaFormResultWithFieldsets | null;
isBuilding: boolean;
handleValidation: (values: FieldValues) => Promise<ValidationResult | null>;
onValuesChange: (values: FieldValues) => Promise<void>;
parseFormValues: (values: FieldValues) => Promise<FieldValues>;
Expand All @@ -33,6 +34,10 @@
}: UseHeadlessFormArgs): HeadlessForm {
const isBuildOnce = strategy === 'buildOnce';
const [, setRevision] = useState(0);
const [resolved, setResolved] = useState<{
schema: Record<string, unknown>;
form: JSONSchemaFormResultWithFieldsets;
} | null>(null);
const [stableOptions, setStableOptions] = useState(options);
if (!equal(stableOptions, options)) {
setStableOptions(options);
Expand All @@ -44,7 +49,7 @@

const buildValues = isBuildOnce ? undefined : values;

const form = useMemo(() => {
const builtForm = useMemo(() => {
if (!schema) return null;
return isBuildOnce
? createHeadlessForm(
Expand All @@ -55,29 +60,39 @@
: createHeadlessForm(schema, buildValues, stableOptions);
}, [schema, buildValues, stableOptions, isBuildOnce]);

const validate = useCallback(
async (nextValues: FieldValues, isCancelled: () => boolean) => {
if (!form) return null;
const form = !isBuildOnce
? builtForm
: resolved && resolved.schema === schema
? resolved.form
: null;

const validateForm = useCallback(
async (
target: JSONSchemaFormResultWithFieldsets | null,
nextValues: FieldValues,
isCancelled: () => boolean,
) => {
if (!target) return null;
// buildOnce keeps invisible values on purpose. The fields still hold the
// visibility of the previous change, so dropping their values would hide
// what a field that is about to become visible needs to compute itself:
// a hidden fieldset coming back would lose the values driving its own
// children. handleValidation resolves the visibility first and nulls
// whatever it considers hidden afterwards, which is the right order.
const parsedValues = await parseJSFToValidate(nextValues, form.fields, {
const parsedValues = await parseJSFToValidate(nextValues, target.fields, {
isPartialValidation: isBuildOnce,
});
if (isCancelled()) return null;
const result = form.handleValidation(parsedValues);
const result = target.handleValidation(parsedValues);
if (isBuildOnce) setRevision((revision) => revision + 1);
return result;
},
[form, isBuildOnce],
[isBuildOnce],
);

const handleValidation = useCallback(
(nextValues: FieldValues) => validate(nextValues, () => false),
[validate],
(nextValues: FieldValues) => validateForm(form, nextValues, () => false),
[validateForm, form],
);

const onValuesChange = useCallback(
Expand All @@ -88,19 +103,31 @@
);

useEffect(() => {
if (!isBuildOnce) return;
if (!isBuildOnce || !schema || !builtForm) return;
let cancelled = false;
void validate(latestValues.current, () => cancelled);
void validateForm(builtForm, latestValues.current, () => cancelled).finally(

Check warning on line 108 in src/common/useHeadlessForm.ts

View workflow job for this annotation

GitHub Actions / Lint and Format

react(set-state-in-effect)

src/common/useHeadlessForm.ts:108:10: Calling setState synchronously within an effect can trigger cascading renders
() => {
if (!cancelled) setResolved({ schema, form: builtForm });
},
);
return () => {
cancelled = true;
};
}, [validate, isBuildOnce]);
}, [validateForm, builtForm, schema, isBuildOnce]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Form resolves without employment values

High Severity

The first buildOnce replay can finish before employment.basic_information is in values. isBuilding then drops and nothing replays again unless onValuesChange runs, so a cached or faster schema leaves conditionals hidden for read-only review and headless consumers.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 28f8206. Configure here.


const parseFormValues = useCallback(
async (nextValues: FieldValues) =>
form ? parseJSFToValidate(nextValues, form.fields) : {},
[form],
);

return { form, handleValidation, onValuesChange, parseFormValues };
const isBuilding = Boolean(schema) && !form;

return {
form,
isBuilding,
handleValidation,
onValuesChange,
parseFormValues,
};
}
87 changes: 84 additions & 3 deletions src/flows/Onboarding/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ export const useContractDetailsSchema = ({
options?: FlowOptions & { queryOptions?: { enabled?: boolean } };
query?: Record<string, unknown>;
jsonSchemaVersion?: number | 'latest';
}): Omit<HeadlessForm, 'form' | 'onValuesChange'> & {
}): Omit<HeadlessForm, 'form' | 'isBuilding' | 'onValuesChange'> & {
data: JSONSchemaFormResultWithFieldsets | null;
isLoading: boolean;
} => {
Expand Down Expand Up @@ -375,14 +375,95 @@ export const useContractDetailsSchema = ({
select: ({ data }) => data?.data,
});

const { form, handleValidation, parseFormValues } = useHeadlessForm({
const { form, isBuilding, handleValidation, parseFormValues } =
useHeadlessForm({
schema,
values: fieldValues,
options: { jsfModify: options?.jsfModify },
strategy: 'buildOnce',
});

return {
data: form,
isLoading: isLoading || isBuilding,
handleValidation,
parseFormValues,
};
};

export const useBasicInformationSchema = ({
countryCode,
fieldValues,
options,
jsonSchemaVersion,
}: {
countryCode: string;
fieldValues: FieldValues;
options?: FlowOptions & { queryOptions?: { enabled?: boolean } };
jsonSchemaVersion?: number | 'latest';
}): Omit<HeadlessForm, 'form' | 'isBuilding'> & {
data: JSONSchemaFormResultWithFieldsets | null;
isLoading: boolean;
} => {
const { client } = useClient();
const jsonSchemaQueryParam = jsonSchemaVersion
? {
json_schema_version: jsonSchemaVersion,
}
: {};
const { data: schema, isLoading } = useQuery({
queryKey: [
'onboarding-basic-information-schema',
countryCode,
jsonSchemaVersion,
],
retry: false,
queryFn: async () => {
const response = await getV1CountriesCountryCodeForm({
client: client as Client,
headers: {
Authorization: ``,
},
path: {
country_code: countryCode,
form: 'employment_basic_information',
},
query: {
skip_benefits: true,
...jsonSchemaQueryParam,
},
});

if (response.error || !response.data) {
throw new Error('Failed to fetch onboarding schema');
}

return response;
},
enabled: options?.queryOptions?.enabled,
select: ({ data }) => data?.data,
});

const {
form,
isBuilding,
handleValidation,
onValuesChange,
parseFormValues,
} = useHeadlessForm({
schema,
values: fieldValues,
options: { jsfModify: options?.jsfModify },
strategy: 'buildOnce',
});

return { data: form, isLoading, handleValidation, parseFormValues };
return {
data: form,
isLoading: isLoading || isBuilding,
handleValidation,
onValuesChange,
parseFormValues,
};
};

export const useBenefitOffersSchema = (
Expand Down
Loading
Loading