RangeError on every backspace in a document longer than ~125,000 characters · Issue #1359 · basecamp/trix · GitHub
Skip to content

RangeError on every backspace in a document longer than ~125,000 characters #1359

Open

Description

@KnoxMajor

The bug

Past roughly 125,000 characters, every keystroke that deletes or extends the selection by one character throws:

Uncaught RangeError: Maximum call stack size exceeded

thrown out of ucs2encode, reached by Composition#deleteInDirection → getExpandedRangeInDirection → translateUTF16PositionFromOffset → UTF16String#offsetToUCS2Offset.

Backspace, delete and shift+arrow stop working, permanently, for as long as the document stays that long. Typing still inserts, so the document keeps growing and the editor can never be shortened again from the keyboard. The content is intact — it is only uneditable.

Repro

<trix-editor></trix-editor>
<script>
  const editor = document.querySelector("trix-editor").editor
  editor.insertString("a".repeat(200000))
  editor.setSelectedRange(200000)
</script>

Put the cursor at the end and press backspace. Or, without the keyboard:

document.querySelector("trix-editor").editor.getDocument().toUTF16String().offsetToUCS2Offset(200000)
// RangeError: Maximum call stack size exceeded

Chrome 138.0.7204.101 / macOS 15.4.1, Trix 2.1.19. Anything on V8 should reproduce; engines with a lower argument cap should reproduce sooner.

Why

ucs2encode spreads the whole array into one call:

ucs2encode = (array) => String.fromCodePoint(...Array.from(array || []))

How many arguments a call can spread is capped by the engine and by how much stack is left at the call. On Node 24 / V8 the ceiling measures at 124,479 arguments; in a browser, under a deeper stack, it is lower and not fixed. Past it the call throws instead of returning a string.

Two paths reach ucs2encode with an array as long as the whole document:

  • UTF16String#offsetToUCS2Offset encodes the entire prefix only to read .length off it. Composition#translateUTF16PositionFromOffset calls it for every collapsed-selection
    deleteInDirection and expandSelectionInDirection — the chain above, and that is every backspace.
  • UTF16String.fromCodepoints, via utf16StringDifferences in summarizeStringChange, which MutationObserver runs on every text change inside a block. One long paragraph is
    enough.

Below the cliff the same code is quietly expensive: offsetToUCS2Offset allocates a fresh copy of everything before the cursor on each of those keystrokes, so a 100,000-character document copies 100,000 characters per backspace to compute one number.

This is not a regression. The fallback branch of ucs2encode — used where String.fromCodePoint is missing — builds the string with forEach and join and has no such limit; only the fast path does. It has spread since the 2021 decaffeination and, before that, splatted through Function.prototype.apply, which caps the same way.

Fix

Encoding in chunks fixes both call paths at the single shared function, and counting code units instead of encoding them takes the allocation out of the keystroke path.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions