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),