Skip to content

Submitting an instrument again before the first save completes creates a duplicate record #1668

Open

Description

@joshunrau

The instrument renderers save a second record when the same item is submitted again before the first save has finished. ScalarInstrumentRenderer and SeriesInstrumentRenderer only leave the content step after await onSubmit(...) resolves, so the form or iframe stays live for the whole network round trip, and nothing marks a submission as in flight. A form can be submitted again in that window: FormContent hands libui's Form a callback that returns undefined (void onSubmit(...)), so libui's isSubmitting overlay is cleared before the save starts and the Submit button stays enabled. An interactive instrument can do the same by calling done() twice, for example from a double-clicked "Finish" button, because InteractiveContent forwards every done event. In apps/web each call is a POST /v1/instrument-records, and the API has no uniqueness constraint that would stop it, so a double click on Submit leaves the subject with two identical records. In a series, both calls use the same currentItemIndex, so both save item k and the series moves forward only once. Through the gateway, a second PATCH /api/assignments/:id for a series item appends that item's ciphertext again if it lands after the first write. GatewaySynchronizer maps ciphertexts to items by position, so the extra entry shifts every later answer onto the wrong item. Once the series is finished there is one more entry than there are items, and the synchronizer then rejects the whole assignment ("Expected number of submitted series items … to be between 1 and the series instrument content length").

Where

packages/react-core/src/components/FormContent/FormContent.tsx:61:

onSubmit={(data) => void onSubmit({ data, kind: 'FORM' })}

packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx:76-84:

const handleDoneEvent = useCallback(
  (event: CustomEvent<Json>) => {
    void (async function () {
      const data = await $Json.parseAsync(event.detail);
      await onSubmit({ data, kind: 'INTERACTIVE' });
    })();
  },
  [onSubmit]
);

packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx:82-89 (same shape in SeriesInstrumentRenderer.tsx:99-118):

await onSubmit({
  ...result,
  data: JSON.parse(JSON.stringify(data, replacer)),
  instrumentId: target.id
});
setIndex(2);
setData(data);

Reproduce

  1. Start a session in apps/web and open any form instrument (e.g. Happiness Questionnaire). On a slow connection (or in dev, where config.dev.networkLatency delays every response), fill it in.
  2. Double-click Submit (or press Enter twice in a field).
  3. Open the subject's records in the Data Hub.

Actual: two identical records exist for the one administration. The same happens when an interactive instrument dispatches done twice before the first save resolves. Verified in a unit test: with an onSubmit that stays pending, a second form submit and a second done event each produce a second onSubmit call.
Expected: the renderer accepts one submission per item. Any further submit or done event is ignored until that submission fails.

Tests

  • packages/react-core/src/components/InstrumentRenderer/__tests__/ScalarInstrumentRenderer.test.tsx: it('should ignore a second form submit while the first is still saving, so a double click creates one record') and it('should ignore a second done event while the first is still saving, so an instrument calling done twice creates one record').
  • packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx: it('should save an item once when it is submitted twice before the save resolves, so later items keep their positions').
  • e2e, testing/src/specs/instrument-completion.spec.ts: double-click Submit on a form and assert the subject's record table shows one record.

Suggested fix

Track an in-flight submission in each renderer, for example a useRef<boolean> checked and set at the top of createSubmitHandler and cleared only when onSubmit rejects. Then a second submission is dropped whichever content kind sent it. Also have FormContent return the onSubmit promise instead of void-ing it, so libui's isSubmitting overlay covers the form while the save is pending.

Activity

  1. added
    BugType: existing behavior is wrong
    Priority: HighSignificant or frequently hit user impact; target the next release or two
    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). None of ScalarInstrumentRenderer.tsx, SeriesInstrumentRenderer.tsx, FormContent.tsx or InteractiveContent.tsx has changed since the issue was filed, and no open PR touches them.

    The suggested fix needs four changes

    1. The ref is the guard. The returned promise is not. libui 6.16.1's handleSubmit (src/components/Form/Form.tsx:132-166) has no re-entry check. isSubmitting only renders an overlay that blocks clicks (:200), and it is raised after an await on validation (:134, :150). Keyboard input goes straight past it. Submit is disabled only under suspendWhileSubmitting (:189, :257), which FormContent does not pass and which holds every submit for at least 500 ms (:153-155). So if only the promise is returned, Enter on the focused Submit button still saves twice.

    2. A dropped call has to return the pending save, not undefined. Once FormContent returns the promise, libui awaits the second call too. A bare return resolves at once, so libui runs reset() (:162) and setIsSubmitting(false) (:164) while the first save is still running. That empties a resetButton form mid-save (Forms with a reset button are emptied on submit even when the save fails #1673 again) and drops the overlay. Keep the save promise in the ref and hand it back to any repeat call.

    3. In the series, "cleared only when onSubmit rejects" blocks every item after the first. Key the slot on the item:

      const pendingSave = useRef<null | { itemIndex: number; promise: Promise<void> }>(null);
      
      // first line of the handler
      if (pendingSave.current?.itemIndex === currentItemIndex) {
        return pendingSave.current.promise;
      }
      // ...validation and terminate, unchanged...
      const promise = save(); // the existing `await onSubmit(...)` and the state updates after it
      pendingSave.current = { itemIndex: currentItemIndex, promise };
      promise.catch(() => {
        pendingSave.current = null;
      });
      return promise;

      A boolean cleared on success would also let a stale submit through. If item k's form submits after its save resolves but before React renders item k + 1, that handler's closure still has currentItemIndex === k.

    4. Set the slot only after validateSubmission passes. Validation and terminate are synchronous, so nothing interleaves between the check and the set. The validation-failure return (a bare return today, { success: false, errorMessage } after Forms with a reset button are emptied on submit even when the save fails #1673) then never holds the guard.

    The scalar renderer takes the same shape with a plain useRef<Promise<void> | null>. It is never cleared on success, because a successful save moves to the summary (setIndex(2)). For INTERACTIVE items, clearing on rejection gives no retry, because the task has already ended. That is #1535's INTERACTIVE row, and this guard cannot fix it.

    Tests to add

    • ScalarInstrumentRenderer.test.tsx: it('should keep the answers of a resettable form when it is submitted again while saving') and it('should accept a resubmission once the save rejects, so a failed save can be retried'). Drive the second test with the FILE instrument. FileInstrumentContent catches the rejection and re-enables Submit. A rejected FORM save instead escapes libui's void handleSubmit(event) as an unhandled rejection, which fails the vitest run.
    • SeriesInstrumentRenderer.test.tsx:191 already fails if the guard blocks the second item.
    • e2e, instrument-completion.spec.ts: hold POST /v1/instrument-records with page.route (as error-page.spec.ts:13 does) and click Submit. Then call submitButton.press('Enter'), release the request, and assert one POST and one row. press has no actionability checks, so it gets past the overlay where a second click() would wait on it. The test therefore fails if only the promise is returned.

    Order

    Land #1673 first, then this. Rule 2 only applies once FormContent returns the promise, and both change FormContent.tsx:61 and both createSubmitHandlers. #1680 changes handleDoneEvent, the INTERACTIVE caller of the same handler, so the three fit in one PR with one commit each. Land this before #1750, which adds itemIndex to the same onSubmit({...}) call at SeriesInstrumentRenderer.tsx:99-106. #1825 appends tests to instrument-completion.spec.ts, so expect a trivial rebase. #1713 (a FILE retry creates a second record) and #1641 (the Breakout Task calls done twice) still need their own fixes. This guard only stops a second call while the first save is pending.

    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: HighSignificant or frequently hit user impact; target the next release or two

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions