Repository navigation
Description
Problem
When a subject completes the last item of a series instrument, the browser console shows an error:
TypeError: Cannot read properties of undefined (reading 'kind')
The UI is correct (the "Thank You" screen shows), but each completed series logs this error. The error is noise and can hide real errors.
Cause
- After the last item is submitted, the submit handler increments the item index to
items.length:
OpenDataCapture/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx
Line 115 in 432dec8
setCurrentItemIndex(currentItemIndex + 1); - The scalar bundle is then
undefined, and the renderer passes an empty string to the hook:
OpenDataCapture/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx
Lines 65 to 69 in 432dec8
const scalarBundle = target.items[currentItemIndex]?.bundle; const scalarId = target.items[currentItemIndex]?.id; const rootState = useInterpretedInstrument<UnilingualSeriesInstrument>(target.bundle); const scalarState = useInterpretedInstrument<AnyUnilingualScalarInstrument>(scalarBundle ?? ''); - The hook interprets every bundle, including
'', and logs every rejection withconsole.error:
useEffect(() => { // A bundle that resolves after the hook has moved on to another one would otherwise be reported // as the outcome of that other bundle, or leave this stuck reporting LOADING for it forever. let isCurrent = true; interpreter .interpret(bundle, options) .then((instrument) => { if (isCurrent) { setInterpretation({ bundle, instrument, status: 'DONE' }); } }) .catch((error: unknown) => { console.error(error); if (isCurrent) { setInterpretation({ bundle, error: error instanceof Error ? error : new Error('Unexpected Non-Error Thrown', { cause: error }), status: 'ERROR' }); } }); return () => { isCurrent = false; }; }, [bundle]); evaluateInstrument('')runsnew Function('return ')(), which returnsundefined:
return await new Function(`return ${bundle}`)(); - Without
validate, thetryblock does not throw. Theinstrument.kindaccess is outside thetry, so it throws a rawTypeError:
OpenDataCapture/packages/instrument-interpreter/src/index.ts
Lines 18 to 30 in 432dec8
try { value = await evaluateInstrument(bundle); instrument = options?.validate ? await $AnyInstrument.parseAsync(value) : (value as AnyInstrument); } catch (error) { if (value) { console.error({ message: 'Validation Error', value }); } throw new Error(`Failed to evaluate instrument bundle`, { cause: error }); } if (instrument.kind !== 'INTERACTIVE') {
A series that ends through terminate does not have this problem, because the handler returns before it increments the index.
Steps to reproduce
- Open a series instrument (for example, in the playground or as a remote assignment).
- Complete all items.
- Open the browser console.
Expected: No error.
Actual: One TypeError: Cannot read properties of undefined (reading 'kind') is logged.
Suggested fix
- In
useInterpretedInstrument, do not interpret an empty bundle (or let the hook acceptnulland skip the effect). Or, inSeriesInstrumentRenderer, do not request a scalar instrument whencurrentItemIndex >= target.items.length. - Also, in
InstrumentInterpreter.interpret, reject a non-object result inside thetryblock, so that a bad bundle gives the normal "Failed to evaluate instrument bundle" error and not a rawTypeError.
cc @joshunrau (last change to these files)
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). None of the four cited files has changed since 432dec8, so the issue's permalinks still matchmain.One correction to the repro: the playground cannot administer a series. Its preview always renders
ScalarInstrumentRendererwithvalidate: true(PreviewApp.tsx#L66-L67). Use the demo's "Happiness Questionnaire (Repeated)" series inapps/web, or a gateway assignment.Fix
Take the
nulloption. Type the hook's first parameter asbundle: null | string, return early from its effect fornull, and passscalarBundle ?? nullatSeriesInstrumentRenderer.tsx#L69, the only caller that passes''. The hook then reportsLOADING, because no interpretation carries anullbundle. Skipping''inside the hook would keep the sentinel. The renderer-side option comes down to the samenull, because the hook cannot be called conditionally.useInstrument(id: null | string)inapps/web/src/hooks/useInstrument.ts:10already uses this shape.Leave the interpreter change out of this fix. The hook logs every rejection (
useInterpretedInstrument.ts#L42), so the check would only change the logged message. Once the hook is fixed, nothing reaches it: every stored bundle has passed$AnyInstrument.safeParseAsync(apps/api/src/instruments/instruments.service.ts:102), and the playground validates. If it is added later, for example with #1803, which proposes moving the kind check at lines 30-37 into a shared package, it must rejectnulland primitives too. A bundle that evaluates to1gets past line 30 and throwsCannot create property 'id' on number '1'at line 38.This fix leaves one more effect of the past-the-end index. The last submit commits one render with
indexstill 1, before the effect at lines 124-128 shows "Thank You!". WithoutskipProgressthat render is the interstitial ("Instruments Completed: N/N"). WithskipProgressit is aSpinner. It is cosmetic and can be a follow-up.Tests
packages/react-core/src/hooks/__tests__/useInterpretedInstrument.test.ts:nullreportsLOADINGand never callsInstrumentInterpreter.prototype.interpret. Spy on it as the test at line 110 does.packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx: complete both items as the test at line 215 does, then assert that nointerpretcall got''as its first argument (expect(spy.mock.calls.map(([bundle]) => bundle)).not.toContain('')).not.toHaveBeenCalledWith('')passes onmaintoo: the hook also passesoptions, asundefined, and Vitest only matches argument lists of the same length. Spy on the call, not onconsole.error: the call is recorded before the completion screen renders, and the log only once the promise rejects.testing/src/specs/series-instrument.spec.ts, in theskipProgresstest (line 62): collectpage.on('console')messages of typeerror, and at the end of the test assert that none containsreading 'kind'. The hook catches the rejection, so the error arrives as a console message, not apageerror. Nothing intesting/srccaptures console output yet.#1683 and #1686 also fix this file and name the same two test files, so one PR for all three (one commit each) would work. #1668 and #1673 change the submit handler (lines 77-119). This fix touches only line 69 and the hook, so it does not conflict with them.
Triage note written with Claude Code. Code references point at
main@d86740e.