Skip to content

Fix: Handsontable Numeric Cell Type Registration Race Condition

Note: This is a temporary planning document. Delete after the fix in #2537 is merged.

Problem

The timepoint spreadsheet in Data Extraction crashes with:

Error: You declared cell type "numeric" as a string that is not mapped to a known object.

Sentry: SYRF-WEB-372 -- 83 occurrences, 13 users, ongoing since December 2025.

Root Cause

A race condition in TimepointSpreadsheetComponent between async Handsontable cell type registration and synchronous table initialisation.

Sequence of Events (Current -- Broken)

  1. Angular creates TimepointSpreadsheetComponent
  2. ngOnChanges() fires first (Angular lifecycle order), setting this.columns with type: 'numeric'
  3. ngOnInit() fires, calling await ensureHotBasicsRegistered() -- this yields to the microtask queue
  4. HotTableComponent.ngAfterViewInit fires synchronously, calling this.hotInstance.init() which resolves cell type strings
  5. Crash -- NumericCellType hasn't been registered yet because the async import hasn't resolved

Key Files

File Role
src/services/web/src/app/shared/form-controls/timepoint-spreadsheet/timepoint-spreadsheet.component.ts Component that declares columns with type: 'numeric'
src/services/web/src/app/shared/form-controls/timepoint-spreadsheet/timepoint-spreadsheet.component.html Template containing <hot-table>
src/services/web/src/app/shared/utils/handsontable-loader.ts Lazy registration utility for NumericCellType and CopyPaste plugin

Why It's Timing-Dependent

The bug depends on whether the dynamic import('handsontable/cellTypes') resolves before HotTableComponent.ngAfterViewInit runs. On fast machines with cached modules, the import may resolve quickly enough. On slower machines or first loads, the race is lost and the crash occurs.

Fix Options

Add a ready flag to TimepointSpreadsheetComponent. Await ensureHotBasicsRegistered() in ngOnInit(), then set ready = true. Wrap the <hot-table> in the template with @if (ready).

Pros:

  • Simplest change (3 files, ~10 lines)
  • Preserves lazy-loading intent -- Handsontable stays out of the initial bundle
  • Only affects the component that needs it
  • Brief loading state is imperceptible to users

Cons:

  • Momentary flash before table appears (milliseconds, unlikely to be visible)

Option B: Register cell types eagerly at app startup

Call registerCellType(NumericCellType) in app.config.ts or an APP_INITIALIZER.

Pros:

  • Guarantees registration before any component loads

Cons:

  • Adds Handsontable to the initial bundle (~200KB+), increasing load time for ALL users
  • Defeats the purpose of the lazy loader utility
  • Overkill for a single component

Option C: Route resolver on Data Extraction route

Add a resolver that awaits ensureHotBasicsRegistered() on the review route.

Pros:

  • Registration guaranteed before the component tree mounts

Cons:

  • Couples Handsontable registration to routing config
  • Would need to be added to every route that might use the spreadsheet
  • More complex than necessary

Chosen Approach: Option A

Implementation Plan

  1. timepoint-spreadsheet.component.ts:
  2. Add ready = false property
  3. In ngOnInit(), await ensureHotBasicsRegistered(), then set this.ready = true
  4. Trigger change detection after setting ready (component may use OnPush or need a manual cycle)

  5. timepoint-spreadsheet.component.html:

  6. Wrap the <hot-table> element with @if (ready) { ... }

  7. Tests:

  8. Add/update unit test verifying the table doesn't render before registration completes
  9. Ensure existing tests still pass

  10. Manual verification:

  11. Navigate to a Data Extraction review page with timepoint data
  12. Confirm spreadsheet renders without error
  13. Check Sentry for absence of new SYRF-WEB-372 events after deployment

Sequence of Events (After Fix)

  1. Angular creates TimepointSpreadsheetComponent
  2. ngOnChanges() fires, setting this.columns with type: 'numeric'
  3. ngOnInit() fires, calling await ensureHotBasicsRegistered() -- yields to microtask queue
  4. Template renders with @if (ready) -- ready is false, so <hot-table> is not mounted
  5. ensureHotBasicsRegistered() resolves -- NumericCellType is now registered
  6. ready is set to true, Angular re-renders, <hot-table> mounts
  7. HotTableComponent.ngAfterViewInit fires, getCellType('numeric') succeeds

Acceptance Criteria

  • NumericCellType is guaranteed registered before HotTableComponent.ngAfterViewInit runs
  • No regression in Data Extraction timepoint input
  • Unit test covers the loading guard
  • Sentry issue SYRF-WEB-372 stops receiving new events after deployment