RangeError on every backspace in a document longer than ~125,000 characters
- Dominant language
- JavaScript
- Stars
- 20k
- Forks
- 1.1k
- Avg merge
- 3d 12h
- Merged PRs (30d)
- 13
Description
## 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
```html
const editor = document.querySelector("trix-editor").editor
editor.insertString("a".repeat(200000))
editor.setSelectedRange(200000)
```
Put the cursor at the end and press backspace. Or, without the keyboard:
```js
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:
```js
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.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start at the shared ucs2encode entry point, then inspect UTF16String#offsetToUCS2Offset and UTF16String.fromCodepoints, including their callers in Composition and summarizeStringChange. Run the supplied 200,000-character reproduction and verify that offset conversion and long-document mutations no longer throw, while backspace, delete, and selection expansion continue to work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- frontend, performance
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100