Repository navigation
Description
A series' terminate predicate receives the item's data in serialized form, not as the typed data its signature promises. SeriesInstrumentRenderer passes it parsedData, the output of JSON.parse(JSON.stringify(data, replacer)). libjs's replacer encodes every Date and Set as a tagged object, { __deserializedType: 'Date' | 'Set', __isSerializedType: true, value }. SeriesInstrument.Params['terminate'] types its argument as TData, and the instrument guidelines tell authors to annotate it with the terminating item's data shape. So a predicate written against a date field or a set field fails. If it throws, for example data.visitDate.getFullYear() or data.symptoms.has('none'), the throw escapes createSubmitHandler before onSubmit runs, and FormContent discards it as an unhandled rejection. The item is never saved, the series never advances, and no error is shown, so the subject is stuck on a form whose Submit button does nothing. If it does not throw, for example data.symptoms.size === 0 or data.visitDate < cutoff, it quietly returns the wrong answer and the series terminates, or fails to, against the author's rule.
Where
packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx:80-97:
const parsedData = JSON.parse(JSON.stringify(data, replacer)) as Json;
// ...
const shouldTerminate = params.terminate?.(parsedData, { itemIndex: currentItemIndex, itemName }) ?? false;Reproduce
- Author a series whose first item is a form with a
datefield (z.date()), andparams.terminate: (data: { visitDate: Date }) => data.visitDate.getFullYear() < 2000. - Administer it, enter a date, and click Submit.
Actual: terminate receives { visitDate: { __deserializedType: 'Date', __isSerializedType: true, value: '…' } } and throws TypeError: data.visitDate.getFullYear is not a function. onSubmit is never called and the form stays on screen with no error. Verified in a unit test against a hand-written series bundle.
Expected: terminate receives the item's data as the validated TData, with real Date and Set values, and the series terminates or continues accordingly.
Tests
packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx: it('should pass terminate the item data with real Date and Set values, so a predicate on a date field can end the series'). e2e, testing/src/specs/series-instrument.spec.ts: a series whose terminate reads a date field ends after that item.
Suggested fix
Call params.terminate with the original data (the validated form output, or the interactive payload) rather than parsedData. Alternatively, pass it JSON.parse(JSON.stringify(parsedData), reviver), the same revival validateSubmission applies. Keep parsedData for onSubmit.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e).SeriesInstrumentRenderer.tsx:80-97is unchanged. No commit or open PR has touchedInstrumentRenderer/since this was filed.Fix
reviveralternative. For a FORM item,datais already the output of libui'svalidationSchema.safeParseAsync(values)(libui 6.16.1,src/components/Form/Form.tsx:134, passed toonSubmitat:152), so itsDateandSetvalues are real. For an INTERACTIVE item it is the$Json-parsed payload (InteractiveContent.tsx:79), which holds neither type and comes through thereplacerunchanged. Sodatais correct for both kinds. RenameparsedDatatoserializedData, the nameScalarInstrumentRenderer.tsx:68uses.data(ScalarInstrumentRenderer.tsx:89), nothing underapps/calls a series'terminate, and web and gateway both render series through this component.data.visitDate.getFullYear()on an optional date the subject left empty. Wrap the call:console.errorthe error, show an error notification inen,esandfr, and return without callingonSubmit, as the validation branch at:82-93does. Do not treat a throw asfalse. In a consent gate likeDNP_HAPPINESS_QUESTIONNAIRE_WITH_CONSENT, that would administer the items the predicate exists to skip.Tests
terminatetests (SeriesInstrumentRenderer.test.tsx:361-390) submit only strings, which survive thereplacer. For the new test, put aDateand aSetin the item'sinitialValues. libui'sgetInitialValuespasses them through, sofireEvent.submitneeds no date picker. Assert thatcompletefollows the predicate and thatonSubmitstill receives the tagged form. Add a second test whereterminatethrows: a notification appears andonSubmitis not called.terminate(DNP_HAPPINESS_QUESTIONNAIRE_WITH_CONSENT/index.ts:48) reads a boolean, andPOST /instruments/seriestakes noparams($CreateSeriesInstrumentData,packages/schemas/src/instrument/instrument.base.ts:276-291).ApiClientmethod that posts{ bundle }to/instruments. Onlyauthorization.spec.ts:402posts there today. Interactive instruments in a series ignore their language-select, language-lock, toggle and fullscreen options #1686 needs the same helper, and the comment there sets out the ways to build the bundle.datefield, then a two-item SERIES whoseterminatereads that date. PutuniqueIdin the form'sinternal.name, since its id is a hash of name and edition, and in the series title the spec clicks. Load zod through the__importpreamble, as compiled bundles do (packages/instrument-bundler/src/bundle.ts:12-18). The API validates uploads against$AnyInstrument(instruments.service.ts:102), so the bundles need every required field.isolatedGroupManager()after the uploads, becauseapi.createGroupgrants only the instruments that exist when it runs (api-client.ts:70). Then build page objects withnew StartSessionPage(page)and their owngoto, asdatahub.spec.ts:190does.getPageModelwould re-authenticate asactingRole, whose cached group predates the uploads.1999-06-01and expectseriesCompletionHeadingafter the first item. libui'sDateFieldstores UTC midnight (DateField.tsx:27), so a January 1 date can land in the previous year west of UTC.Landing
#1686 and #1813 change other hunks of this file, so order does not matter. #1673 and #1668 rewrite this
createSubmitHandler. With #1673, the newcatchshould return{ success: false, errorMessage }, as the validation branch will, so libui shows the error and does not empty aresetButtonform. #1668's in-flight guard must not be held when thecatchreturns, so set it after validation andterminate, or clear it in thecatch. Otherwise a throwing predicate locks Submit for good. #1723 is the samereplacer/revivermismatch on the API side.Triage note written with Claude Code. Code references point at
main@d86740e.