#1359·trix

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

Author: KnoxMajorCreated Sep 11, 2026Updated Sep 11, 2026

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#deleteInDirectiongetExpandedRangeInDirectiontranslateUTF16PositionFromOffsetUTF16String#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

xml
<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:

javascript
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:

javascript
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.