Skip to content

String.prototype.localeCompare: cache IntlCollator for (string locale, no options) - #360

Open
robobun wants to merge 1 commit into
mainfrom
bun/cache-locale-compare-collator
Open

String.prototype.localeCompare: cache IntlCollator for (string locale, no options)#360
robobun wants to merge 1 commit into
mainfrom
bun/cache-locale-compare-collator

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

stringProtoFuncLocaleCompare already reuses globalObject->defaultCollator() when both locales and options are undefined. Passing any explicit locales argument, even the plain string "en", fell through to IntlCollator::create + initializeCollator on every call, so the idiomatic sort comparator

arr.sort((a, b) => a.localeCompare(b, "en"))

rebuilt a UCollator for every comparison. On a 200k-element sort this is ~25x slower than arr.sort(new Intl.Collator("en").compare) and ~60x slower than Node/V8, which caches this shape.

This adds a single-entry cache on JSGlobalObject, keyed on the raw locales string, used when locales is a primitive string and options is undefined. Constructing Intl.Collator with a string locale and undefined options has no observable side effects and its result is fully determined by the string, so reusing the collator is spec-equivalent. A different locale string overwrites the slot; an invalid locale that throws during initializeCollator is never cached. The DFG/FTL StringLocaleCompare intrinsic is untouched (it only applies to the 1-argument form).

All new state and fast-path code is guarded by USE(BUN_JSC_ADDITIONS).

Covers the same fast path V8 has for this shape; see https://github.com/oven-sh/bun/issues with the localeCompare perf report.

Bench (release, 200k-element sort):

before after
sort((a,b)=>a.localeCompare(b,"en")) 6606 ms ~260 ms
sort(new Intl.Collator("en").compare) 262 ms 262 ms
node v26.3.0 localeCompare(b,"en") 106 ms

…, no options)

stringProtoFuncLocaleCompare already reuses the default collator when both
locales and options are undefined. Passing any explicit locales argument,
even the plain string "en", fell through to IntlCollator::create +
initializeCollator on every call, so the idiomatic sort comparator
arr.sort((a, b) => a.localeCompare(b, "en")) rebuilt a UCollator for every
comparison.

Add a single-entry cache on JSGlobalObject, keyed on the raw locales string,
used when locales is a primitive string and options is undefined. Constructing
Intl.Collator with that shape has no observable side effects and its result is
fully determined by the string, so reusing the collator is spec-equivalent.
The cache is invalidated simply by overwriting the slot when the string
changes; invalid locales that throw during initializeCollator are never
cached. The DFG/FTL StringLocaleCompare intrinsic is untouched (it only
handles the 1-argument form).

Guarded by USE(BUN_JSC_ADDITIONS).
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8734dd37-de80-4113-9f1f-e7938c2bdf3b

📥 Commits

Reviewing files that changed from the base of the PR and between 9839239 and 52f23e4.

📒 Files selected for processing (3)
  • Source/JavaScriptCore/runtime/JSGlobalObject.cpp
  • Source/JavaScriptCore/runtime/JSGlobalObject.h
  • Source/JavaScriptCore/runtime/StringPrototype.cpp

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found, but I'd like a human to sign off on the spec-equivalence claim for caching IntlCollator on the raw locales string — it looks correct, but it's a semantic optimization in a core runtime path.

What was reviewed:

  • GC: m_cachedLocaleCompareCollator is a WriteBarrier and is appended in visitChildrenImpl, so the cached collator is kept alive.
  • Exception path: an invalid locale that throws in initializeCollator returns before setCachedLocaleCompareCollator, so a half-initialized collator is never cached.
  • Cache key: keyed on the raw string, so "de" vs "de-u-co-phonebk" are separate entries; the cached object is never exposed to JS, so it can't be mutated between calls.
Extended reasoning...

Overview

Adds a single-entry IntlCollator cache on JSGlobalObject, keyed by the raw locales string, for the shape String.prototype.localeCompare(that, <string>) with options === undefined. Touches three files: two new WriteBarrier/String members plus accessor methods on JSGlobalObject (with a matching visitChildrenImpl append), and a new else if branch in stringProtoFuncLocaleCompare. All new code is behind USE(BUN_JSC_ADDITIONS).

Security risks

None identified. No new untrusted-input parsing beyond what initializeCollator already does; the cached object is created via the same path as the existing slow path and is never exposed to user JS.

Level of scrutiny

Medium-high. While the diff is small and mechanically clean (WriteBarrier + visit + accessor follows the established pattern used throughout this file), the correctness of the optimization rests on the claim that new Intl.Collator(<primitive string>, undefined) is side-effect-free and fully determined by the string. That claim looks right — canonicalizeLocaleList on a primitive string runs no user code, options coercion is skipped for undefined, and the resulting collator has no mutable state — but it's an ECMA-402 spec-equivalence argument in a hot runtime path, not a mechanical refactor. Someone with Intl/JSC ownership should confirm they agree with the reasoning (e.g., that locale fallback to the default locale for an unmatched tag doesn't need cache invalidation, mirroring the existing m_defaultCollator assumption).

Other factors

The GC integration is correct (WriteBarrier<IntlCollator> + visitor.append in the existing BUN_JSC_ADDITIONS block of visitChildrenImpl). The cache-miss path is byte-identical to the previous slow path plus a store, so behavior on first call is unchanged. The localeString is resolved via asString(locales)->value(globalObject) after locales.isString(), which cannot re-enter user code. No tests are added in this PR; the existing ECMA-402 test suite in JSTests should exercise the path but won't specifically stress cache reuse across differing locale strings.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
52f23e4f autobuild-preview-pr-360-52f23e4f 2026-07-27 13:22:55 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant