From 3cf76628b5d185f7ee55ad3b0a2eefd4faae5129 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:20:03 +0200 Subject: [PATCH] Memoize Phosphor icon resolution --- scripts/measure-icon-resolution.mjs | 135 ++++++++++++++++ scripts/test-icon-runtime-phosphor-cache.mjs | 147 ++++++++++++++++++ .../src/raycast-api/icon-runtime-phosphor.tsx | 86 ++++++++-- src/renderer/src/vite-env.d.ts | 4 + 4 files changed, 357 insertions(+), 15 deletions(-) create mode 100644 scripts/measure-icon-resolution.mjs create mode 100644 scripts/test-icon-runtime-phosphor-cache.mjs diff --git a/scripts/measure-icon-resolution.mjs b/scripts/measure-icon-resolution.mjs new file mode 100644 index 00000000..4555ada0 --- /dev/null +++ b/scripts/measure-icon-resolution.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node + +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/renderer/src/raycast-api/icon-runtime-phosphor.tsx'); +const outputFile = path.join(os.tmpdir(), `supercmd-icon-resolution-${process.pid}-${Date.now()}.mjs`); + +const exactInputs = [ + 'AddPerson', + 'Icon.ArrowLeftCircleFilled', + 'MagnifyingGlass', + 'Stopwatch', + 'Temperature', + 'Dot', + 'XMarkCircleFilled', + 'Folder', +]; + +const unknownAndFuzzyInputs = [ + 'TotallyMissingIconName', + 'HyperSpecificSparkleClockWidget', + 'SuperCmdTimerStopwatchShape', + 'UnmappedTerminalCommandLineIcon', + 'MysteryNetworkCloudBolt', + 'Noise___No_Such_Icon_999', +]; + +function exposeResolverPlugin() { + const normalizedTarget = path.normalize(targetFile); + return { + name: 'expose-icon-resolution-for-measurement', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-phosphor\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source}\nexport const __measureResolvePhosphorIconFromRaycast = resolvePhosphorIconFromRaycast;\n`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }; +} + +function stubServerRenderingPlugin() { + return { + name: 'stub-server-rendering-for-measurement', + setup(buildApi) { + buildApi.onResolve({ filter: /^react-dom\/server$/ }, () => ({ + path: 'react-dom-server-measurement-stub', + namespace: 'measurement-stub', + })); + buildApi.onLoad({ filter: /.*/, namespace: 'measurement-stub' }, () => ({ + contents: 'export function renderToStaticMarkup() { return ""; }', + loader: 'js', + })); + }, + }; +} + +function measureCase(resolveIcon, { name, inputs, iterations }) { + const calls = inputs.length * iterations; + const start = performance.now(); + let resolved = 0; + + for (let iteration = 0; iteration < iterations; iteration += 1) { + for (const input of inputs) { + if (resolveIcon(input)?.icon) resolved += 1; + } + } + + const durationMs = performance.now() - start; + return { + name, + calls, + resolved, + durationMs: Number(durationMs.toFixed(3)), + callsPerMs: Number((calls / Math.max(durationMs, 0.001)).toFixed(3)), + }; +} + +async function main() { + await build({ + entryPoints: [targetFile], + outfile: outputFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'es2022', + jsx: 'automatic', + logLevel: 'silent', + plugins: [exposeResolverPlugin(), stubServerRenderingPlugin()], + }); + + const moduleUrl = `${pathToFileURL(outputFile).href}?t=${Date.now()}`; + const runtime = await import(moduleUrl); + const resolveIcon = runtime.__measureResolvePhosphorIconFromRaycast; + if (typeof resolveIcon !== 'function') { + throw new Error('Failed to expose resolvePhosphorIconFromRaycast for measurement.'); + } + + const results = [ + measureCase(resolveIcon, { + name: 'exact/repeated', + inputs: exactInputs, + iterations: 2500, + }), + measureCase(resolveIcon, { + name: 'unknown-fuzzy/repeated', + inputs: unknownAndFuzzyInputs, + iterations: 250, + }), + ]; + + console.log('Icon resolution measurement'); + for (const result of results) { + console.log( + `${result.name}: ${result.calls} calls, ${result.durationMs} ms, ${result.callsPerMs} calls/ms, resolved ${result.resolved}` + ); + } + console.log(JSON.stringify({ results }, null, 2)); +} + +try { + await main(); +} finally { + await fs.unlink(outputFile).catch(() => {}); +} diff --git a/scripts/test-icon-runtime-phosphor-cache.mjs b/scripts/test-icon-runtime-phosphor-cache.mjs new file mode 100644 index 00000000..ee5b2734 --- /dev/null +++ b/scripts/test-icon-runtime-phosphor-cache.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/renderer/src/raycast-api/icon-runtime-phosphor.tsx'); + +async function importIconRuntimeForTest() { + const outputFile = path.join(os.tmpdir(), `supercmd-icon-runtime-test-${process.pid}-${Date.now()}.mjs`); + const normalizedTarget = path.normalize(targetFile); + + try { + await build({ + entryPoints: [targetFile], + outfile: outputFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'es2022', + jsx: 'automatic', + logLevel: 'silent', + plugins: [ + { + name: 'expose-icon-runtime-test-hooks', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-phosphor\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source} +export const __testIconRuntimePhosphor = { + resolvePhosphorIconFromRaycast, + tryResolvePhosphorByName, + clearIconResolutionCaches: () => { + phosphorIconResolutionCache.clear(); + phosphorNameResolutionCache.clear(); + raycastIconNameResolutionCache.clear(); + }, + caches: { + phosphorIconResolutionCache, + phosphorNameResolutionCache, + raycastIconNameResolutionCache, + }, +}; +`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }, + { + name: 'stub-server-rendering-for-icon-runtime-tests', + setup(buildApi) { + buildApi.onResolve({ filter: /^react-dom\/server$/ }, () => ({ + path: 'react-dom-server-test-stub', + namespace: 'test-stub', + })); + buildApi.onLoad({ filter: /.*/, namespace: 'test-stub' }, () => ({ + contents: 'export function renderToStaticMarkup() { return ""; }', + loader: 'js', + })); + }, + }, + ], + }); + + const runtime = await import(`${pathToFileURL(outputFile).href}?t=${Date.now()}`); + return runtime.__testIconRuntimePhosphor; + } finally { + await fs.unlink(outputFile).catch(() => {}); + } +} + +test('Phosphor icon runtime resolution cache', async (t) => { + const iconRuntime = await importIconRuntimeForTest(); + const { + resolvePhosphorIconFromRaycast, + tryResolvePhosphorByName, + clearIconResolutionCaches, + caches, + } = iconRuntime; + + await t.test('resolves direct Raycast icon names without changing weight', () => { + clearIconResolutionCaches(); + + const expected = tryResolvePhosphorByName('MagnifyingGlass'); + const resolved = resolvePhosphorIconFromRaycast('MagnifyingGlass'); + + assert.ok(expected, 'expected Phosphor MagnifyingGlass export to exist'); + assert.equal(resolved?.icon, expected); + assert.equal(resolved?.weight, 'regular'); + }); + + await t.test('preserves explicit Raycast aliases and filled weight compatibility', () => { + clearIconResolutionCaches(); + + const stopwatch = resolvePhosphorIconFromRaycast('Stopwatch'); + assert.equal(stopwatch?.icon, tryResolvePhosphorByName('Timer')); + assert.equal(stopwatch?.weight, 'regular'); + + const filled = resolvePhosphorIconFromRaycast('XMarkCircleFilled'); + assert.equal(filled?.icon, tryResolvePhosphorByName('XCircle')); + assert.equal(filled?.weight, 'fill'); + }); + + await t.test('keeps unknown icons on the existing fallback glyph', () => { + clearIconResolutionCaches(); + + const fallback = tryResolvePhosphorByName('Question') || tryResolvePhosphorByName('Circle'); + const resolved = resolvePhosphorIconFromRaycast('QzxvPqmn999'); + + assert.ok(fallback, 'expected a Phosphor fallback glyph to exist'); + assert.equal(resolved?.icon, fallback); + assert.equal(resolved?.weight, 'regular'); + }); + + await t.test('caches successful and failed resolutions while allowing whole-cache clear', () => { + clearIconResolutionCaches(); + + const first = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + const second = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + assert.equal(second, first, 'repeated final resolution should return the cached result object'); + + assert.equal(tryResolvePhosphorByName('DefinitelyNotAPhosphorIcon'), undefined); + assert.equal( + caches.phosphorNameResolutionCache.get('DefinitelyNotAPhosphorIcon'), + null, + 'failed Phosphor export lookups are memoized as misses', + ); + + clearIconResolutionCaches(); + assert.equal(caches.phosphorIconResolutionCache.size, 0); + assert.equal(caches.phosphorNameResolutionCache.size, 0); + assert.equal(caches.raycastIconNameResolutionCache.size, 0); + + const afterClear = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + assert.equal(afterClear?.icon, first?.icon); + assert.equal(afterClear?.weight, first?.weight); + }); +}); diff --git a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx index 2716a240..09130951 100644 --- a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx @@ -16,9 +16,27 @@ type PhosphorIconComponent = React.ComponentType<{ style?: React.CSSProperties; weight?: PhosphorIconWeight; }>; +type PhosphorIconResolution = { icon: PhosphorIconComponent; weight: PhosphorIconWeight }; type PhosphorExportValue = unknown; +const ICON_RESOLUTION_CACHE_LIMIT = 2048; +const raycastIconNameResolutionCache = new Map(); +const phosphorNameResolutionCache = new Map(); +const phosphorIconResolutionCache = new Map(); + +function setBoundedCacheEntry(cache: Map, key: K, value: V) { + if (!cache.has(key) && cache.size >= ICON_RESOLUTION_CACHE_LIMIT) { + cache.clear(); + } + cache.set(key, value); +} + +function cachePhosphorIconResolution(input: string, result: PhosphorIconResolution | undefined): PhosphorIconResolution | undefined { + setBoundedCacheEntry(phosphorIconResolutionCache, input, result || null); + return result; +} + function normalizeIconName(name: string): string { return String(name || '') .trim() @@ -76,29 +94,61 @@ if (RAYCAST_ICON_VALUE_TO_NAME instanceof Map) { function resolveRaycastIconName(input: string): RaycastIconName | undefined { const rawInput = String(input || '').trim(); + const cached = raycastIconNameResolutionCache.get(rawInput); + if (cached !== undefined || raycastIconNameResolutionCache.has(rawInput)) { + return cached || undefined; + } + const normalized = normalizeIconName(input); - if (!rawInput && !normalized) return undefined; - if (raycastIconNameSet.has(rawInput)) return rawInput as RaycastIconName; - return raycastIconValueToNameMap.get(rawInput) || raycastIconValueToNameMap.get(normalized); + let resolved: RaycastIconName | undefined; + if (rawInput || normalized) { + resolved = raycastIconNameSet.has(rawInput) + ? rawInput as RaycastIconName + : raycastIconValueToNameMap.get(rawInput) || raycastIconValueToNameMap.get(normalized); + } + + setBoundedCacheEntry(raycastIconNameResolutionCache, rawInput, resolved || null); + return resolved; } function tryResolvePhosphorByName(name: string): PhosphorIconComponent | undefined { - if (!name) return undefined; + const cacheKey = String(name || ''); + const cached = phosphorNameResolutionCache.get(cacheKey); + if (cached !== undefined || phosphorNameResolutionCache.has(cacheKey)) { + return cached || undefined; + } + + if (!name) { + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); + return undefined; + } const direct = (Phosphor as Record)[name]; - if (isRenderablePhosphorComponent(direct)) return direct as PhosphorIconComponent; + if (isRenderablePhosphorComponent(direct)) { + const resolved = direct as PhosphorIconComponent; + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, resolved); + return resolved; + } const normalizedTarget = normalizeIconName(name); - if (!normalizedTarget) return undefined; + if (!normalizedTarget) { + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); + return undefined; + } // Some bundling modes can make namespace entries non-enumerable for Object.entries. // getOwnPropertyNames is more robust for resolving the export keys. for (const key of Object.getOwnPropertyNames(Phosphor)) { if (normalizeIconName(key) !== normalizedTarget) continue; const candidate = (Phosphor as Record)[key]; - if (isRenderablePhosphorComponent(candidate)) return candidate as PhosphorIconComponent; + if (isRenderablePhosphorComponent(candidate)) { + const resolved = candidate as PhosphorIconComponent; + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, resolved); + return resolved; + } } + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); return undefined; } @@ -203,7 +253,13 @@ function bestFuzzyPhosphorCandidate(input: string): string | undefined { return bestName || undefined; } -function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComponent; weight: PhosphorIconWeight } | undefined { +function resolvePhosphorIconFromRaycast(input: string): PhosphorIconResolution | undefined { + const cacheKey = String(input || ''); + const cached = phosphorIconResolutionCache.get(cacheKey); + if (cached !== undefined || phosphorIconResolutionCache.has(cacheKey)) { + return cached || undefined; + } + const resolvedRaycastName = resolveRaycastIconName(input); const iconName = (resolvedRaycastName || input || '').replace(/^Icon\./, ''); const normalized = normalizeIconName(iconName); @@ -214,7 +270,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp if (normalized === 'dot' || normalizedBase === 'dot') { const dotIcon = tryResolvePhosphorByName('Circle'); if (dotIcon) { - return { icon: dotIcon, weight: 'fill' }; + return cachePhosphorIconResolution(cacheKey, { icon: dotIcon, weight: 'fill' }); } } @@ -233,7 +289,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of directCandidates) { const resolved = tryResolvePhosphorByName(candidate); if (resolved) { - return { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -244,7 +300,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of explicitAliases) { const resolved = tryResolvePhosphorByName(candidate); if (resolved) { - return { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -296,7 +352,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of candidates) { const icon = tryResolvePhosphorByName(candidate); if (icon) { - return { icon, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -304,13 +360,13 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp if (fuzzyCandidate) { const fuzzyResolved = tryResolvePhosphorByName(fuzzyCandidate); if (fuzzyResolved) { - return { icon: fuzzyResolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: fuzzyResolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } const fallback = tryResolvePhosphorByName('Question') || tryResolvePhosphorByName('Circle'); - if (!fallback) return undefined; - return { icon: fallback, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + if (!fallback) return cachePhosphorIconResolution(cacheKey, undefined); + return cachePhosphorIconResolution(cacheKey, { icon: fallback, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } export function renderPhosphorIcon(input: string, className: string, tint?: string): React.ReactNode { diff --git a/src/renderer/src/vite-env.d.ts b/src/renderer/src/vite-env.d.ts index 11f02fe2..b6a63e5e 100644 --- a/src/renderer/src/vite-env.d.ts +++ b/src/renderer/src/vite-env.d.ts @@ -1 +1,5 @@ /// + +declare module '*@phosphor-icons/react/dist/index.es.js' { + export * from '@phosphor-icons/react'; +}