Skip to content

Forms with a reset button are emptied on submit even when the save fails #1673

Open

Description

@joshunrau

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

  1. Start a session in apps/web and open the Enhanced Demographics Questionnaire.
  2. Fill it in, then make the save fail, e.g. stop the API (or go offline) before clicking Submit.
  3. 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: route POST /v1/instrument-records to a 500 with page.route, submit the completed Enhanced Demographics Questionnaire, and assert householdSize still 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

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: SharedCross-tier packages (schemas, react-core, etc.) not covered by another area
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). The cited lines have not moved, nothing has touched FormContent/ or InstrumentRenderer/ since this was filed, and no open PR touches either.

    Two gaps in the suggested fix

    • Widening only FormContentProps['onSubmit'] fails tsc. Each renderer passes the same createSubmitHandler(instrument) to every content kind (ScalarInstrumentRenderer.tsx:129, :140, :144; SeriesInstrumentRenderer.tsx:215, :222). InteractiveContent.tsx:263 and FileInstrumentContent/types.ts:55 type onSubmit as returning Promisable<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 export FormSubmitResult, so derive it from the exported FormProps instead of copying it.
    • Keep the host's rejection a rejection. In libui 6.16.1, handleSubmit wraps onSubmit in try/finally with no catch (src/components/Form/Form.tsx:149-165), and the form calls it as void handleSubmit(event) (:197). A failed save therefore stays an unhandled rejection, raised from libui now instead of from FormContent. Both hosts' axios interceptors already show a notification for it. Do not turn it into { success: false } inside createSubmitHandler: FileInstrumentContent shows FAILED only when that promise rejects (FileInstrumentContent/store.ts:77-85).

    Tests

    • Vitest fails the run on an unhandled rejection, so every unit test with a rejecting onSubmit has to capture it. apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/capture-unhandled-rejection.ts is the pattern. react-core has no equivalent.
    • The FORM validation test cannot reuse __testRejectingSchema (z.never()). libui parses with the same schema first, so it never calls onSubmit. Use a resetButton: true bundle whose schema libui accepts but validateSubmission rejects on the re-parse, e.g. z.object({ answer: z.string().transform(Number) }). Add the same case to SeriesInstrumentRenderer.test.tsx, which has the same bare return.
    • The reproduce step's toast is wrong. With the API stopped or the browser offline, web shows the "Connection Problem" warning, because 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 the page.route 500 the way error-page.spec.ts:13 does. After asserting householdSize, call page.unroute, submit again and assert summaryHeading, so the test also proves the retry.

    Related

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: SharedCross-tier packages (schemas, react-core, etc.) not covered by another areaBugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions