diff --git a/config/scripts/locale-collator-sort-benchmark.mjs b/config/scripts/locale-collator-sort-benchmark.mjs new file mode 100644 index 00000000000..8a68331bd44 --- /dev/null +++ b/config/scripts/locale-collator-sort-benchmark.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// Benchmarks optioned localeCompare calls used by renderer list comparators. +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { createJiti } from 'jiti' + +const ROUND_COUNT = 5 +const MIN_ROUND_MS = 120 +const jiti = createJiti(import.meta.url, { + alias: { '@': fileURLToPath(new URL('../../src/renderer/src', import.meta.url)) } +}) +const { compareBaseSensitivityLocaleText } = await jiti.import( + '../../src/renderer/src/lib/locale-text-collators.ts' +) +const { sortJiraIssues } = await jiti.import( + '../../src/renderer/src/components/jira-issue-sorter.ts' +) + +let randomState = 0x9e3779b9 +function random() { + randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0 + return randomState / 0x100000000 +} + +function shuffle(values) { + for (let index = values.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)) + ;[values[index], values[swapIndex]] = [values[swapIndex], values[index]] + } + return values +} + +const labels = ['alpha', 'Álpha', 'beta', 'BÉTA', 'café', 'Cafe', 'zeta', 'Ångström'] + +function makeJiraIssues(count) { + return shuffle( + Array.from({ length: count }, (_, index) => ({ + key: `TASK-${(index * 37) % (count + 17)}`, + title: labels[index % labels.length], + updatedAt: '2026-01-01T00:00:00.000Z' + })) + ) +} + +function makeBaseSensitivityValues(count) { + return shuffle( + Array.from( + { length: count }, + (_, index) => + `${labels[(index * 5) % labels.length]}-${String((index * 41) % (count + 23)).padStart(4, '0')}` + ) + ) +} + +function calibrate(run) { + for (let index = 0; index < 5; index += 1) { + run() + } + let iterations = 1 + while (true) { + const startedAt = performance.now() + for (let index = 0; index < iterations; index += 1) { + run() + } + if (performance.now() - startedAt >= MIN_ROUND_MS) { + break + } + iterations *= 2 + } + return iterations +} + +function measureRound(run, iterations) { + const startedAt = performance.now() + for (let index = 0; index < iterations; index += 1) { + run() + } + return (performance.now() - startedAt) / iterations +} + +function measurePair(before, after) { + const beforeIterations = calibrate(before) + const afterIterations = calibrate(after) + const beforeSamples = [] + const afterSamples = [] + for (let round = 0; round < ROUND_COUNT; round += 1) { + if (round % 2 === 0) { + beforeSamples.push(measureRound(before, beforeIterations)) + afterSamples.push(measureRound(after, afterIterations)) + } else { + afterSamples.push(measureRound(after, afterIterations)) + beforeSamples.push(measureRound(before, beforeIterations)) + } + } + const middle = Math.floor(ROUND_COUNT / 2) + return { + beforeMs: beforeSamples.sort((a, b) => a - b)[middle], + afterMs: afterSamples.sort((a, b) => a - b)[middle] + } +} + +function assertSameOrder(before, after, label) { + const expected = before() + const actual = after() + if ( + expected.length !== actual.length || + expected.some((value, index) => value !== actual[index]) + ) { + throw new Error(`${label} sort order changed`) + } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Renderer locale sort, ms per sort (median of 5 rounds). Lower is better.') +console.log( + `${pad('mode', 9)} ${pad('items', 7)} ${pad('per-call', 11)} ${pad('reused', 11)} ${pad('speedup', 9)}` +) + +for (const count of [36, 50, 250]) { + const issues = makeJiraIssues(count) + const before = () => + [...issues] + .sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true })) + .map((issue) => issue.key) + const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key) + assertSameOrder(before, after, `numeric ${count}`) + const { beforeMs, afterMs } = measurePair(before, after) + console.log( + `${pad('numeric', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +for (const count of [10, 50, 250]) { + const values = makeBaseSensitivityValues(count) + const before = () => + [...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + const after = () => [...values].sort(compareBaseSensitivityLocaleText) + assertSameOrder(before, after, `base ${count}`) + const { beforeMs, afterMs } = measurePair(before, after) + console.log( + `${pad('base', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + '\n36 rows matches the Linear page size, 50 matches the picker/Jira scale, and\n250 is a stress case. Both arms assert identical output before timing.' +) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 6b86b16a000..520f2debbdd 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -43,6 +43,7 @@ import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { compareNumericLocaleText } from '@/lib/locale-text-collators' import { getSettingsFocusedExecutionHostId, parseExecutionHostId, @@ -846,7 +847,7 @@ function compareLinearIssues(a: LinearIssue, b: LinearIssue, orderBy: LinearOrde return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() } if (orderBy === 'identifier') { - return a.identifier.localeCompare(b.identifier, undefined, { numeric: true }) + return compareNumericLocaleText(a.identifier, b.identifier) } const priorityDelta = getLinearPriorityRank(a.priority) - getLinearPriorityRank(b.priority) diff --git a/src/renderer/src/components/jira-issue-sorter.ts b/src/renderer/src/components/jira-issue-sorter.ts index a95680c1c34..875aaff0ef0 100644 --- a/src/renderer/src/components/jira-issue-sorter.ts +++ b/src/renderer/src/components/jira-issue-sorter.ts @@ -1,4 +1,5 @@ import type { JiraIssue, JiraPriority } from '../../../shared/types' +import { compareNumericLocaleText } from '@/lib/locale-text-collators' export type JiraIssueSortColumn = 'key' | 'title' | 'status' | 'priority' | 'assignee' | 'updated' @@ -57,7 +58,7 @@ export function sortJiraIssues( return [...issues].sort((a, b) => { let comparison = 0 if (orderBy === 'key') { - comparison = a.key.localeCompare(b.key, undefined, { numeric: true }) + comparison = compareNumericLocaleText(a.key, b.key) } else if (orderBy === 'title') { comparison = a.title.localeCompare(b.title) } else if (orderBy === 'status') { diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts index 2592ca0acab..6938c2078a4 100644 --- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -4,6 +4,7 @@ import { isSafeDisplayCharacter, stripUnsafeDisplayCharacters } from '../../../../shared/skill-display-text' +import { compareBaseSensitivityLocaleText } from '@/lib/locale-text-collators' // Send classification lives in shared so mobile gates optimistic echoes with // the same rules; re-exported here to keep renderer import paths stable. @@ -191,7 +192,7 @@ function sanitizePickerText(value: string, maxLength: number): string { function compareDiscoveredSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { return ( SCOPE_PRIORITY[a.sourceKind] - SCOPE_PRIORITY[b.sourceKind] || - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || + compareBaseSensitivityLocaleText(a.name, b.name) || a.skillFilePath.localeCompare(b.skillFilePath) ) } @@ -202,7 +203,7 @@ function comparePickerSkills( ): number { return ( SCOPE_PRIORITY[a.sources[0].sourceKind] - SCOPE_PRIORITY[b.sources[0].sourceKind] || - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + compareBaseSensitivityLocaleText(a.name, b.name) ) } diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index 94233164037..7c76e9f9ead 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -1,6 +1,7 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants' import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { resolveWorktreeDisplayName } from './worktree-default-display-name' import type { MatchRange } from './worktree-palette-search' @@ -43,7 +44,7 @@ export function isBrowserPaletteQueryTooLarge( } function compareText(a: string, b: string): number { - return a.localeCompare(b, undefined, { sensitivity: 'base' }) + return compareBaseSensitivityLocaleText(a, b) } export function isBlankBrowserUrl(url: string): boolean { diff --git a/src/renderer/src/lib/locale-text-collators.test.ts b/src/renderer/src/lib/locale-text-collators.test.ts new file mode 100644 index 00000000000..63a15ba5b63 --- /dev/null +++ b/src/renderer/src/lib/locale-text-collators.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' +import { compareBaseSensitivityLocaleText, compareNumericLocaleText } from './locale-text-collators' + +function expectComparatorParity( + values: readonly string[], + legacy: (a: string, b: string) => number, + current: (a: string, b: string) => number +): void { + for (const a of values) { + for (const b of values) { + expect(Math.sign(current(a, b))).toBe(Math.sign(legacy(a, b))) + } + } + expect([...values].sort(current)).toEqual([...values].sort(legacy)) +} + +describe('locale text collators', () => { + it('preserves base-sensitivity localeCompare ordering', () => { + const values = [ + 'zeta', + 'Zeta', + 'Álpha', + 'alpha', + 'BÉTA', + 'beta', + 'café', + 'Cafe', + 'a-b', + 'a b', + 'a_b', + 'Ångström', + 'alpha' + ] + const legacy = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' }) + + expectComparatorParity(values, legacy, compareBaseSensitivityLocaleText) + }) + + it('preserves numeric localeCompare ordering and ties', () => { + const values = [ + 'TASK-10', + 'TASK-2', + 'TASK-02', + 'TASK-1', + 'TASK-20', + 'TASK-11', + 'TASK_2', + 'task-2', + 'TASK-2' + ] + const legacy = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true }) + + expectComparatorParity(values, legacy, compareNumericLocaleText) + }) + + it('constructs each collator only when its comparison mode is first used', async () => { + vi.resetModules() + const NativeCollator = Intl.Collator + const collatorSpy = vi + .spyOn(Intl, 'Collator') + .mockImplementation(function Collator(locales, options) { + return new NativeCollator(locales, options) + }) + try { + const comparison = await import('./locale-text-collators') + expect(collatorSpy).not.toHaveBeenCalled() + comparison.compareNumericLocaleText('TASK-2', 'TASK-10') + comparison.compareNumericLocaleText('TASK-3', 'TASK-11') + expect(collatorSpy).toHaveBeenCalledTimes(1) + comparison.compareBaseSensitivityLocaleText('café', 'Cafe') + comparison.compareBaseSensitivityLocaleText('Álpha', 'alpha') + expect(collatorSpy).toHaveBeenCalledTimes(2) + } finally { + collatorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/lib/locale-text-collators.ts b/src/renderer/src/lib/locale-text-collators.ts new file mode 100644 index 00000000000..d8e7bccda12 --- /dev/null +++ b/src/renderer/src/lib/locale-text-collators.ts @@ -0,0 +1,13 @@ +let baseSensitivityCollator: Intl.Collator | undefined +let numericCollator: Intl.Collator | undefined + +export function compareBaseSensitivityLocaleText(a: string, b: string): number { + // Why: stay lazy like localeCompare while resolving ICU options only once. + baseSensitivityCollator ??= new Intl.Collator(undefined, { sensitivity: 'base' }) + return baseSensitivityCollator.compare(a, b) +} + +export function compareNumericLocaleText(a: string, b: string): number { + numericCollator ??= new Intl.Collator(undefined, { numeric: true }) + return numericCollator.compare(a, b) +} diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index d1c7b47779b..148d723f8f2 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -2,6 +2,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import type { Tab, TabGroup, Worktree } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import { selectPaletteTypeAliasMatch } from './palette-type-alias-match' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { resolveWorktreeDisplayName } from './worktree-default-display-name' import type { MatchRange } from './worktree-palette-search' @@ -67,7 +68,7 @@ export type BuildSearchableSimulatorTabsOptions = { } function compareText(a: string, b: string): number { - return a.localeCompare(b, undefined, { sensitivity: 'base' }) + return compareBaseSensitivityLocaleText(a, b) } function findRange(text: string, query: string): MatchRange | null { diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 18570802abc..6a34cfbd95a 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -1,4 +1,5 @@ import { selectPaletteTypeAliasMatch } from './palette-type-alias-match' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { resolveWorktreeDisplayName } from './worktree-default-display-name' import type { MatchRange } from './worktree-palette-search' import type { @@ -27,7 +28,7 @@ export type WorkspaceTabPaletteSearchResult = { } function compareText(a: string, b: string): number { - return a.localeCompare(b, undefined, { sensitivity: 'base' }) + return compareBaseSensitivityLocaleText(a, b) } function findRange(text: string, query: string): MatchRange | null {