Skip to content

Karaoke highlight crashes mid-edit on null parentNode #294

@maboa

Description

@maboa

Symptom

After editing the transcript (e.g., deleting a word or paragraph), the karaoke word highlight stops updating. The browser console shows:

Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'word.n.parentNode.classList')
    at updateTranscriptVisualState (hyperaudio-lite.js:742)
    at forEach
    at checkStatus (hyperaudio-lite.js:641)
    at checkPlayHead (hyperaudio-lite.js:600)

Subsequent polling never recovers because the rejection happens inside the setTimeout callback that schedules the next poll, so the chain dies silently.

Root cause

hyperaudio-lite caches a wordArr of { n: <element>, m, p } entries built from transcript.querySelectorAll('[data-m]'). Its poll chain (checkPlayHeadcheckStatusupdateTranscriptVisualState) iterates wordArr on a setTimeout schedule. When the editor deletes a span, the corresponding wordArr entry's n is now a detached node — n.parentNode === null — and updateTranscriptVisualState dereferences .classList on it.

The editor already debounces a wordArr refresh (via refreshHyperaudioInstance in registerStrikeThrus) at 150ms after the last edit, but the library's poll can fire inside that window and crash before the refresh runs.

Why fix it here (not upstream)

The race only exists in editor-style consumers that mutate the transcript DOM at runtime. hyperaudio-lite is otherwise designed for read-only playback contexts, where this never happens. Rather than push a defensive change into the library, we should harden the editor's use of it — that keeps the library minimal and removes a library-version dependency.

Proposed fix

Monkeypatch the HyperaudioLite instance right after it's created in hyperaudio() (index.html:737):

const original = hyperaudioInstance.updateTranscriptVisualState.bind(hyperaudioInstance);
hyperaudioInstance.updateTranscriptVisualState = function (currentTime) {
  try {
    return original(currentTime);
  } catch (e) {
    // The cached wordArr referenced a deleted span. Rebuild against the
    // live DOM and retry, so the polling chain doesn't die silently.
    refreshHyperaudioInstance();
    return original(currentTime);
  }
};

This is self-healing: under normal conditions the patch is a single try/catch with no overhead; under the edit-race it rebuilds and recovers without breaking the polling chain.

Acceptance criteria

  • Rapid deletion of words/paragraphs during playback does not produce console errors.
  • Karaoke highlight resumes within one poll cycle after an edit-race.
  • No regression for non-editing flows (load, playback, strikethrough toggle, gap-skip).
  • The patch is applied once per HyperaudioLite instance (notably re-applied on every call of hyperaudio(), since restoreTranscript creates a fresh instance).

Out of scope

A defensive null check in hyperaudio-lite itself (if (!word.n.parentNode) return; inside the forEach at line 740) would be a one-line upstream fix and might still be worth filing — but isn't required for this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions