From baa1cb135c72ff0ca2af05cd31155e08d5b47e59 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 12 Sep 2026 18:03:06 -0700 Subject: [PATCH] perf(mobile): avoid materializing input characters on backspace (#20220) Co-authored-by: m4air --- config/scripts/mobile-backspace-benchmark.mjs | 112 ++++++++++++++++++ .../terminal-live-text-commit.test.ts | 46 ++++++- .../src/terminal/terminal-live-text-commit.ts | 5 +- 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 config/scripts/mobile-backspace-benchmark.mjs diff --git a/config/scripts/mobile-backspace-benchmark.mjs b/config/scripts/mobile-backspace-benchmark.mjs new file mode 100644 index 00000000000..f1839089776 --- /dev/null +++ b/config/scripts/mobile-backspace-benchmark.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +// git show :mobile/src/terminal/terminal-live-text-commit.ts | node config/scripts/mobile-backspace-benchmark.mjs +const target = resolve('mobile/src/terminal/terminal-live-text-commit.ts') +async function load(source) { + const result = await build({ + stdin: { contents: source, loader: 'ts', resolveDir: dirname(target) }, + bundle: true, + write: false, + platform: 'node', + format: 'esm' + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).getTerminalLiveAccessoryLocalEditText +} +const baseline = readFileSync(0, 'utf8') +assert.ok( + baseline.includes('function getTerminalLiveAccessoryLocalEditText'), + 'Pipe baseline source into stdin' +) +const implementations = { + before: await load(baseline), + after: await load(readFileSync(target, 'utf8')) +} +const tokens = [ + '', + 'a', + '\u0000', + '\r', + '\n', + 'ν•œ', + '\u0301', + '\u200d', + 'πŸ™‚', + '\ud800', + '\udbff', + '\udc00', + '\udfff' +] +let cases = 0 +for (const first of tokens) { + for (const second of tokens) { + for (const third of tokens) { + for (const localEdit of ['backspace', 'delete']) { + const input = { fieldText: first + second + third, localEdit } + assert.equal( + implementations.after(input), + implementations.before(input), + JSON.stringify(input) + ) + cases += 1 + } + } + } +} +const results = [] +for (const inputBytes of [32, 4096, 65_536, 262_144]) { + for (const glyph of ['a', 'πŸ™‚']) { + const fieldText = glyph.repeat(inputBytes / Buffer.byteLength(glyph)) + const input = { localEdit: 'backspace', fieldText } + const expected = implementations.before(input) + assert.equal(implementations.after(input), expected) + const iterations = Math.max(10, Math.floor(1_000_000 / inputBytes)) + for (let warmup = 0; warmup < 100; warmup += 1) { + implementations.before(input) + implementations.after(input) + } + /** @type {{ before: number[], after: number[] }} */ + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + let actual + const started = performance.now() + for (let repeat = 0; repeat < iterations; repeat += 1) { + actual = implementations[arm](input) + } + samples[arm].push(performance.now() - started) + assert.equal(actual, expected) + } + } + const means = Object.fromEntries( + Object.entries(samples).map(([arm, values]) => [ + arm, + (values.reduce((sum, ms) => sum + ms, 0) * 1000) / values.length / iterations + ]) + ) + results.push({ + inputBytes, + glyph, + iterations, + meanMicrosecondsPerCall: means, + before: summarizeBenchmarkSamples(samples.before), + after: summarizeBenchmarkSamples(samples.after) + }) + } +} +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases: cases, results }, + null, + 2 + ) +) diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts index a1206645f25..7c41cb1c5b8 100644 --- a/mobile/src/terminal/terminal-live-text-commit.test.ts +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getTerminalLiveAccessoryBytesDecision, getTerminalLiveAccessoryLocalEditText, @@ -101,6 +101,50 @@ describe('terminal live accessory bytes decision', () => { }) describe('terminal live accessory local edit text', () => { + it.each([ + ['', ''], + ['a', ''], + ['aπŸ™‚', 'a'], + ['πŸ™‚a', 'πŸ™‚'], + ['πŸ™‚πŸ™‚', 'πŸ™‚'], + ['a\ud800', 'a'], + ['a\udc00', 'a'], + ['\ud800\ud800', '\ud800'], + ['\udc00\ud800', '\udc00'], + ['e\u0301', 'e'], + ['πŸ‘©β€πŸ’»', 'πŸ‘©β€'], + ['\r\n', '\r'] + ])('preserves code-point deletion for %j', (fieldText, expected) => { + expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText })).toBe( + expected + ) + expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText })).toBe( + fieldText + ) + }) + + it('does not iterate the input prefix to delete the final code point', () => { + const fieldText = `${'a'.repeat(100_000)}πŸ™‚` + const originalIterator = String.prototype[Symbol.iterator] + let visits = 0 + const iterator = vi + .spyOn(String.prototype, Symbol.iterator) + .mockImplementation(function* (this: string) { + for (const codePoint of originalIterator.call(this)) { + visits += 1 + yield codePoint + } + }) + let result: string + try { + result = getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText }) + } finally { + iterator.mockRestore() + } + expect(result).toBe('a'.repeat(100_000)) + expect(visits).toBeLessThanOrEqual(2) + }) + it('Given backspace Then drops the last code point of the field text', () => { expect( getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: 'ν•œκΈ€' }) diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts index 77752e0775f..375e564530c 100644 --- a/mobile/src/terminal/terminal-live-text-commit.ts +++ b/mobile/src/terminal/terminal-live-text-commit.ts @@ -1,4 +1,5 @@ import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input' +import { readUtf8CodePointAt } from '../../../src/shared/utf8-byte-limits' export type TerminalLiveSpecialKeyDecision = | { readonly kind: 'ignore' } @@ -79,5 +80,7 @@ export function getTerminalLiveAccessoryLocalEditText({ return fieldText } - return Array.from(fieldText).slice(0, -1).join('') + const end = fieldText.length + const width = end > 1 && readUtf8CodePointAt(fieldText, end - 2) > 0xffff ? 2 : 1 + return fieldText.slice(0, -width) }