Skip to content

Bug: Answers carry between items of a skipProgress series #1517

Description

@david-roper

Answers carry between items of a skipProgress series

Type: bug — reproduced
Area: packages/react-coreSeriesInstrumentRenderer, useInterpretedInstrument
Impact: latent today; a data-integrity bug as soon as a skipProgress series has two items sharing a field name

Summary

In a series with params.skipProgress: true, the same libui Form instance is reused across items
rather than remounted. libui initialises its values state once and never re-syncs it, so answers
from item N persist into item N+1 and are presented to the subject as their own answers.

No instrument in the library triggers this today, so nothing is currently stored incorrectly. It
becomes live the first time a skipProgress series has two items that share a field name — which is
a natural thing to author, since repeated measures are much of what a series is for.

Reproduction

Confirmed by building a throwaway series of DNP_GENERAL_CONSENT_FORM twice with
skipProgress: true, seeding it into a demo instance, and driving it through the real UI. The probe
has since been deleted; anyone can recreate it in a few minutes.

Observed, with skipProgress: true:

item1 checked BEFORE answering: false     ← fresh form, as expected
item1 checked AFTER  answering: true
item2 checked ON ARRIVAL     : true       ← BUG: arrives pre-answered

Item 2 is a fresh administration of the same instrument for the same subject, and the consent radio
is already selected. A subject can submit it without reading or touching it.

Flipping only skipProgress to false on the identical probe isolates the cause:

skipProgress Interstitial between items Item 2 on arrival
false yes false — fresh
true no truebled

The interstitial ("Series Instrument in Progress") is what unmounts the form in the normal path.
skipProgress removes it, and with it the only thing that was resetting the form.

Mechanism

Four conditions hold simultaneously:

1. The form stays mounted between items. handleSubmit only leaves the in-progress branch when
skipProgress is false:

// SeriesInstrumentRenderer.tsx
setCurrentItemIndex(currentItemIndex + 1);
if (!skipProgress) {
  setIsInstrumentInProgress(false);   // this is what unmounts FormContent between items
}

2. Nothing else forces a remount. useInterpretedInstrument does not return to LOADING when
its bundle changes — the effect only starts interpretation, leaving state at the previous DONE:

useEffect(() => {
  interpreter.interpret(bundle, options).then(setInstrument).catch(/* ... */);
}, [bundle]);          // no setState({ status: 'LOADING' })

So match(scalarState) never takes the <Spinner /> branch, and <FormContent> renders at the same
position with no key — React reconciles it as the same instance.

3. libui's Form initialises values exactly once. It is a useState initialiser, and the
component's only two effects handle subscribe and clearing errors on a language change; neither
re-syncs values when content or initialValues change:

const [values, setValues] = useState(initialValues ? getInitialValues(initialValues) : {});

4. The one thing that would clear it is suppressed. libui empties the form only in reset(),
which runs after a successful submit, and only when preventResetValuesOnReset is absent:

const reset = () => {
  setRootErrors([]);
  setErrors({});
  if (!preventResetValuesOnReset) { setValues({}); }
};

Why nothing is broken today

The only instrument setting skipProgress: true is DNP_HAPPINESS_QUESTIONNAIRE_WITH_CONSENT,
whose two items have disjoint field names:

Item Fields
DNP_GENERAL_CONSENT_FORM consent
DNP_HAPPINESS_QUESTIONNAIRE personalLifeSatisfaction, professionalLifeSatisfaction, isSatisfiedOverall, reasonNotSatisfied, causesOfDissatisfaction

A leftover { consent: true } does carry into item 2's values, but no rendered field reads it and
item 2's Zod object strips it on submit. The bug is real and reproducible; its blast radius is
currently zero.

Note on the resetButton change

FormContent now passes preventResetValuesOnReset={!instrument.resetButton} rather than passing it
unconditionally. That narrows this issue's surface without addressing it:

  • An instrument declaring resetButton: true is incidentally immune — condition 4 no longer
    holds, so reset() empties the form after each submit.
  • Every other instrument is exposed exactly as before.

That immunity is a side effect and should not be relied on as a fix.

Suggested direction

Not prescriptive; whoever picks this up should weigh these:

  1. Remount the form per item. Give <FormContent> a key derived from the item (the scalar
    instrument's id, or currentItemIndex) in SeriesInstrumentRenderer. Smallest change, and it
    makes "each item is a fresh form" structural rather than a side effect of the interstitial.
  2. Return to LOADING when the bundle changes in useInterpretedInstrument. Fixes this by
    unmounting the form, and arguably corrects the hook on its own merits — reporting DONE with the
    previous instrument while a new one is interpreting is misleading to any caller. Wider blast
    radius: every consumer would newly see a loading state.
  3. Clear values in libui when content changes. Upstream, affecting every Form consumer across
    all DNP apps. Most invasive.

Option 1 is the most contained. Option 2 addresses a hook-level inaccuracy that may deserve fixing
regardless, and would want its own consideration.

Testing

packages/react-core/src/components/InstrumentRenderer/ has no __tests__ directory — there are
no unit tests for SeriesInstrumentRenderer at all, and the package contributes no vitest project
(no vitest.config.ts, so pnpm test skips it). skipProgress appears nowhere in testing/.

So a fix needs new coverage rather than an existing suite to lean on: either the first vitest project
for react-core (.agents/docs/playbooks/add-vitest-project.md), or a Playwright spec driving a
two-item skipProgress series whose items share a field name — essentially the probe described
above, kept rather than deleted.

Activity

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

Metadata

Metadata

Assignees

Labels

BugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions