basecamp / basecamp/trix

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

Open
#1,359 0 comments 0 reactions 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.