Skip to content

Completing a series instrument logs a TypeError "Cannot read properties of undefined (reading 'kind')" #1813

Open

Description

@gdevenyi

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

  1. After the last item is submitted, the submit handler increments the item index to items.length:
  2. The scalar bundle is then undefined, and the renderer passes an empty string to the hook:
    const scalarBundle = target.items[currentItemIndex]?.bundle;
    const scalarId = target.items[currentItemIndex]?.id;
    const rootState = useInterpretedInstrument<UnilingualSeriesInstrument>(target.bundle);
    const scalarState = useInterpretedInstrument<AnyUnilingualScalarInstrument>(scalarBundle ?? '');
  3. The hook interprets every bundle, including '', and logs every rejection with console.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]);
  4. evaluateInstrument('') runs new Function('return ')(), which returns undefined:
    return await new Function(`return ${bundle}`)();
  5. Without validate, the try block does not throw. The instrument.kind access is outside the try, so it throws a raw TypeError:
    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

  1. Open a series instrument (for example, in the playground or as a remote assignment).
  2. Complete all items.
  3. 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 accept null and skip the effect). Or, in SeriesInstrumentRenderer, do not request a scalar instrument when currentItemIndex >= target.items.length.
  • Also, in InstrumentInterpreter.interpret, reject a non-object result inside the try block, so that a bad bundle gives the normal "Failed to evaluate instrument bundle" error and not a raw TypeError.

cc @joshunrau (last change to these files)

Activity

  1. joshunrau commented on Oct 26, 2026

    @joshunrau
    Collaborator

    Still reproduces on main (d86740e). None of the four cited files has changed since 432dec8, so the issue's permalinks still match main.

    One correction to the repro: the playground cannot administer a series. Its preview always renders ScalarInstrumentRenderer with validate: true (PreviewApp.tsx#L66-L67). Use the demo's "Happiness Questionnaire (Repeated)" series in apps/web, or a gateway assignment.

    Fix

    Take the null option. Type the hook's first parameter as bundle: null | string, return early from its effect for null, and pass scalarBundle ?? null at SeriesInstrumentRenderer.tsx#L69, the only caller that passes ''. The hook then reports LOADING, because no interpretation carries a null bundle. Skipping '' inside the hook would keep the sentinel. The renderer-side option comes down to the same null, because the hook cannot be called conditionally. useInstrument(id: null | string) in apps/web/src/hooks/useInstrument.ts:10 already 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 reject null and primitives too. A bundle that evaluates to 1 gets past line 30 and throws Cannot 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 index still 1, before the effect at lines 124-128 shows "Thank You!". Without skipProgress that render is the interstitial ("Instruments Completed: N/N"). With skipProgress it is a Spinner. It is cosmetic and can be a follow-up.

    Tests

    • packages/react-core/src/hooks/__tests__/useInterpretedInstrument.test.ts: null reports LOADING and never calls InstrumentInterpreter.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 no interpret call got '' as its first argument (expect(spy.mock.calls.map(([bundle]) => bundle)).not.toContain('')). not.toHaveBeenCalledWith('') passes on main too: the hook also passes options, as undefined, and Vitest only matches argument lists of the same length. Spy on the call, not on console.error: the call is recorded before the completion screen renders, and the log only once the promise rejects.
    • e2e, testing/src/specs/series-instrument.spec.ts, in the skipProgress test (line 62): collect page.on('console') messages of type error, and at the end of the test assert that none contains reading 'kind'. The hook catches the rejection, so the error arrives as a console message, not a pageerror. Nothing in testing/src captures 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.

  2. added
    BugType: existing behavior is wrong
    Priority: LowNice to have, cosmetic or speculative
    Difficulty: LowIsolated change, about 2 hours or less
    Area: SharedCross-tier packages (schemas, react-core, etc.) not covered by another area
    on Oct 26, 2026
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: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions