Repository navigation
Description
A form instrument that declares resetButton: true loses every answer when its submission fails. libui's Form calls reset() as soon as its onSubmit callback resolves. FormContent passes it (data) => void onSubmit(...), which resolves immediately with undefined, before the host has saved anything. For an instrument with a reset button, preventResetValuesOnReset is false, so reset() clears every field at that moment. If the save then fails, the renderer correctly stays on the form, but the form is now empty. Causes include a network error, an API error, or validateSubmission rejecting the data with "The information submitted is invalid and cannot be saved". The subject has to fill the whole form in again. The library's only opt-in instrument is the Enhanced Demographics Questionnaire, which testing/src/specs/form-reset.spec.ts describes as the longest form in the library. The instrument guidelines say a resetButton form "is emptied" after a successful submit, but today it is emptied after any submit.
Where
packages/react-core/src/components/FormContent/FormContent.tsx:53-62:
<Form
...
preventResetValuesOnReset={!instrument.resetButton}
resetBtn={instrument.resetButton}
...
onSubmit={(data) => void onSubmit({ data, kind: 'FORM' })}
/>packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx:70-81 (and SeriesInstrumentRenderer.tsx:82-93) also resolves normally when validation fails, so returning the promise alone would not stop libui from resetting:
if (!validationResult.success) {
console.error(validationResult.issues);
addNotification({ ... type: 'error' });
return;
}Reproduce
- Start a session in
apps/weband open the Enhanced Demographics Questionnaire. - Fill it in, then make the save fail, e.g. stop the API (or go offline) before clicking Submit.
- Click Submit.
Actual: the "HTTP Request Failed" notification appears and the form stays on screen with every field empty. Verified in a unit test: with an onSubmit that rejects, the field is "" immediately after submit while the form is still rendered.
Expected: the answers stay in place when the save fails, so the subject can retry; the form is cleared only after a successful save.
Tests
packages/react-core/src/components/FormContent/__tests__/FormContent.test.tsx:it('should keep the answers of a resettable form when the submission fails, so the subject can retry without re-entering them').packages/react-core/src/components/InstrumentRenderer/__tests__/ScalarInstrumentRenderer.test.tsx:it('should keep the answers of a resettable form when the submission fails validation').- e2e,
testing/src/specs/form-reset.spec.ts: routePOST /v1/instrument-recordsto a 500 withpage.route, submit the completed Enhanced Demographics Questionnaire, and asserthouseholdSizestill has its value.
Suggested fix
Make FormContent return the promise (onSubmit={(data) => onSubmit({ data, kind: 'FORM' })}), so libui skips reset() when the save rejects. Have the renderers' submit handlers return { success: false, errorMessage } instead of a bare return when validateSubmission fails, so libui shows the error and does not reset. That needs FormContentProps['onSubmit'] widened to libui's FormSubmitResult | void return type.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). The cited lines have not moved, nothing has touchedFormContent/orInstrumentRenderer/since this was filed, and no open PR touches either.Two gaps in the suggested fix
FormContentProps['onSubmit']failstsc. Each renderer passes the samecreateSubmitHandler(instrument)to every content kind (ScalarInstrumentRenderer.tsx:129,:140,:144;SeriesInstrumentRenderer.tsx:215,:222).InteractiveContent.tsx:263andFileInstrumentContent/types.ts:55typeonSubmitas returningPromisable<void>, and a handler that can resolve to{ success: false, errorMessage }is not assignable to that (TS2322). Widen all three to one type. Neither of the other two reads the resolved value, so their behaviour does not change. libui 6.16.1 does not exportFormSubmitResult, so derive it from the exportedFormPropsinstead of copying it.handleSubmitwrapsonSubmitintry/finallywith nocatch(src/components/Form/Form.tsx:149-165), and the form calls it asvoid handleSubmit(event)(:197). A failed save therefore stays an unhandled rejection, raised from libui now instead of fromFormContent. Both hosts' axios interceptors already show a notification for it. Do not turn it into{ success: false }insidecreateSubmitHandler:FileInstrumentContentshowsFAILEDonly when that promise rejects (FileInstrumentContent/store.ts:77-85).Tests
onSubmithas to capture it.apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/capture-unhandled-rejection.tsis the pattern. react-core has no equivalent.__testRejectingSchema(z.never()). libui parses with the same schema first, so it never callsonSubmit. Use aresetButton: truebundle whose schema libui accepts butvalidateSubmissionrejects on the re-parse, e.g.z.object({ answer: z.string().transform(Number) }). Add the same case toSeriesInstrumentRenderer.test.tsx, which has the same barereturn.isTransientError(apps/web/src/services/axios.ts:46) treats a missing response as transient. A 500 from the API gives "HTTP Request Failed". For the e2e, build thepage.route500 the wayerror-page.spec.ts:13does. After assertinghouseholdSize, callpage.unroute, submit again and assertsummaryHeading, so the test also proves the retry.Related
FormContentreturns the promise, that issue's in-flight guard must hand a repeat submit the pending save, notundefined. Otherwise libui runsreset()mid-save and empties the form again.resetButton" row is this bug. After this fix that row behaves like the plain FORM row. The rest of Session expires after one hour without warning, and in-progress instrument data is lost #1535 is unaffected.Triage note written with Claude Code. Code references point at
main@d86740e.