RangeError on every backspace in a document longer than ~125,000 characters
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 exceededChrome 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#offsetToUCS2Offsetencodes the entire prefix only to read.lengthoff it.Composition#translateUTF16PositionFromOffsetcalls it for every collapsed-selectiondeleteInDirectionandexpandSelectionInDirection— the chain above, and that is every backspace.UTF16String.fromCodepoints, viautf16StringDifferencesinsummarizeStringChange, whichMutationObserverruns 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.
Source: basecamp/trix