Repository navigation
Description
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
- Start a session in
apps/weband open any form instrument (e.g. Happiness Questionnaire). On a slow connection (or in dev, whereconfig.dev.networkLatencydelays every response), fill it in. - Double-click Submit (or press Enter twice in a field).
- 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')andit('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
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). None ofScalarInstrumentRenderer.tsx,SeriesInstrumentRenderer.tsx,FormContent.tsxorInteractiveContent.tsxhas changed since the issue was filed, and no open PR touches them.The suggested fix needs four changes
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.isSubmittingonly renders an overlay that blocks clicks (:200), and it is raised after anawaiton validation (:134,:150). Keyboard input goes straight past it. Submit is disabled only undersuspendWhileSubmitting(:189,:257), whichFormContentdoes 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.A dropped call has to return the pending save, not
undefined. OnceFormContentreturns the promise, libui awaits the second call too. A barereturnresolves at once, so libui runsreset()(:162) andsetIsSubmitting(false)(:164) while the first save is still running. That empties aresetButtonform 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.In the series, "cleared only when
onSubmitrejects" blocks every item after the first. Key the slot on the item: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.Set the slot only after
validateSubmissionpasses. Validation andterminateare synchronous, so nothing interleaves between the check and the set. The validation-failure return (a barereturntoday,{ 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')andit('should accept a resubmission once the save rejects, so a failed save can be retried'). Drive the second test with the FILE instrument.FileInstrumentContentcatches the rejection and re-enables Submit. A rejected FORM save instead escapes libui'svoid handleSubmit(event)as an unhandled rejection, which fails the vitest run.SeriesInstrumentRenderer.test.tsx:191already fails if the guard blocks the second item.instrument-completion.spec.ts: holdPOST /v1/instrument-recordswithpage.route(aserror-page.spec.ts:13does) and click Submit. Then callsubmitButton.press('Enter'), release the request, and assert onePOSTand one row.presshas no actionability checks, so it gets past the overlay where a secondclick()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
FormContentreturns the promise, and both changeFormContent.tsx:61and bothcreateSubmitHandlers. #1680 changeshandleDoneEvent, the INTERACTIVE caller of the same handler, so the three fit in one PR with one commit each. Land this before #1750, which addsitemIndexto the sameonSubmit({...})call atSeriesInstrumentRenderer.tsx:99-106. #1825 appends tests toinstrument-completion.spec.ts, so expect a trivial rebase. #1713 (a FILE retry creates a second record) and #1641 (the Breakout Task callsdonetwice) 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.