From 107fe45248b7c9b7bf7f3c49ae836dab00ddf4e6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 3 Aug 2026 15:31:22 +0300 Subject: [PATCH] perf(runtime): cache the derived runtime key `getRuntimeKey` keys caches, stores, and persisted state across the whole UI, so it runs on store reads, event handling, and render paths. Until the runtime endpoint is explicitly initialised, every call re-derived the key by trimming two injected globals and constructing three URL objects. In a streaming capture this made `readInjectedLocalOrigin` the single most expensive application function: 315 ms of self time, 12% of all main-thread busy time. After the change it does not appear in the profile at all, and the same capture went from two long tasks to none, with the longest task dropping from 210 ms to 47 ms. The key depends only on the active API base URL and two injected globals, and `switchRuntimeEndpoint` writes the injected API base URL at runtime, so the cache is validated against the raw untrimmed values rather than memoised outright. That comparison allocates nothing and still recomputes as soon as any input changes. Tests cover both directions, including an operation-count assertion that repeated calls construct no URLs. The streaming profiler also reports output-normalised metrics, because response length varies between runs and makes per-second totals incomparable. --- .../lib/runtime-switch.runtime-key.test.ts | 80 +++++++++++++++++++ packages/ui/src/lib/runtime-switch.ts | 45 ++++++++++- scripts/profile-session.mjs | 17 +++- 3 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/lib/runtime-switch.runtime-key.test.ts diff --git a/packages/ui/src/lib/runtime-switch.runtime-key.test.ts b/packages/ui/src/lib/runtime-switch.runtime-key.test.ts new file mode 100644 index 00000000..9808b088 --- /dev/null +++ b/packages/ui/src/lib/runtime-switch.runtime-key.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + +import { getRuntimeKey } from './runtime-switch'; + +/** + * `getRuntimeKey` runs on store, event, and render paths, so its cost is + * multiplied by everything the UI does. These tests pin both directions of the + * derived-key cache: repeated calls with unchanged inputs must do no work, and + * any change to the inputs it derives from must still be observed. + * + * This lives in its own file because the cache is only reachable while the + * runtime endpoint has not been explicitly initialised, and module state is + * shared across tests within a file. + */ + +type RuntimeWindow = typeof globalThis & { + __OPENCHAMBER_API_BASE_URL__?: string; + __OPENCHAMBER_LOCAL_ORIGIN__?: string; +}; + +const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); +const NativeURL = globalThis.URL; +let urlConstructions = 0; + +const setRuntimeWindow = (apiBaseUrl: string | undefined, localOrigin: string | undefined): void => { + const runtimeWindow = {} as RuntimeWindow; + if (apiBaseUrl !== undefined) runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl; + if (localOrigin !== undefined) runtimeWindow.__OPENCHAMBER_LOCAL_ORIGIN__ = localOrigin; + Object.defineProperty(globalThis, 'window', { value: runtimeWindow, configurable: true, writable: true }); +}; + +beforeEach(() => { + urlConstructions = 0; + class CountingURL extends NativeURL { + constructor(url: string | URL, base?: string | URL) { + urlConstructions += 1; + super(url, base); + } + } + globalThis.URL = CountingURL as unknown as typeof URL; +}); + +afterEach(() => { + globalThis.URL = NativeURL; + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); + else Reflect.deleteProperty(globalThis, 'window'); +}); + +describe('getRuntimeKey caching', () => { + test('resolves a same-origin endpoint to the local runtime key', () => { + setRuntimeWindow('https://app.example.com/api', 'https://app.example.com'); + expect(getRuntimeKey()).toBe('local'); + }); + + test('performs no URL work on repeated calls with unchanged inputs', () => { + setRuntimeWindow('https://remote.example.com', 'https://app.example.com'); + const first = getRuntimeKey(); + expect(first).toBe('url:https://remote.example.com'); + + urlConstructions = 0; + for (let index = 0; index < 50; index += 1) expect(getRuntimeKey()).toBe(first); + expect(urlConstructions).toBe(0); + }); + + test('recomputes when the injected API base URL changes at runtime', () => { + setRuntimeWindow('https://first.example.com', 'https://app.example.com'); + expect(getRuntimeKey()).toBe('url:https://first.example.com'); + + (globalThis as RuntimeWindow & { window: RuntimeWindow }).window.__OPENCHAMBER_API_BASE_URL__ = 'https://second.example.com'; + expect(getRuntimeKey()).toBe('url:https://second.example.com'); + }); + + test('recomputes when the injected local origin changes at runtime', () => { + setRuntimeWindow('https://app.example.com', 'https://other.example.com'); + expect(getRuntimeKey()).toBe('url:https://app.example.com'); + + (globalThis as RuntimeWindow & { window: RuntimeWindow }).window.__OPENCHAMBER_LOCAL_ORIGIN__ = 'https://app.example.com'; + expect(getRuntimeKey()).toBe('local'); + }); +}); diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 23ed5e16..babc4a1a 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -76,11 +76,52 @@ const sameOrigin = (left: string, right: string): boolean => { }; export const getRuntimeApiBaseUrl = (): string => activeApiBaseUrl || readInjectedApiBaseUrl(); + +// `getRuntimeKey` keys caches, stores, and persisted state across the whole UI, +// so it runs on store reads, event handling, and render paths. Before the +// runtime endpoint is explicitly initialised, every call re-derived the key by +// trimming two injected globals and constructing three `URL` objects, which +// made this one of the most expensive functions during streaming. +// +// The result depends only on `activeApiBaseUrl` and the two injected globals, +// and `switchRuntimeEndpoint` writes the injected API base URL at runtime, so +// the cache is validated against the raw, untrimmed values. That comparison +// allocates nothing and still recomputes the moment any input changes. +let cachedRuntimeKey = ''; +let cachedActiveApiBaseUrl: string | null = null; +let cachedRawApiBaseUrl: string | undefined; +let cachedRawLocalOrigin: string | undefined; + +const readRawRuntimeGlobal = (key: '__OPENCHAMBER_API_BASE_URL__' | '__OPENCHAMBER_LOCAL_ORIGIN__'): string | undefined => { + if (typeof window === 'undefined') return undefined; + const value = (window as typeof window & { + __OPENCHAMBER_API_BASE_URL__?: string; + __OPENCHAMBER_LOCAL_ORIGIN__?: string; + })[key]; + return typeof value === 'string' ? value : undefined; +}; + export const getRuntimeKey = (): string => { if (activeRuntimeKey) return activeRuntimeKey; + + const rawApiBaseUrl = readRawRuntimeGlobal('__OPENCHAMBER_API_BASE_URL__'); + const rawLocalOrigin = readRawRuntimeGlobal('__OPENCHAMBER_LOCAL_ORIGIN__'); + if ( + cachedActiveApiBaseUrl === activeApiBaseUrl + && cachedRawApiBaseUrl === rawApiBaseUrl + && cachedRawLocalOrigin === rawLocalOrigin + ) { + return cachedRuntimeKey; + } + const apiBaseUrl = getRuntimeApiBaseUrl(); - if (sameOrigin(apiBaseUrl, readInjectedLocalOrigin())) return 'local'; - return normalizeRuntimeUrlKey(apiBaseUrl); + cachedRuntimeKey = sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) + ? 'local' + : normalizeRuntimeUrlKey(apiBaseUrl); + cachedActiveApiBaseUrl = activeApiBaseUrl; + cachedRawApiBaseUrl = rawApiBaseUrl; + cachedRawLocalOrigin = rawLocalOrigin; + return cachedRuntimeKey; }; export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; runtimeKey?: string | null } = {}): void => { diff --git a/scripts/profile-session.mjs b/scripts/profile-session.mjs index bc271cba..f7c0e11e 100644 --- a/scripts/profile-session.mjs +++ b/scripts/profile-session.mjs @@ -187,6 +187,9 @@ const REPORTED_METRICS = [ { key: "layoutsPerSecond", label: "Layouts/sec", unit: "", lowerIsBetter: true }, { key: "framesPerSecond", label: "Animation frames/sec", unit: "", lowerIsBetter: false }, { key: "streamSeconds", label: "Stream duration", unit: "s", lowerIsBetter: true }, + { key: "renderedCharacters", label: "Rendered characters", unit: "", lowerIsBetter: false }, + { key: "busyMsPerKilochar", label: "Busy per 1k chars", unit: "ms", lowerIsBetter: true }, + { key: "recalcStylePerKilochar", label: "Style recalcs per 1k", unit: "", lowerIsBetter: true }, { key: "nodeGrowth", label: "DOM node growth", unit: "", lowerIsBetter: true }, { key: "listenerGrowth", label: "Listener growth", unit: "", lowerIsBetter: true }, { key: "heapGrowthMbPerSecond", label: "Heap growth", unit: "MB/s", lowerIsBetter: true }, @@ -429,6 +432,8 @@ const main = async () => { .some((entry) => entry.metric.startsWith("ui.message_list") && entry.count > 0) const renderedStream = renderedAfter.messages > renderedBefore.messages && messageListRendered + const renderedCharacterGrowth = renderedAfter.characters - renderedBefore.characters + const tasks = summarizeLongTasks(traceEvents) const delta = (name) => Number(after[name] ?? 0) - Number(before[name] ?? 0) const perSecond = (name) => round(delta(name) / elapsedSeconds) @@ -448,7 +453,7 @@ const main = async () => { renderedStream, renderedMessagesBefore: renderedBefore.messages, renderedMessagesAfter: renderedAfter.messages, - renderedCharacterGrowth: renderedAfter.characters - renderedBefore.characters, + renderedCharacterGrowth, traceComplete, disposableSession: !options.keepSession && !options.session, metrics: { @@ -458,6 +463,16 @@ const main = async () => { recalcStylePerSecond: perSecond("RecalcStyleCount"), layoutsPerSecond: perSecond("LayoutCount"), framesPerSecond: round(Number(probe?.counters?.rafScheduled ?? 0) / elapsedSeconds), + // Response length varies between runs even for an identical prompt, so + // per-second and total figures are not comparable across captures. + // Normalising by rendered output is what makes two runs contrastable. + renderedCharacters: renderedCharacterGrowth, + busyMsPerKilochar: renderedCharacterGrowth > 0 + ? round((delta("TaskDuration") * 1000) / (renderedCharacterGrowth / 1000)) + : 0, + recalcStylePerKilochar: renderedCharacterGrowth > 0 + ? round(delta("RecalcStyleCount") / (renderedCharacterGrowth / 1000)) + : 0, streamSeconds, recordedSeconds: round(elapsedSeconds), nodeStart: Number(before.Nodes ?? 0),