Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/huge-hotels-ask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"unicode-segmenter": patch
---

Removed pinned `graphemeSegments()` in the module scope to make all APIs able to be three-shaken properly.

It was introduced when they all use `graphemeSegments()` as the core. But now they are all have their own loop.
4 changes: 2 additions & 2 deletions .claude/skills/codspeed-debug/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ CI (`.github/workflows/codspeed.yml`) runs `node benchmark/grapheme/codspeed.js`
- V8 runs with stability flags (e.g. `--predictable --hash-seed=1`) and the plugin measures roughly: a few warmup calls → forced `gc()` → **one** measured call.
- Consequence 1: the measured call sees whatever tier the code reached during warmup. The repo defends with a 2000-iteration `beforeAll` warmup in `codspeed.js` — do not remove it.
- Consequence 2 (the big trap): **forced GC clears weak references embedded in optimized code** (deopt reason: `embedded weak objects cleared` — e.g. hidden-class maps of objects with no live instances), so optimized code deopts right before the measured call, producing phantom regressions up to ~60%.
- Defense in `src/grapheme.js`: the pin block stashing a live generator and its first result on `PAIR._keep`, which keeps the segment-object hidden classes strongly reachable across GC. Do not remove it.
- A module-scope `const _keep = ...` does NOT work — unreferenced module bindings don't survive module evaluation; the pin must hang off an object that stays reachable (`PAIR`).
- `src/grapheme.js` carried a defense against this from #133 to #145: a block stashing a live generator and its first result on `PAIR._keep`, so the segment-object hidden classes stayed strongly reachable across GC. It was removed after measuring it inert on the current (generator-based) code — the deopt it guarded was diagnosed on a class-iterator rewrite that never shipped. If you re-add anything like it, note that a module-scope `const _keep = ...` does NOT work: unreferenced module bindings don't survive module evaluation, so the pin has to hang off an object that stays reachable.
- Before re-adding it, measure it properly: a single instrumented call cannot resolve this for `graphemeSegments`, which allocates ~120 objects per call, so whether a scavenge lands inside the measured call swings it ±30%. Run 50 `{ gc(); one call }` cycles under callgrind and subtract a `{ gc() }`-only run of the same shape; that showed 92,965 vs 92,915 instructions per call (0.05%), with the pin making each major GC ~26 k instructions *more* expensive.

## Local replication recipe

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,8 @@ Since [Hermes doesn't support the `Intl.Segmenter` API](https://github.com/faceb

| Name | Unicode® | ESM? | Size | Size (min) | Size (min+gzip) | Size (min+br) | Size (min+zstd) |
|--------------------------------------|----------|------|--------:|-----------:|----------------:|--------------:|----------------:|
| `unicode-segmenter/grapheme` | 17.0.0 | ✔️ | 7,851 | 4,911 | 2,351 | 2,134 | 2,402 |
| `unicode-segmenter/grapheme` (full*) | 17.0.0 | ✔️ | 10,257 | 5,760 | 2,638 | 2,367 | 2,695 |
| `unicode-segmenter/grapheme` | 17.0.0 | ✔️ | 7,767 | 4,876 | 2,325 | 2,104 | 2,374 |
| `unicode-segmenter/grapheme` (full*) | 17.0.0 | ✔️ | 10,173 | 5,725 | 2,607 | 2,322 | 2,668 |
| `graphemer` | 15.0.0 | ✖️ | 410,435 | 95,104 | 15,752 | 10,660 | 15,911 |
| `grapheme-splitter` | 10.0.0 | ✖️ | 122,254 | 23,682 | 7,852 | 4,802 | 6,753 |
| `@formatjs/intl-segmenter`* | 17.0.0 | ✖️ | 268,301 | 176,759 | 45,988 | 31,701 | 45,370 |
Expand All @@ -255,8 +255,8 @@ Since [Hermes doesn't support the `Intl.Segmenter` API](https://github.com/faceb

| Name | Bytecode size | Bytecode size (gzip)* |
|-------------------------------------|--------------:|----------------------:|
| `unicode-segmenter/grapheme` | 15,892 | 9,012 |
| `unicode-segmenter/grapheme` (full) | 16,097 | 9,176 |
| `unicode-segmenter/grapheme` | 15,794 | 8,916 |
| `unicode-segmenter/grapheme` (full) | 16,003 | 9,073 |
| `graphemer` | 134,085 | 31,770 |
| `grapheme-splitter` | 63,942 | 19,165 |
| `@formatjs/intl-segmenter` | 329,547 | 136,751 |
Expand Down
12 changes: 0 additions & 12 deletions src/grapheme.js
Original file line number Diff line number Diff line change
Expand Up @@ -437,15 +437,3 @@ export function collectGraphemes(input) {
result.push(input.slice(index));
return result;
}

// Keep one live segmenter and its result reachable so their hidden classes stay strongly referenced.
// Otherwise a major GC while no segmenter is alive clears the maps embedded weakly in JIT-optimized code,
// and the next run pays deoptimization and re-learning costs.
//
// The instances are stashed on an always-retained module object,
// since unreferenced module-scope bindings do not survive module evaluation.
{
let keep = graphemeSegments('_');
// @ts-ignore intended expando
PAIR._keep = [keep, keep.next()];
}
Loading