From fb7405fe8a13ce9048a26c1f55dbd3f685a63187 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:29:58 +0200 Subject: [PATCH] perf(icons): cache tint color resolution --- scripts/test-icon-tint-cache.mjs | 341 ++++++++++++++++++ .../src/raycast-api/icon-runtime-assets.tsx | 211 +++++++++-- 2 files changed, 513 insertions(+), 39 deletions(-) create mode 100644 scripts/test-icon-tint-cache.mjs diff --git a/scripts/test-icon-tint-cache.mjs b/scripts/test-icon-tint-cache.mjs new file mode 100644 index 00000000..a2f89c89 --- /dev/null +++ b/scripts/test-icon-tint-cache.mjs @@ -0,0 +1,341 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const MEASURE_ONLY = process.env.SUPERCMD_TINT_CACHE_MEASURE_ONLY === '1'; +const ITERATIONS = 10_000; + +const NAMED_COLORS = new Map([ + ['black', { r: 0, g: 0, b: 0 }], + ['blue', { r: 0, g: 0, b: 255 }], + ['green', { r: 0, g: 128, b: 0 }], + ['red', { r: 255, g: 0, b: 0 }], + ['rebeccapurple', { r: 102, g: 51, b: 153 }], + ['white', { r: 255, g: 255, b: 255 }], +]); + +function parseRgbTriplet(value) { + const match = String(value).match(/^rgba?\(([^)]+)\)$/i); + if (!match) return null; + const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); + if (parts.length < 3 || parts.slice(0, 3).some((part) => Number.isNaN(part))) return null; + return { + r: Math.max(0, Math.min(255, Math.round(parts[0]))), + g: Math.max(0, Math.min(255, Math.round(parts[1]))), + b: Math.max(0, Math.min(255, Math.round(parts[2]))), + }; +} + +function parseHexColor(value) { + const hex = String(value).trim(); + const match = hex.match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i); + if (!match) return null; + const raw = match[1]; + const normalized = raw.length === 3 + ? raw.split('').map((part) => `${part}${part}`).join('') + : raw.slice(0, 6); + return { + r: Number.parseInt(normalized.slice(0, 2), 16), + g: Number.parseInt(normalized.slice(2, 4), 16), + b: Number.parseInt(normalized.slice(4, 6), 16), + }; +} + +function toCssRgb(color) { + return `rgb(${color.r}, ${color.g}, ${color.b})`; +} + +function createStyleDeclaration() { + let color = ''; + return { + position: '', + visibility: '', + pointerEvents: '', + get color() { + return color; + }, + set color(value) { + const raw = String(value || '').trim(); + if (!raw) { + color = ''; + return; + } + if (parseHexColor(raw) || parseRgbTriplet(raw) || NAMED_COLORS.has(raw.toLowerCase()) || /^var\(--[^)]+\)$/i.test(raw)) { + color = raw; + return; + } + color = ''; + }, + }; +} + +function resolveComputedColor(value, cssVariables) { + const raw = String(value || '').trim(); + const variableMatch = raw.match(/^var\((--[^)]+)\)$/i); + const color = variableMatch ? String(cssVariables.get(variableMatch[1]) || '').trim() : raw; + const rgb = parseHexColor(color) || parseRgbTriplet(color) || NAMED_COLORS.get(color.toLowerCase()); + return rgb ? toCssRgb(rgb) : color; +} + +function createIconTintHarness() { + const moduleCache = new Map(); + const cssVariables = new Map([ + ['--surface-base-rgb', '247, 248, 250'], + ['--accent-color', '#336699'], + ]); + let dark = false; + let rootStyleVersion = 0; + let elementCreations = 0; + let bodyAppends = 0; + let computedStyleCalls = 0; + + const documentElement = { + get className() { + return dark ? 'dark' : ''; + }, + classList: { + contains(className) { + return className === 'dark' && dark; + }, + }, + getAttribute(attributeName) { + if (attributeName !== 'style') return ''; + return `--style-version: ${rootStyleVersion}`; + }, + }; + + const documentStub = { + documentElement, + body: { + appendChild(element) { + bodyAppends += 1; + element.parentNode = this; + }, + }, + createElement() { + elementCreations += 1; + return { + parentNode: null, + style: createStyleDeclaration(), + remove() { + this.parentNode = null; + }, + }; + }, + }; + + const windowStub = { + getComputedStyle(target) { + computedStyleCalls += 1; + if (target === documentElement) { + return { + color: dark ? 'rgb(255, 255, 255)' : 'rgb(17, 23, 32)', + getPropertyValue(variableName) { + return cssVariables.get(variableName) || ''; + }, + }; + } + return { + color: resolveComputedColor(target?.style?.color, cssVariables), + getPropertyValue() { + return ''; + }, + }; + }, + }; + + class MutationObserverStub { + observe() {} + disconnect() {} + } + + function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + window: windowStub, + document: documentStub, + MutationObserver: MutationObserverStub, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; + } + + function setDark(nextDark) { + dark = nextDark; + cssVariables.set('--surface-base-rgb', dark ? '30, 31, 36' : '247, 248, 250'); + } + + function setCssVariable(variableName, value) { + cssVariables.set(variableName, value); + rootStyleVersion += 1; + } + + function resetCounts() { + elementCreations = 0; + bodyAppends = 0; + computedStyleCalls = 0; + } + + return { + assets: loadTsModule('src/renderer/src/raycast-api/icon-runtime-assets.tsx'), + setDark, + setCssVariable, + resetCounts, + get counts() { + return { + elementCreations, + bodyAppends, + computedStyleCalls, + }; + }, + }; +} + +function measureScenario(name, scenario) { + const harness = createIconTintHarness(); + const start = performance.now(); + const result = scenario(harness); + const elapsedMs = performance.now() - start; + const counts = harness.counts; + console.log( + `icon-tint-cache measurement: scenario=${name} iterations=${ITERATIONS} elementCreations=${counts.elementCreations} bodyAppends=${counts.bodyAppends} getComputedStyle=${counts.computedStyleCalls} elapsedMs=${elapsedMs.toFixed(3)}`, + ); + return { result, counts }; +} + +function assertCacheCount(actual, max, label) { + if (MEASURE_ONLY) return; + assert.ok(actual <= max, `${label}: expected <= ${max}, got ${actual}`); +} + +test('icon tint color cache', async (t) => { + await t.test('preserves string, hex, invalid, and object tint semantics', () => { + const harness = createIconTintHarness(); + const { resolveTintColor } = harness.assets; + + assert.equal(resolveTintColor('336699'), '#336699'); + assert.equal(resolveTintColor('#336699'), '#336699'); + assert.equal(resolveTintColor('rebeccapurple'), 'rebeccapurple'); + assert.equal(resolveTintColor('definitely-not-a-color'), undefined); + + const tint = { light: 'red', dark: 'blue' }; + assert.equal(resolveTintColor(tint), 'red'); + harness.setDark(true); + assert.equal(resolveTintColor(tint), 'blue'); + + assert.equal(resolveTintColor({ light: 'red' }), 'red'); + harness.setDark(false); + assert.equal(resolveTintColor({ dark: 'blue' }), 'blue'); + }); + + await t.test('caches repeated string tint validation', () => { + const { result, counts } = measureScenario('resolveTintColor-string', (harness) => { + let resolved; + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveTintColor('336699'); + } + return resolved; + }); + + assert.equal(result, '#336699'); + assertCacheCount(counts.elementCreations, 1, 'string tint validation element creations'); + }); + + await t.test('caches repeated object tint validation per selected theme color', () => { + const { result, counts } = measureScenario('resolveTintColor-object-light-dark', (harness) => { + const tint = { light: 'red', dark: 'blue' }; + let resolved = ''; + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveTintColor(tint) || ''; + } + harness.setDark(true); + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveTintColor(tint) || ''; + } + return resolved; + }); + + assert.equal(result, 'blue'); + assertCacheCount(counts.elementCreations, 2, 'object tint validation element creations'); + }); + + await t.test('caches readable tint DOM parsing and root CSS variable reads', () => { + const { result, counts } = measureScenario('resolveReadableTintColor-repeat', (harness) => { + let resolved; + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveReadableTintColor('#777777', { minContrast: 4.25 }); + } + return resolved; + }); + + assert.equal(typeof result, 'string'); + assertCacheCount(counts.elementCreations, 2, 'readable tint element creations'); + assertCacheCount(counts.computedStyleCalls, 2, 'readable tint getComputedStyle calls'); + }); + + await t.test('invalidates readable tint results when theme or root style changes', () => { + const harness = createIconTintHarness(); + const { resolveReadableTintColor } = harness.assets; + + const light = resolveReadableTintColor('#777777', { minContrast: 4.25 }); + const lightCounts = harness.counts; + harness.setDark(true); + const dark = resolveReadableTintColor('#777777', { minContrast: 4.25 }); + const darkCounts = harness.counts; + harness.setCssVariable('--surface-base-rgb', '5, 5, 5'); + const darker = resolveReadableTintColor('#777777', { minContrast: 4.25 }); + + assert.notEqual(light, dark); + assert.equal(typeof dark, 'string'); + assert.equal(darker, '#777777'); + assert.notEqual(dark, darker); + assert.ok(darkCounts.computedStyleCalls > lightCounts.computedStyleCalls); + assert.ok(harness.counts.computedStyleCalls > darkCounts.computedStyleCalls); + }); +}); diff --git a/src/renderer/src/raycast-api/icon-runtime-assets.tsx b/src/renderer/src/raycast-api/icon-runtime-assets.tsx index 05731627..1475bc20 100644 --- a/src/renderer/src/raycast-api/icon-runtime-assets.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-assets.tsx @@ -6,12 +6,31 @@ import React from 'react'; import { getIconRuntimeContext } from './icon-runtime-config'; +const CSS_COLOR_CACHE_MAX = 1024; +const normalizedCssColorCache = new Map(); +const validCssColorCache = new Map(); +const parsedCssColorCache = new Map(); +const cssRgbVarCache = new Map(); +const readableTintColorCache = new Map(); +let observedThemeRoot: HTMLElement | null = null; +let themeMutationObserver: MutationObserver | null = null; +let themeCacheVersion = 0; +let lastThemeSignature = ''; + type RgbColor = { r: number; g: number; b: number; }; +function setBoundedCacheValue(cache: Map, key: K, value: V, max = CSS_COLOR_CACHE_MAX): V { + if (!cache.has(key) && cache.size >= max) { + cache.clear(); + } + cache.set(key, value); + return value; +} + export function isEmojiOrSymbol(input: unknown): boolean { const s = typeof input === 'string' ? input.trim() : ''; if (!s) return false; @@ -101,69 +120,171 @@ export function resolveIconSrc(src: string, assetsPathOverride?: string): string return raw; } +function getDocumentElement(): HTMLElement | null { + if (typeof document === 'undefined') return null; + return document.documentElement || null; +} + +function readPrefersDark(): boolean { + return Boolean(getDocumentElement()?.classList?.contains('dark')); +} + +function invalidateThemeColorCaches(): void { + themeCacheVersion += 1; + parsedCssColorCache.clear(); + cssRgbVarCache.clear(); + readableTintColorCache.clear(); +} + +function getThemeSignature(root: HTMLElement, prefersDark: boolean): string { + const className = typeof root.className === 'string' + ? root.className + : (root.getAttribute?.('class') || ''); + const styleAttribute = root.getAttribute?.('style') || ''; + return `${prefersDark ? 'dark' : 'light'}|${className}|${styleAttribute}`; +} + +function ensureThemeObserver(root: HTMLElement): void { + if (observedThemeRoot === root) return; + try { + themeMutationObserver?.disconnect(); + } catch { + // Ignore observer cleanup failures. + } + + observedThemeRoot = root; + themeMutationObserver = null; + + if (typeof MutationObserver === 'undefined') return; + + try { + themeMutationObserver = new MutationObserver(() => { + invalidateThemeColorCaches(); + }); + themeMutationObserver.observe(root, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + } catch { + themeMutationObserver = null; + } +} + +function getThemeCacheKey(): string { + const root = getDocumentElement(); + if (!root) return `no-document:${themeCacheVersion}`; + + ensureThemeObserver(root); + const prefersDark = readPrefersDark(); + const signature = getThemeSignature(root, prefersDark); + if (signature !== lastThemeSignature) { + lastThemeSignature = signature; + invalidateThemeColorCaches(); + } + + return `${prefersDark ? 'dark' : 'light'}:${themeCacheVersion}`; +} + +function resolveTintColorString(raw: string): string | undefined { + const normalized = normalizeCssColor(raw); + return isValidCssColor(normalized) ? normalized : undefined; +} + +function canCacheResolvedCssColor(value: string): boolean { + return !/(var\(|env\(|light-dark\(|currentcolor|inherit|initial|revert|unset)/i.test(value); +} + export function resolveTintColor(tintColor: any): string | undefined { if (!tintColor) return undefined; if (typeof tintColor === 'string') { - const normalized = normalizeCssColor(tintColor); - return isValidCssColor(normalized) ? normalized : undefined; + return resolveTintColorString(tintColor); } if (typeof tintColor === 'object') { - const prefersDark = document.documentElement.classList.contains('dark'); + const prefersDark = readPrefersDark(); const raw = prefersDark ? (tintColor.dark || tintColor.light) : (tintColor.light || tintColor.dark); if (typeof raw !== 'string') return undefined; - const normalized = normalizeCssColor(raw); - return isValidCssColor(normalized) ? normalized : undefined; + return resolveTintColorString(raw); } return undefined; } function isValidCssColor(value: string): boolean { + if (!value) return false; + const cached = validCssColorCache.get(value); + if (cached !== undefined) return cached; + if (typeof document === 'undefined') return false; + + let isValid = false; try { const el = document.createElement('span'); el.style.color = ''; el.style.color = value; - return Boolean(el.style.color); + isValid = Boolean(el.style.color); } catch { - return false; + isValid = false; } + return setBoundedCacheValue(validCssColorCache, value, isValid); } function normalizeCssColor(value: string): string { + const cached = normalizedCssColorCache.get(value); + if (cached !== undefined) return cached; + const v = value.trim(); - if (/^[0-9a-f]{3}$/i.test(v) || /^[0-9a-f]{6}$/i.test(v) || /^[0-9a-f]{8}$/i.test(v)) return `#${v}`; - return v; + const normalized = /^[0-9a-f]{3}$/i.test(v) || /^[0-9a-f]{6}$/i.test(v) || /^[0-9a-f]{8}$/i.test(v) + ? `#${v}` + : v; + return setBoundedCacheValue(normalizedCssColorCache, value, normalized); } -function parseCssColorToRgb(value: string): RgbColor | null { +function parseCssColorToRgb(value: string, themeKey: string): RgbColor | null { + const canCache = canCacheResolvedCssColor(value); + const cacheKey = `${themeKey}|${value}`; + if (canCache && parsedCssColorCache.has(cacheKey)) return parsedCssColorCache.get(cacheKey) || null; if (typeof document === 'undefined' || !document.body) return null; - const el = document.createElement('span'); - el.style.position = 'absolute'; - el.style.visibility = 'hidden'; - el.style.pointerEvents = 'none'; - el.style.color = value; - document.body.appendChild(el); - const computed = window.getComputedStyle(el).color; - el.remove(); - - const match = computed.match(/rgba?\(([^)]+)\)/i); - if (!match) return null; - const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); - if (parts.length < 3 || parts.slice(0, 3).some((part) => Number.isNaN(part))) return null; - return { - r: Math.max(0, Math.min(255, Math.round(parts[0]))), - g: Math.max(0, Math.min(255, Math.round(parts[1]))), - b: Math.max(0, Math.min(255, Math.round(parts[2]))), - }; + + let parsed: RgbColor | null = null; + try { + const el = document.createElement('span'); + el.style.position = 'absolute'; + el.style.visibility = 'hidden'; + el.style.pointerEvents = 'none'; + el.style.color = value; + document.body.appendChild(el); + const computed = window.getComputedStyle(el).color; + el.remove(); + + const match = computed.match(/rgba?\(([^)]+)\)/i); + if (match) { + const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); + if (parts.length >= 3 && parts.slice(0, 3).every((part) => Number.isFinite(part))) { + parsed = { + r: Math.max(0, Math.min(255, Math.round(parts[0]))), + g: Math.max(0, Math.min(255, Math.round(parts[1]))), + b: Math.max(0, Math.min(255, Math.round(parts[2]))), + }; + } + } + } catch { + parsed = null; + } + + return canCache ? setBoundedCacheValue(parsedCssColorCache, cacheKey, parsed) : parsed; } -function readCssRgbVar(variableName: string, fallback: RgbColor): RgbColor { +function readCssRgbVar(variableName: string, fallback: RgbColor, themeKey: string): RgbColor { + const cacheKey = `${themeKey}|${variableName}|${fallback.r},${fallback.g},${fallback.b}`; + const cached = cssRgbVarCache.get(cacheKey); + if (cached) return cached; + + let resolved = fallback; try { const raw = window.getComputedStyle(document.documentElement).getPropertyValue(variableName).trim(); const parts = raw.split(',').map((part) => Number.parseFloat(part.trim())); if (parts.length >= 3 && parts.slice(0, 3).every((part) => Number.isFinite(part))) { - return { + resolved = { r: Math.max(0, Math.min(255, Math.round(parts[0]))), g: Math.max(0, Math.min(255, Math.round(parts[1]))), b: Math.max(0, Math.min(255, Math.round(parts[2]))), @@ -172,7 +293,7 @@ function readCssRgbVar(variableName: string, fallback: RgbColor): RgbColor { } catch { // fall through to fallback } - return fallback; + return setBoundedCacheValue(cssRgbVarCache, cacheKey, resolved); } function mixRgb(base: RgbColor, target: RgbColor, amount: number): RgbColor { @@ -208,16 +329,26 @@ export function resolveReadableTintColor(tintColor: any, options?: { minContrast const resolved = resolveTintColor(tintColor); if (!resolved) return undefined; - const color = parseCssColorToRgb(resolved); - if (!color) return resolved; + const themeKey = getThemeCacheKey(); + const minContrast = options?.minContrast ?? 4.5; + const canCache = canCacheResolvedCssColor(resolved); + const readableCacheKey = `${themeKey}|${resolved}|${minContrast}`; + const cached = canCache ? readableTintColorCache.get(readableCacheKey) : undefined; + if (cached) return cached; + + const color = parseCssColorToRgb(resolved, themeKey); + if (!color) { + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, resolved) : resolved; + } - const prefersDark = document.documentElement.classList.contains('dark'); + const prefersDark = readPrefersDark(); const background = readCssRgbVar('--surface-base-rgb', prefersDark ? { r: 30, g: 31, b: 36 } - : { r: 247, g: 248, b: 250 }); - const minContrast = options?.minContrast ?? 4.5; + : { r: 247, g: 248, b: 250 }, themeKey); - if (contrastRatio(color, background) >= minContrast) return resolved; + if (contrastRatio(color, background) >= minContrast) { + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, resolved) : resolved; + } const target = prefersDark ? { r: 255, g: 255, b: 255 } @@ -226,11 +357,13 @@ export function resolveReadableTintColor(tintColor: any, options?: { minContrast for (let step = 1; step <= 12; step += 1) { const adjusted = mixRgb(color, target, step / 12); if (contrastRatio(adjusted, background) >= minContrast) { - return formatRgb(adjusted); + const readable = formatRgb(adjusted); + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, readable) : readable; } } - return formatRgb(mixRgb(color, target, 1)); + const readable = formatRgb(mixRgb(color, target, 1)); + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, readable) : readable; } export function addHexAlpha(color: string, alphaHex: string): string | undefined {