Skip to content

Series terminate predicate receives serialized Date and Set values instead of the item's data #1683

Open

Description

@joshunrau

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

  1. Author a series whose first item is a form with a date field (z.date()), and params.terminate: (data: { visitDate: Date }) => data.visitDate.getFullYear() < 2000.
  2. 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

  1. added
    BugType: existing behavior is wrong
    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). SeriesInstrumentRenderer.tsx:80-97 is unchanged. No commit or open PR has touched InstrumentRenderer/ since this was filed.

    Fix

    • Take the first suggestion and drop the reviver alternative. For a FORM item, data is already the output of libui's validationSchema.safeParseAsync(values) (libui 6.16.1, src/components/Form/Form.tsx:134, passed to onSubmit at :152), so its Date and Set values are real. For an INTERACTIVE item it is the $Json-parsed payload (InteractiveContent.tsx:79), which holds neither type and comes through the replacer unchanged. So data is correct for both kinds. Rename parsedData to serializedData, the name ScalarInstrumentRenderer.tsx:68 uses.
    • This is the only call site. The scalar renderer already gives its summary the raw data (ScalarInstrumentRenderer.tsx:89), nothing under apps/ calls a series' terminate, and web and gateway both render series through this component.
    • A predicate that throws for any other reason still leaves Submit dead, e.g. data.visitDate.getFullYear() on an optional date the subject left empty. Wrap the call: console.error the error, show an error notification in en, es and fr, and return without calling onSubmit, as the validation branch at :82-93 does. Do not treat a throw as false. In a consent gate like DNP_HAPPINESS_QUESTIONNAIRE_WITH_CONSENT, that would administer the items the predicate exists to skip.

    Tests

    • Unit: the existing terminate tests (SeriesInstrumentRenderer.test.tsx:361-390) submit only strings, which survive the replacer. For the new test, put a Date and a Set in the item's initialValues. libui's getInitialValues passes them through, so fireEvent.submit needs no date picker. Assert that complete follows the predicate and that onSubmit still receives the tagged form. Add a second test where terminate throws: a notification appears and onSubmit is not called.
    • e2e: no seeded instrument can drive this. The only built-in terminate (DNP_HAPPINESS_QUESTIONNAIRE_WITH_CONSENT/index.ts:48) reads a boolean, and POST /instruments/series takes no params ($CreateSeriesInstrumentData, packages/schemas/src/instrument/instrument.base.ts:276-291).
      1. Add an ApiClient method that posts { bundle } to /instruments. Only authorization.spec.ts:402 posts 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.
      2. Upload a hand-written FORM with a date field, then a two-item SERIES whose terminate reads that date. Put uniqueId in the form's internal.name, since its id is a hash of name and edition, and in the series title the spec clicks. Load zod through the __import preamble, 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.
      3. Call isolatedGroupManager() after the uploads, because api.createGroup grants only the instruments that exist when it runs (api-client.ts:70). Then build page objects with new StartSessionPage(page) and their own goto, as datahub.spec.ts:190 does. getPageModel would re-authenticate as actingRole, whose cached group predates the uploads.
      4. Enter a mid-year date such as 1999-06-01 and expect seriesCompletionHeading after the first item. libui's DateField stores 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 new catch should return { success: false, errorMessage }, as the validation branch will, so libui shows the error and does not empty a resetButton form. #1668's in-flight guard must not be held when the catch returns, so set it after validation and terminate, or clear it in the catch. Otherwise a throwing predicate locks Submit for good. #1723 is the same replacer/reviver mismatch on the API side.

    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: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions