Answers carry between items of a skipProgress series
Type: bug — reproduced
Area: packages/react-core — SeriesInstrumentRenderer, 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 |
true — bled |
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:
- 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.
- 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.
- 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.
Answers carry between items of a
skipProgressseriesType: bug — reproduced
Area:
packages/react-core—SeriesInstrumentRenderer,useInterpretedInstrumentImpact: latent today; a data-integrity bug as soon as a
skipProgressseries has two items sharing a field nameSummary
In a series with
params.skipProgress: true, the same libuiForminstance is reused across itemsrather than remounted. libui initialises its
valuesstate once and never re-syncs it, so answersfrom 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
skipProgressseries has two items that share a field name — which isa 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_FORMtwice withskipProgress: true, seeding it into a demo instance, and driving it through the real UI. The probehas since been deleted; anyone can recreate it in a few minutes.
Observed, with
skipProgress: true: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
skipProgresstofalseon the identical probe isolates the cause:skipProgressfalsefalse— freshtruetrue— bledThe interstitial ("Series Instrument in Progress") is what unmounts the form in the normal path.
skipProgressremoves it, and with it the only thing that was resetting the form.Mechanism
Four conditions hold simultaneously:
1. The form stays mounted between items.
handleSubmitonly leaves the in-progress branch whenskipProgressis false:2. Nothing else forces a remount.
useInterpretedInstrumentdoes not return toLOADINGwhenits bundle changes — the effect only starts interpretation, leaving
stateat the previousDONE:So
match(scalarState)never takes the<Spinner />branch, and<FormContent>renders at the sameposition with no
key— React reconciles it as the same instance.3. libui's
Forminitialisesvaluesexactly once. It is auseStateinitialiser, and thecomponent's only two effects handle
subscribeand clearing errors on a language change; neitherre-syncs values when
contentorinitialValueschange: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
preventResetValuesOnResetis absent:Why nothing is broken today
The only instrument setting
skipProgress: trueisDNP_HAPPINESS_QUESTIONNAIRE_WITH_CONSENT,whose two items have disjoint field names:
DNP_GENERAL_CONSENT_FORMconsentDNP_HAPPINESS_QUESTIONNAIREpersonalLifeSatisfaction,professionalLifeSatisfaction,isSatisfiedOverall,reasonNotSatisfied,causesOfDissatisfactionA leftover
{ consent: true }does carry into item 2'svalues, but no rendered field reads it anditem 2's Zod object strips it on submit. The bug is real and reproducible; its blast radius is
currently zero.
Note on the
resetButtonchangeFormContentnow passespreventResetValuesOnReset={!instrument.resetButton}rather than passing itunconditionally. That narrows this issue's surface without addressing it:
resetButton: trueis incidentally immune — condition 4 no longerholds, so
reset()empties the form after each submit.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:
<FormContent>akeyderived from the item (the scalarinstrument's id, or
currentItemIndex) inSeriesInstrumentRenderer. Smallest change, and itmakes "each item is a fresh form" structural rather than a side effect of the interstitial.
LOADINGwhen the bundle changes inuseInterpretedInstrument. Fixes this byunmounting the form, and arguably corrects the hook on its own merits — reporting
DONEwith theprevious instrument while a new one is interpreting is misleading to any caller. Wider blast
radius: every consumer would newly see a loading state.
contentchanges. Upstream, affecting everyFormconsumer acrossall 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 areno unit tests for
SeriesInstrumentRendererat all, and the package contributes no vitest project(no
vitest.config.ts, sopnpm testskips it).skipProgressappears nowhere intesting/.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 atwo-item
skipProgressseries whose items share a field name — essentially the probe describedabove, kept rather than deleted.