From fe1e179cde2e9540214aafd239d102aa39349c20 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 22:52:51 -0700 Subject: [PATCH] perf(rpc): reuse required primitive schemas by message (#35072) Co-authored-by: m4air --- .../rpc-contract/rpc-param-primitives.test.ts | 24 ++++++++++ .../rpc-contract/rpc-param-primitives.ts | 44 ++++++++++++------- src/shared/string-result-cache.ts | 22 ++++++++++ .../terminal-title-classification-memo.ts | 20 +-------- 4 files changed, 75 insertions(+), 35 deletions(-) create mode 100644 src/shared/rpc-contract/rpc-param-primitives.test.ts create mode 100644 src/shared/string-result-cache.ts diff --git a/src/shared/rpc-contract/rpc-param-primitives.test.ts b/src/shared/rpc-contract/rpc-param-primitives.test.ts new file mode 100644 index 00000000000..fe33476b8c7 --- /dev/null +++ b/src/shared/rpc-contract/rpc-param-primitives.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { requiredNumber, requiredString, requiredStringAllowingEmpty } from './rpc-param-primitives' + +describe('required RPC primitive schemas', () => { + it.each([requiredString, requiredStringAllowingEmpty, requiredNumber])( + 'reuses schemas for the same message without conflating different messages', + (factory) => { + const first = factory('Missing id') + expect(factory('Missing id') === first).toBe(true) + expect(factory('Missing name') === first).toBe(false) + } + ) + + it('keeps each factory distinct for an identical message', () => { + const strict = requiredString('Missing value') + const empty = requiredStringAllowingEmpty('Missing value') + const number = requiredNumber('Missing value') + expect(strict.safeParse('').success).toBe(false) + expect(empty.parse('')).toBe('') + expect(empty.safeParse(1).success).toBe(false) + expect(number.parse(1)).toBe(1) + expect(number.safeParse('1').success).toBe(false) + }) +}) diff --git a/src/shared/rpc-contract/rpc-param-primitives.ts b/src/shared/rpc-contract/rpc-param-primitives.ts index 86e485e8af0..db077ebc62a 100644 --- a/src/shared/rpc-contract/rpc-param-primitives.ts +++ b/src/shared/rpc-contract/rpc-param-primitives.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { memoizeStringResult } from '../string-result-cache' + +// ~300 declarations repeat ~170 messages; zod schemas are immutable, so equal messages can share one instance. +const MAX_CACHED_VALIDATION_MESSAGES = 256 // Preserve legacy nonnumeric/nonfinite limits as absent. Zod v4 needs outer optional(): one hidden by pipe // still makes object keys required. @@ -55,22 +59,28 @@ export const BrowserTarget = z.object({ page: OptionalString }) -export function requiredString(message: string) { - return z - .unknown() - .transform((value) => (typeof value === 'string' ? value : '')) - .pipe(z.string().min(1, message)) -} +export const requiredString = memoizeStringResult( + (message: string) => + z + .unknown() + .transform((value) => (typeof value === 'string' ? value : '')) + .pipe(z.string().min(1, message)), + MAX_CACHED_VALIDATION_MESSAGES +) -export function requiredStringAllowingEmpty(message: string) { - return z.unknown().refine((value): value is string => typeof value === 'string', { message }) -} +export const requiredStringAllowingEmpty = memoizeStringResult( + (message: string) => + z.unknown().refine((value): value is string => typeof value === 'string', { message }), + MAX_CACHED_VALIDATION_MESSAGES +) -export function requiredNumber(message: string) { - return z - .unknown() - .transform((value) => - typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN - ) - .pipe(z.number().refine((v) => Number.isFinite(v), { message })) -} +export const requiredNumber = memoizeStringResult( + (message: string) => + z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN + ) + .pipe(z.number().refine((v) => Number.isFinite(v), { message })), + MAX_CACHED_VALIDATION_MESSAGES +) diff --git a/src/shared/string-result-cache.ts b/src/shared/string-result-cache.ts new file mode 100644 index 00000000000..9c1120b5f60 --- /dev/null +++ b/src/shared/string-result-cache.ts @@ -0,0 +1,22 @@ +export function memoizeStringResult( + compute: (key: string) => T, + maxEntries: number +): (key: string) => T { + // Box results so undefined and null are cache hits too. + const cache = new Map() + return (key: string): T => { + const cached = cache.get(key) + if (cached) { + return cached.value + } + const value = compute(key) + if (cache.size >= maxEntries) { + const oldest = cache.keys().next() + if (!oldest.done) { + cache.delete(oldest.value) + } + } + cache.set(key, { value }) + return value + } +} diff --git a/src/shared/terminal-title-classification-memo.ts b/src/shared/terminal-title-classification-memo.ts index 31464556595..dbbc16349c4 100644 --- a/src/shared/terminal-title-classification-memo.ts +++ b/src/shared/terminal-title-classification-memo.ts @@ -1,4 +1,5 @@ /** Pure title classifiers can reuse verdicts across unrelated store writes; changed titles become new keys, so no invalidation signal is needed. */ +import { memoizeStringResult } from './string-result-cache' /** Bound growth across session length while keeping the live pane/title working set cached. */ const MAX_MEMOIZED_TITLES = 1024 @@ -6,22 +7,5 @@ const MAX_MEMOIZED_TITLES = 1024 export function memoizeTitleClassification( classify: (title: string) => T ): (title: string) => T { - // Boxed values so `undefined`/`null` verdicts are still cache hits. - const cache = new Map() - return (title: string): T => { - const cached = cache.get(title) - if (cached) { - return cached.value - } - const value = classify(title) - // FIFO evicts superseded title frames first; they are least likely to be requested again. - if (cache.size >= MAX_MEMOIZED_TITLES) { - const oldest = cache.keys().next() - if (!oldest.done) { - cache.delete(oldest.value) - } - } - cache.set(title, { value }) - return value - } + return memoizeStringResult(classify, MAX_MEMOIZED_TITLES) }