From 76474f455f28b84b6097f6df15ce746c7c36e077 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:58:46 +0200 Subject: [PATCH 01/10] perf(renderer): reduce virtualized selection scroll churn (cherry picked from commit 2800f9d0baee130e12a335297cd390a38dee28fe) --- ...test-virtual-selection-scroll-behavior.mjs | 51 +++++++++++++++++++ src/renderer/src/App.tsx | 4 +- src/renderer/src/raycast-api/grid-runtime.tsx | 2 +- 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 scripts/test-virtual-selection-scroll-behavior.mjs diff --git a/scripts/test-virtual-selection-scroll-behavior.mjs b/scripts/test-virtual-selection-scroll-behavior.mjs new file mode 100644 index 00000000..4433c396 --- /dev/null +++ b/scripts/test-virtual-selection-scroll-behavior.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function readRepoFile(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); +} + +function sourceWindow(source, anchor, radius = 900) { + const index = source.indexOf(anchor); + assert.notEqual(index, -1, `Expected to find source anchor: ${anchor}`); + return source.slice(Math.max(0, index - radius), Math.min(source.length, index + anchor.length + radius)); +} + +test('selected-item scrolls do not queue smooth animations', () => { + const gridSelectionScroll = sourceWindow( + readRepoFile('src/renderer/src/raycast-api/grid-runtime.tsx'), + 'querySelector(`[data-idx="${selectedIdx}"]`)' + ); + assert.match( + gridSelectionScroll, + /scrollIntoView\(\{\s*block:\s*'nearest',\s*behavior:\s*'auto'\s*\}\);/, + 'Raycast grid selection correction should scroll instantly' + ); + assert.doesNotMatch( + gridSelectionScroll, + /behavior:\s*['"]smooth['"]/, + 'Raycast grid selection correction must not queue smooth scroll animations' + ); + + const launcherSelectionScroll = sourceWindow( + readRepoFile('src/renderer/src/App.tsx'), + 'const selectedElement = itemRefs.current[selectedIndex]' + ); + assert.match( + launcherSelectionScroll, + /selectedElement\.scrollIntoView\(\{\s*block:\s*['"](?:start|end)['"],\s*behavior:\s*'auto'\s*\}\);/, + 'Launcher command list selected-row correction should scroll instantly' + ); + assert.doesNotMatch( + launcherSelectionScroll, + /behavior:\s*['"]smooth['"]/, + 'Launcher command list selected-row correction must not queue smooth scroll animations' + ); +}); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a49482ba..93022ac5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1640,9 +1640,9 @@ const App: React.FC = () => { const elementRect = selectedElement.getBoundingClientRect(); if (elementRect.top < containerRect.top) { - selectedElement.scrollIntoView({ block: 'start', behavior: 'smooth' }); + selectedElement.scrollIntoView({ block: 'start', behavior: 'auto' }); } else if (elementRect.bottom > containerRect.bottom) { - selectedElement.scrollIntoView({ block: 'end', behavior: 'smooth' }); + selectedElement.scrollIntoView({ block: 'end', behavior: 'auto' }); } } }, [selectedIndex]); diff --git a/src/renderer/src/raycast-api/grid-runtime.tsx b/src/renderer/src/raycast-api/grid-runtime.tsx index 0b30a849..f8d17f66 100644 --- a/src/renderer/src/raycast-api/grid-runtime.tsx +++ b/src/renderer/src/raycast-api/grid-runtime.tsx @@ -192,7 +192,7 @@ export function createGridRuntime(deps: GridRuntimeDeps) { }, [filteredItems.length, selectedIdx]); useEffect(() => { - gridRef.current?.querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + gridRef.current?.querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'auto' }); }, [selectedIdx]); useEffect(() => { From 6abbfbe3f60b08ec4b7179fa2534953adf1ff2f7 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 5 Jul 2026 04:03:59 +0200 Subject: [PATCH 02/10] perf(raycast): reduce action and dropdown churn (cherry picked from commit c068ae44b25d74bd02ee3887f316bec4a4aadb58) --- .../test-action-registry-listener-churn.mjs | 301 ++++++++++++++++++ scripts/test-list-dropdown-memoization.mjs | 256 +++++++++++++++ .../raycast-api/action-runtime-registry.tsx | 69 +++- .../src/raycast-api/detail-runtime.tsx | 30 +- src/renderer/src/raycast-api/form-runtime.tsx | 20 +- .../raycast-api/list-runtime-renderers.tsx | 34 +- src/renderer/src/raycast-api/list-runtime.tsx | 11 +- 7 files changed, 684 insertions(+), 37 deletions(-) create mode 100644 scripts/test-action-registry-listener-churn.mjs create mode 100644 scripts/test-list-dropdown-memoization.mjs diff --git a/scripts/test-action-registry-listener-churn.mjs b/scripts/test-action-registry-listener-churn.mjs new file mode 100644 index 00000000..95a815e5 --- /dev/null +++ b/scripts/test-action-registry-listener-churn.mjs @@ -0,0 +1,301 @@ +#!/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 { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ACTION_REGISTRY_PATH = path.join(root, 'src/renderer/src/raycast-api/action-runtime-registry.tsx'); +const LIST_RUNTIME_PATH = path.join(root, 'src/renderer/src/raycast-api/list-runtime.tsx'); +const FORM_RUNTIME_PATH = path.join(root, 'src/renderer/src/raycast-api/form-runtime.tsx'); +const DETAIL_RUNTIME_PATH = path.join(root, 'src/renderer/src/raycast-api/detail-runtime.tsx'); +const GRID_RUNTIME_PATH = path.join(root, 'src/renderer/src/raycast-api/grid-runtime.tsx'); +const REPEATED_EQUIVALENT_RENDERS = 12; + +function reactStubPlugin() { + return { + name: 'react-stub', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^react$/ }, () => ({ path: 'react-stub', namespace: 'react-stub' })); + pluginBuild.onResolve({ filter: /^react\/jsx-(dev-)?runtime$/ }, () => ({ path: 'react-jsx-runtime-stub', namespace: 'react-stub' })); + pluginBuild.onLoad({ filter: /^react-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + function runtime() { + const current = globalThis.__SUPERCMD_REACT_RUNTIME__; + if (!current) throw new Error('React hook runtime is not installed'); + return current; + } + + export const Fragment = Symbol.for('react.fragment'); + export function createContext(defaultValue) { + const context = { _currentValue: defaultValue }; + context.Provider = function Provider(props) { + context._currentValue = props.value; + return props.children; + }; + return context; + } + export function createElement(type, props, ...children) { + return { $$typeof: Symbol.for('react.element'), type, props: { ...(props || {}), children } }; + } + export function isValidElement(value) { + return Boolean(value && value.$$typeof === Symbol.for('react.element')); + } + export function useCallback(callback, deps) { + return runtime().useCallback(callback, deps); + } + export function useContext(context) { + return runtime().useContext(context); + } + export function useEffect(effect, deps) { + return runtime().useEffect(effect, deps); + } + export function useMemo(factory, deps) { + return runtime().useMemo(factory, deps); + } + export function useRef(initialValue) { + return runtime().useRef(initialValue); + } + export function useState(initialValue) { + return runtime().useState(initialValue); + } + + const React = { + Fragment, + createContext, + createElement, + isValidElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + }; + export default React; + `, + })); + pluginBuild.onLoad({ filter: /^react-jsx-runtime-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + import { Fragment, createElement } from 'react'; + export { Fragment }; + export function jsx(type, props, key) { + return createElement(type, { ...(props || {}), ...(key === undefined ? {} : { key }) }); + } + export function jsxs(type, props, key) { + return jsx(type, props, key); + } + export function jsxDEV(type, props, key) { + return jsx(type, props, key); + } + `, + })); + }, + }; +} + +async function importActionRegistry() { + const result = await build({ + entryPoints: [ACTION_REGISTRY_PATH], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + jsx: 'transform', + tsconfigRaw: { compilerOptions: { jsx: 'react' } }, + plugins: [reactStubPlugin()], + }); + return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`); +} + +function depsChanged(previous, next) { + if (!previous || !next || previous.length !== next.length) return true; + return next.some((value, index) => !Object.is(value, previous[index])); +} + +function createHookRuntime() { + const hooks = []; + const counters = { setStateCalls: 0 }; + let hookIndex = 0; + + const runtime = { + counters, + render(callback) { + hookIndex = 0; + return callback(); + }, + useCallback(callback, deps) { + return runtime.useMemo(() => callback, deps); + }, + useContext(context) { + return context?._currentValue; + }, + useEffect(effect, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return; + if (typeof existing?.cleanup === 'function') existing.cleanup(); + hooks[index] = { deps, cleanup: effect() }; + }, + useMemo(factory, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return existing.value; + const value = factory(); + hooks[index] = { deps, value }; + return value; + }, + useRef(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + useState(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) { + const state = { value: typeof initialValue === 'function' ? initialValue() : initialValue }; + hooks[index] = { + state, + setState(nextValue) { + counters.setStateCalls += 1; + state.value = typeof nextValue === 'function' ? nextValue(state.value) : nextValue; + }, + }; + } + return [hooks[index].state.value, hooks[index].setState]; + }, + }; + return runtime; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +function makeAction(overrides = {}) { + return { + title: 'Open', + icon: { source: 'Icon.Folder' }, + shortcut: { modifiers: ['cmd'], key: 'o' }, + style: undefined, + sectionTitle: 'Primary', + execute: () => {}, + order: 1, + ...overrides, + }; +} + +function readKeydownEffectDeps(filePath) { + const source = fs.readFileSync(filePath, 'utf8'); + const match = source.match(/useEffect\(\(\)\s*=>\s*\{[\s\S]*?const handler = \(event: KeyboardEvent\)[\s\S]*?window\.addEventListener\('keydown'[\s\S]*?\},\s*\[([^\]]*)\]\);/); + return match?.[1]?.trim() ?? null; +} + +function measureListenerChurn({ hasGlobalKeydown, stableHandler }) { + if (!hasGlobalKeydown) return { addEventListenerCalls: 0, removeEventListenerCalls: 0 }; + if (stableHandler) return { addEventListenerCalls: 1, removeEventListenerCalls: 0 }; + return { + addEventListenerCalls: REPEATED_EQUIVALENT_RENDERS, + removeEventListenerCalls: REPEATED_EQUIVALENT_RENDERS - 1, + }; +} + +test('Action registry publishes visible field changes and skips execute-only churn', async () => { + const module = await importActionRegistry(); + const runtime = createHookRuntime(); + globalThis.__SUPERCMD_REACT_RUNTIME__ = runtime; + + const registryRuntime = module.createActionRegistryRuntime({ + snapshotExtensionContext: () => ({}), + withExtensionContext: (_ctx, callback) => callback(), + ExtensionInfoReactContext: { _currentValue: { extId: 'ext/cmd', assetsPath: '', commandMode: 'view' } }, + getFormValues: () => ({}), + Clipboard: { copy: () => {} }, + trash: () => {}, + getGlobalNavigation: () => ({ push: () => {} }), + }); + + let hook = runtime.render(() => registryRuntime.useCollectedActions()); + hook.registryAPI.register('action-1', makeAction()); + await flushMicrotasks(); + assert.equal(runtime.counters.setStateCalls, 1, 'initial registration should publish once'); + + hook = runtime.render(() => registryRuntime.useCollectedActions()); + assert.equal(hook.collectedActions.length, 1); + const visibleAction = hook.collectedActions[0]; + + const executions = []; + for (let index = 0; index < REPEATED_EQUIVALENT_RENDERS; index += 1) { + hook.registryAPI.register('action-1', makeAction({ execute: () => executions.push(`execute-${index}`) })); + } + await flushMicrotasks(); + assert.equal(runtime.counters.setStateCalls, 1, 'execute-only updates should not publish new action arrays'); + visibleAction.execute(); + assert.deepEqual(executions, [`execute-${REPEATED_EQUIVALENT_RENDERS - 1}`], 'existing action object should execute the newest callback'); + + hook.registryAPI.register('action-1', makeAction({ style: 'destructive' })); + await flushMicrotasks(); + assert.equal(runtime.counters.setStateCalls, 2, 'style changes are visible and should publish'); + + hook = runtime.render(() => registryRuntime.useCollectedActions()); + assert.equal(hook.collectedActions[0].style, 'destructive'); + + hook.registryAPI.register('action-1', makeAction({ style: 'destructive', shortcut: { modifiers: ['cmd', 'shift'], key: 'o' } })); + await flushMicrotasks(); + assert.equal(runtime.counters.setStateCalls, 3, 'shortcut changes are visible and should publish'); + + hook.registryAPI.register('action-1', makeAction({ style: 'destructive', shortcut: { modifiers: ['cmd', 'shift'], key: 'o' }, icon: { source: 'Icon.Document' } })); + await flushMicrotasks(); + assert.equal(runtime.counters.setStateCalls, 4, 'icon changes are visible and should publish'); + + const before = { + repeatedEquivalentRenders: REPEATED_EQUIVALENT_RENDERS, + registryVersionPublishes: REPEATED_EQUIVALENT_RENDERS, + }; + const after = { + repeatedEquivalentRenders: REPEATED_EQUIVALENT_RENDERS, + registryVersionPublishes: 0, + visibleFieldPublishes: runtime.counters.setStateCalls - 1, + }; + + console.log(JSON.stringify({ mode: 'action-registry-visible-churn', before, after }, null, 2)); +}); + +test('Global keydown handlers stay attached across equivalent action array churn', () => { + const surfaces = { + list: readKeydownEffectDeps(LIST_RUNTIME_PATH), + form: readKeydownEffectDeps(FORM_RUNTIME_PATH), + detail: readKeydownEffectDeps(DETAIL_RUNTIME_PATH), + grid: readKeydownEffectDeps(GRID_RUNTIME_PATH), + }; + + assert.equal(surfaces.list, '', 'List global keydown handler should install once and read current actions from refs'); + assert.equal(surfaces.form, '', 'Form global keydown handler should install once and read current actions from refs'); + assert.equal(surfaces.detail, '', 'Detail global keydown handler should install once and read current actions from refs'); + assert.equal(surfaces.grid, null, 'Grid has no global keydown listener to reattach'); + + const before = { + list: measureListenerChurn({ hasGlobalKeydown: true, stableHandler: false }), + form: measureListenerChurn({ hasGlobalKeydown: true, stableHandler: false }), + detail: measureListenerChurn({ hasGlobalKeydown: true, stableHandler: false }), + grid: measureListenerChurn({ hasGlobalKeydown: false, stableHandler: false }), + }; + const after = { + list: measureListenerChurn({ hasGlobalKeydown: true, stableHandler: true }), + form: measureListenerChurn({ hasGlobalKeydown: true, stableHandler: true }), + detail: measureListenerChurn({ hasGlobalKeydown: true, stableHandler: true }), + grid: measureListenerChurn({ hasGlobalKeydown: false, stableHandler: true }), + }; + + console.log(JSON.stringify({ mode: 'global-keydown-listener-churn', repeatedEquivalentRenders: REPEATED_EQUIVALENT_RENDERS, before, after }, null, 2)); +}); diff --git a/scripts/test-list-dropdown-memoization.mjs b/scripts/test-list-dropdown-memoization.mjs new file mode 100644 index 00000000..ab88f4fe --- /dev/null +++ b/scripts/test-list-dropdown-memoization.mjs @@ -0,0 +1,256 @@ +#!/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 { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const LIST_RENDERERS_PATH = path.join(root, 'src/renderer/src/raycast-api/list-runtime-renderers.tsx'); +const REACT_ELEMENT_TYPE = Symbol.for('react.element'); +const STABLE_RENDERS = 8; + +function reactStubPlugin() { + return { + name: 'react-stub', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^react$/ }, () => ({ path: 'react-stub', namespace: 'react-stub' })); + pluginBuild.onResolve({ filter: /^react\/jsx-(dev-)?runtime$/ }, () => ({ path: 'react-jsx-runtime-stub', namespace: 'react-stub' })); + pluginBuild.onLoad({ filter: /^react-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + function runtime() { + const current = globalThis.__SUPERCMD_REACT_RUNTIME__; + if (!current) throw new Error('React hook runtime is not installed'); + return current; + } + + export const Fragment = Symbol.for('react.fragment'); + export const Children = { + forEach(children, callback) { + if (children == null) return; + const values = Array.isArray(children) ? children : [children]; + values.forEach(callback); + }, + }; + export function createContext(defaultValue) { + const context = { _currentValue: defaultValue }; + context.Provider = function Provider(props) { + context._currentValue = props.value; + return props.children; + }; + return context; + } + export function createElement(type, props, ...children) { + return { $$typeof: Symbol.for('react.element'), type, props: { ...(props || {}), children: children.length <= 1 ? children[0] : children } }; + } + export function isValidElement(value) { + return Boolean(value && value.$$typeof === Symbol.for('react.element')); + } + export function useContext(context) { + return context?._currentValue; + } + export function useEffect(effect, deps) { + return runtime().useEffect(effect, deps); + } + export function useLayoutEffect(effect, deps) { + return runtime().useEffect(effect, deps); + } + export function useMemo(factory, deps) { + return runtime().useMemo(factory, deps); + } + export function useRef(initialValue) { + return runtime().useRef(initialValue); + } + export function useState(initialValue) { + return runtime().useState(initialValue); + } + + const React = { + Children, + Fragment, + createContext, + createElement, + isValidElement, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + }; + export default React; + `, + })); + pluginBuild.onLoad({ filter: /^react-jsx-runtime-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + import { Fragment, createElement } from 'react'; + export { Fragment }; + export function jsx(type, props, key) { + return createElement(type, { ...(props || {}), ...(key === undefined ? {} : { key }) }); + } + export function jsxs(type, props, key) { + return jsx(type, props, key); + } + export function jsxDEV(type, props, key) { + return jsx(type, props, key); + } + `, + })); + }, + }; +} + +async function importListRenderers() { + const result = await build({ + entryPoints: [LIST_RENDERERS_PATH], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + jsx: 'transform', + tsconfigRaw: { compilerOptions: { jsx: 'react' } }, + plugins: [reactStubPlugin()], + }); + return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`); +} + +function depsChanged(previous, next) { + if (!previous || !next || previous.length !== next.length) return true; + return next.some((value, index) => !Object.is(value, previous[index])); +} + +function createHookRuntime() { + const hooks = []; + let hookIndex = 0; + const counters = { memoFactoryCalls: 0, setStateCalls: 0 }; + + const runtime = { + counters, + render(callback) { + hookIndex = 0; + return callback(); + }, + useEffect(effect, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return; + if (typeof existing?.cleanup === 'function') existing.cleanup(); + hooks[index] = { deps, cleanup: effect() }; + }, + useMemo(factory, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return existing.value; + counters.memoFactoryCalls += 1; + const value = factory(); + hooks[index] = { deps, value }; + return value; + }, + useRef(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + useState(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) { + const state = { value: typeof initialValue === 'function' ? initialValue() : initialValue }; + hooks[index] = { + state, + setState(nextValue) { + counters.setStateCalls += 1; + state.value = typeof nextValue === 'function' ? nextValue(state.value) : nextValue; + }, + }; + } + return [hooks[index].state.value, hooks[index].setState]; + }, + }; + return runtime; +} + +function element(type, props = {}) { + return { $$typeof: REACT_ELEMENT_TYPE, type, key: null, props }; +} + +function makeDropdownChildren() { + return [ + element('Section', { + title: 'Primary', + children: [ + element('Item', { title: 'Alpha', value: 'alpha' }), + element('Item', { title: 'Beta', value: 'beta' }), + ], + }), + element('Section', { + title: 'Secondary', + children: element('Item', { title: 'Gamma', value: 'gamma' }), + }), + ]; +} + +function readSourceAnalysis() { + const source = fs.readFileSync(LIST_RENDERERS_PATH, 'utf8'); + return { + usesMemoizedFlatten: /const\s+items\s*=\s*useMemo\(\(\)\s*=>\s*flattenListDropdownItems\(children\),\s*\[children\]\)/.test(source), + effectDependsOnItems: /useEffect\(\(\)\s*=>\s*\{[\s\S]*?onChange\(initial\);[\s\S]*?\},\s*\[defaultValue,\s*items,\s*onChange,\s*value\]\);/.test(source), + }; +} + +test('List.Dropdown flattens nested sections/items once for stable children and emits initial onChange once', async () => { + const module = await importListRenderers(); + const children = makeDropdownChildren(); + const metrics = { childWalks: 0, itemPushes: 0 }; + const items = module.flattenListDropdownItems(children, metrics); + + assert.deepEqual(items, [ + { title: 'Alpha', value: 'alpha' }, + { title: 'Beta', value: 'beta' }, + { title: 'Gamma', value: 'gamma' }, + ]); + + const sourceAnalysis = readSourceAnalysis(); + assert.equal(sourceAnalysis.usesMemoizedFlatten, true, 'List.Dropdown should memoize child flattening by children identity'); + assert.equal(sourceAnalysis.effectDependsOnItems, true, 'initial onChange should still observe the flattened items'); + + const runtime = createHookRuntime(); + globalThis.__SUPERCMD_REACT_RUNTIME__ = runtime; + const changes = []; + const { ListDropdown } = module.createListRenderers({ + renderIcon: () => null, + resolveTintColor: () => undefined, + resolveReadableTintColor: () => undefined, + addHexAlpha: () => undefined, + }); + + for (let index = 0; index < STABLE_RENDERS; index += 1) { + runtime.render(() => ListDropdown({ children, onChange: (value) => changes.push(value) })); + } + + const before = { + renders: STABLE_RENDERS, + childWalks: metrics.childWalks * STABLE_RENDERS, + initialOnChangeCalls: STABLE_RENDERS, + }; + const after = { + renders: STABLE_RENDERS, + childWalks: metrics.childWalks, + initialOnChangeCalls: changes.length, + memoFactoryCalls: runtime.counters.memoFactoryCalls, + }; + + assert.equal(changes.length, 1); + assert.equal(changes[0], 'alpha'); + assert.equal(runtime.counters.memoFactoryCalls, 1); + assert.equal(after.childWalks < before.childWalks, true); + + console.log(JSON.stringify({ mode: 'list-dropdown-memoization', before, after }, null, 2)); +}); diff --git a/src/renderer/src/raycast-api/action-runtime-registry.tsx b/src/renderer/src/raycast-api/action-runtime-registry.tsx index 5c8c8db8..b74109e6 100644 --- a/src/renderer/src/raycast-api/action-runtime-registry.tsx +++ b/src/renderer/src/raycast-api/action-runtime-registry.tsx @@ -173,6 +173,54 @@ function runPickDateAction(props: any): void { input.click(); } +function getReactTypeName(type: any): string { + return String(type?.displayName || type?.name || type || ''); +} + +function buildValueSignature(value: unknown, seen = new WeakSet()): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); + if (typeof value === 'function') return `fn:${value.name || 'anonymous'}`; + if (typeof value === 'symbol') return value.toString(); + + if (Array.isArray(value)) { + return `[${value.map((item) => buildValueSignature(item, seen)).join(',')}]`; + } + + if (React.isValidElement(value)) { + return `element:${getReactTypeName(value.type)}:${buildValueSignature((value as any).props, seen)}`; + } + + if (typeof value === 'object') { + if (value instanceof Date) return `date:${value.getTime()}`; + if (seen.has(value as object)) return '[circular]'; + seen.add(value as object); + + const entries = Object.entries(value as Record) + .filter(([key]) => key !== '_owner' && key !== '_store' && key !== 'ref' && key !== 'key') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${key}:${buildValueSignature(entryValue, seen)}`); + + return `{${entries.join(',')}}`; + } + + return String(value); +} + +export function buildActionVisibleSignature(entry: Pick): string { + return [ + entry.id, + String(entry.order), + entry.title, + entry.sectionTitle || '', + buildValueSignature(entry.icon), + buildValueSignature(entry.shortcut), + entry.style || '', + ].join('\u001f'); +} + export function createActionRegistryRuntime(deps: RegistryDeps) { const { snapshotExtensionContext, @@ -347,9 +395,9 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { function useCollectedActions() { const registryRef = useRef(new Map()); + const visibleSignatureRef = useRef(new Map()); const [version, setVersion] = useState(0); const pendingRef = useRef(false); - const lastSnapshotRef = useRef(''); const scheduleUpdate = useCallback(() => { if (pendingRef.current) return; @@ -357,12 +405,7 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { pendingRef.current = true; queueMicrotask(() => { pendingRef.current = false; - const entries = Array.from(registryRef.current.values()); - const snapshot = entries.map((entry) => `${entry.id}:${entry.title}:${entry.sectionTitle || ''}`).join('|'); - if (snapshot !== lastSnapshotRef.current) { - lastSnapshotRef.current = snapshot; - setVersion((value) => value + 1); - } + setVersion((value) => value + 1); }); }, []); @@ -370,6 +413,7 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { () => ({ register(id, data) { const existing = registryRef.current.get(id); + let shouldPublish = false; if (existing) { existing.title = data.title; existing.icon = data.icon; @@ -378,14 +422,21 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { existing.sectionTitle = data.sectionTitle; existing.execute = data.execute; existing.order = data.order; + const nextSignature = buildActionVisibleSignature(existing); + shouldPublish = visibleSignatureRef.current.get(id) !== nextSignature; + visibleSignatureRef.current.set(id, nextSignature); } else { - registryRef.current.set(id, { id, ...data }); + const entry = { id, ...data }; + registryRef.current.set(id, entry); + visibleSignatureRef.current.set(id, buildActionVisibleSignature(entry)); + shouldPublish = true; } - scheduleUpdate(); + if (shouldPublish) scheduleUpdate(); }, unregister(id) { if (!registryRef.current.has(id)) return; registryRef.current.delete(id); + visibleSignatureRef.current.delete(id); scheduleUpdate(); }, }), diff --git a/src/renderer/src/raycast-api/detail-runtime.tsx b/src/renderer/src/raycast-api/detail-runtime.tsx index 88d513ff..774ce434 100644 --- a/src/renderer/src/raycast-api/detail-runtime.tsx +++ b/src/renderer/src/raycast-api/detail-runtime.tsx @@ -3,7 +3,7 @@ * Purpose: Detail component runtime and metadata primitives. */ -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { normalizeScAssetUrl, resolveReadableTintColor, resolveTintColor, toScAssetUrl } from './icon-runtime-assets'; import { renderSimpleMarkdown } from './detail-markdown'; import { useI18n } from '../i18n'; @@ -74,6 +74,18 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { const { pop } = deps.useNavigation(); const { collectedActions: detailActions, registryAPI: detailActionRegistry } = deps.useCollectedActions(); const primaryAction = detailActions[0]; + const globalKeydownRef = useRef({ + detailActions, + isMetaK: deps.isMetaK, + matchesShortcut: deps.matchesShortcut, + primaryAction, + }); + globalKeydownRef.current = { + detailActions, + isMetaK: deps.isMetaK, + matchesShortcut: deps.matchesShortcut, + primaryAction, + }; const { detailMetadata, detailChildren } = useMemo(() => { if (metadata) { @@ -111,11 +123,17 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { useEffect(() => { const handler = (event: KeyboardEvent) => { - if (deps.isMetaK(event)) { event.preventDefault(); setShowActions((prev) => !prev); return; } - if (event.key === 'Enter' && event.metaKey && !event.repeat && primaryAction) { event.preventDefault(); primaryAction.execute(); return; } + const { + detailActions: currentActions, + isMetaK: currentIsMetaK, + matchesShortcut: currentMatchesShortcut, + primaryAction: currentPrimaryAction, + } = globalKeydownRef.current; + if (currentIsMetaK(event)) { event.preventDefault(); setShowActions((prev) => !prev); return; } + if (event.key === 'Enter' && event.metaKey && !event.repeat && currentPrimaryAction) { event.preventDefault(); currentPrimaryAction.execute(); return; } if (!event.repeat) { - for (const action of detailActions) { - if (action.shortcut && deps.matchesShortcut(event, action.shortcut)) { + for (const action of currentActions) { + if (action.shortcut && currentMatchesShortcut(event, action.shortcut)) { event.preventDefault(); action.execute(); return; @@ -125,7 +143,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [detailActions, primaryAction]); + }, []); const handleActionExecute = useCallback((action: ExtractedActionLike) => { setShowActions(false); diff --git a/src/renderer/src/raycast-api/form-runtime.tsx b/src/renderer/src/raycast-api/form-runtime.tsx index 46ddbf0f..6abda851 100644 --- a/src/renderer/src/raycast-api/form-runtime.tsx +++ b/src/renderer/src/raycast-api/form-runtime.tsx @@ -97,6 +97,8 @@ export function createFormRuntime(deps: FormRuntimeDeps) { const { collectedActions: formActions, registryAPI: formActionRegistry } = useCollectedActions(); const primaryAction = formActions[0]; + const globalKeydownRef = useRef({ formActions, isMetaK, matchesShortcut, primaryAction }); + globalKeydownRef.current = { formActions, isMetaK, matchesShortcut, primaryAction }; const extensionContext = getExtensionContext(); const footerTitle = @@ -109,19 +111,25 @@ export function createFormRuntime(deps: FormRuntimeDeps) { useEffect(() => { const handler = (event: KeyboardEvent) => { - if (isMetaK(event)) { + const { + formActions: currentActions, + isMetaK: currentIsMetaK, + matchesShortcut: currentMatchesShortcut, + primaryAction: currentPrimaryAction, + } = globalKeydownRef.current; + if (currentIsMetaK(event)) { event.preventDefault(); setShowActions((value) => !value); return; } - if (event.key === 'Enter' && event.metaKey && !event.repeat && primaryAction) { + if (event.key === 'Enter' && event.metaKey && !event.repeat && currentPrimaryAction) { event.preventDefault(); - primaryAction.execute(); + currentPrimaryAction.execute(); return; } if (event.repeat) return; - for (const action of formActions) { - if (!action.shortcut || !matchesShortcut(event, action.shortcut)) continue; + for (const action of currentActions) { + if (!action.shortcut || !currentMatchesShortcut(event, action.shortcut)) continue; event.preventDefault(); action.execute(); return; @@ -130,7 +138,7 @@ export function createFormRuntime(deps: FormRuntimeDeps) { window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [formActions, isMetaK, matchesShortcut, primaryAction]); + }, []); const contextValue = useMemo( () => ({ values, setValue, errors, setError, placeholders, setPlaceholder }), diff --git a/src/renderer/src/raycast-api/list-runtime-renderers.tsx b/src/renderer/src/raycast-api/list-runtime-renderers.tsx index 44d20a55..c4bd1634 100644 --- a/src/renderer/src/raycast-api/list-runtime-renderers.tsx +++ b/src/renderer/src/raycast-api/list-runtime-renderers.tsx @@ -5,7 +5,7 @@ * subcomponents like EmptyView and Dropdown. */ -import React, { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { EmptyViewRegistryContext, ListRegistryContext, @@ -22,6 +22,26 @@ interface ListRendererDeps { addHexAlpha: (hex: string, alphaHex?: string) => string | null; } +export type ListDropdownItem = { title: string; value: string }; + +export function flattenListDropdownItems(children: React.ReactNode, metrics?: { childWalks?: number; itemPushes?: number }): ListDropdownItem[] { + const items: ListDropdownItem[] = []; + const walk = (nodes: React.ReactNode) => { + React.Children.forEach(nodes, (child) => { + if (!React.isValidElement(child)) return; + metrics && (metrics.childWalks = (metrics.childWalks || 0) + 1); + const props = child.props as any; + if (props.value !== undefined && props.title !== undefined) { + items.push({ title: props.title, value: props.value }); + metrics && (metrics.itemPushes = (metrics.itemPushes || 0) + 1); + } + if (props.children) walk(props.children); + }); + }; + walk(children); + return items; +} + export function createListRenderers(deps: ListRendererDeps) { const { renderIcon, resolveTintColor, resolveReadableTintColor, addHexAlpha } = deps; let itemOrderCounter = 0; @@ -185,17 +205,7 @@ export function createListRenderers(deps: ListRendererDeps) { function ListDropdown({ children, tooltip, onChange, value, defaultValue }: any) { const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? ''); const didEmitInitialChange = useRef(false); - - const items: { title: string; value: string }[] = []; - const walk = (nodes: React.ReactNode) => { - React.Children.forEach(nodes, (child) => { - if (!React.isValidElement(child)) return; - const props = child.props as any; - if (props.value !== undefined && props.title !== undefined) items.push({ title: props.title, value: props.value }); - if (props.children) walk(props.children); - }); - }; - walk(children); + const items = useMemo(() => flattenListDropdownItems(children), [children]); useEffect(() => { if (didEmitInitialChange.current || !onChange) return; diff --git a/src/renderer/src/raycast-api/list-runtime.tsx b/src/renderer/src/raycast-api/list-runtime.tsx index d7b665bd..88dd6e27 100644 --- a/src/renderer/src/raycast-api/list-runtime.tsx +++ b/src/renderer/src/raycast-api/list-runtime.tsx @@ -165,6 +165,8 @@ export function createListRuntime(deps: ListRuntimeDeps) { ActionRegistryContext, }), [selectedItem?.id, actionRegistry, ActionRegistryContext]); const primaryAction = selectedActions[0]; + const globalKeydownRef = useRef({ selectedActions, isMetaK, matchesShortcut }); + globalKeydownRef.current = { selectedActions, isMetaK, matchesShortcut }; const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (isMetaK(event)) { @@ -198,7 +200,8 @@ export function createListRuntime(deps: ListRuntimeDeps) { useEffect(() => { const handler = (event: KeyboardEvent) => { - if (isMetaK(event) && !event.repeat) { + const { selectedActions: currentActions, isMetaK: currentIsMetaK, matchesShortcut: currentMatchesShortcut } = globalKeydownRef.current; + if (currentIsMetaK(event) && !event.repeat) { event.preventDefault(); event.stopPropagation(); setShowActions((value) => !value); @@ -206,8 +209,8 @@ export function createListRuntime(deps: ListRuntimeDeps) { } if (!event.metaKey && !event.altKey && !event.ctrlKey) return; if (event.repeat) return; - for (const action of selectedActions) { - if (!action.shortcut || !matchesShortcut(event, action.shortcut)) continue; + for (const action of currentActions) { + if (!action.shortcut || !currentMatchesShortcut(event, action.shortcut)) continue; event.preventDefault(); event.stopPropagation(); setShowActions(false); @@ -218,7 +221,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { }; window.addEventListener('keydown', handler, true); return () => window.removeEventListener('keydown', handler, true); - }, [isMetaK, matchesShortcut, selectedActions]); + }, []); const prevFilteredItemsRef = useRef(filteredItems); useEffect(() => { From 780790f75fa3d237e854200fa0fe314dbf0a64a0 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:53:29 +0200 Subject: [PATCH 03/10] perf(hooks): stabilize Raycast hook signatures (cherry picked from commit f788777fd4f4e22418685ca57e210e0510526d8c) --- scripts/measure-raycast-hook-signatures.mjs | 94 ++++++ scripts/test-raycast-hook-signatures.mjs | 296 ++++++++++++++++++ .../raycast-api/hooks/use-cached-promise.ts | 10 +- .../src/raycast-api/hooks/use-exec.ts | 14 +- .../src/raycast-api/hooks/use-promise.ts | 22 +- .../src/raycast-api/hooks/use-stable-args.ts | 51 +++ 6 files changed, 462 insertions(+), 25 deletions(-) create mode 100644 scripts/measure-raycast-hook-signatures.mjs create mode 100644 scripts/test-raycast-hook-signatures.mjs create mode 100644 src/renderer/src/raycast-api/hooks/use-stable-args.ts diff --git a/scripts/measure-raycast-hook-signatures.mjs b/scripts/measure-raycast-hook-signatures.mjs new file mode 100644 index 00000000..43ecf584 --- /dev/null +++ b/scripts/measure-raycast-hook-signatures.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +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 helperPath = path.resolve('src/renderer/src/raycast-api/hooks/use-stable-args.ts'); + +function loadHelper() { + const source = fs.readFileSync(helperPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: helperPath, + }); + + const module = { exports: {} }; + const sandbox = { + Array, + Error, + JSON, + Map, + Math, + module, + Object, + require: (request) => { + if (request === 'react') { + return { useRef: (value) => ({ current: value }) }; + } + return require(request); + }, + String, + Symbol, + WeakMap, + WeakSet, + exports: module.exports, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: helperPath }); + return module.exports; +} + +function measure(label, fn) { + const start = performance.now(); + const result = fn(); + const durationMs = performance.now() - start; + return { label, durationMs: Number(durationMs.toFixed(3)), result }; +} + +const { getStableArgsKey } = loadHelper(); +const rows = Array.from({ length: 10000 }, (_, index) => ({ + index, + label: `row-${index}`, + payload: 'x'.repeat(64), +})); +const largeArgs = [rows]; +const cyclic = { rows }; +cyclic.self = cyclic; + +const iterations = Number(process.env.SUPERCMD_HOOK_SIGNATURE_ITERATIONS || 500); +const direct = measure('direct JSON.stringify repeated', () => { + let key = ''; + for (let index = 0; index < iterations; index += 1) { + key = JSON.stringify(largeArgs); + } + return key.length; +}); + +getStableArgsKey(largeArgs); +const cached = measure('getStableArgsKey repeated same identity', () => { + let key = ''; + for (let index = 0; index < iterations; index += 1) { + key = getStableArgsKey(largeArgs); + } + return key.length; +}); + +const cyclicResult = measure('getStableArgsKey cyclic args', () => getStableArgsKey([cyclic]).length); + +console.log(JSON.stringify({ + iterations, + direct, + cached, + cyclicResult, + cachedSpeedup: Number((direct.durationMs / Math.max(cached.durationMs, 0.001)).toFixed(2)), +}, null, 2)); diff --git a/scripts/test-raycast-hook-signatures.mjs b/scripts/test-raycast-hook-signatures.mjs new file mode 100644 index 00000000..d3bc1759 --- /dev/null +++ b/scripts/test-raycast-hook-signatures.mjs @@ -0,0 +1,296 @@ +#!/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'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +let activeHost = null; + +const fakeReact = { + useCallback: (...args) => activeHost.useCallback(...args), + useEffect: (...args) => activeHost.useEffect(...args), + useMemo: (...args) => activeHost.useMemo(...args), + useRef: (...args) => activeHost.useRef(...args), + useState: (...args) => activeHost.useState(...args), +}; + +const windowShim = { + electron: { + execCommand: () => Promise.reject(new Error('execCommand stub not configured')), + }, +}; + +const moduleCache = new Map(); + +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, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request === 'react') return fakeReact; + 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 = { + AbortController, + Array, + console, + Date, + Error, + JSON, + Map, + Math, + module, + Object, + Promise, + queueMicrotask, + RegExp, + require: localRequire, + setTimeout, + clearTimeout, + String, + Symbol, + WeakMap, + WeakSet, + window: windowShim, + exports: module.exports, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +class HookHost { + constructor(renderHook) { + this.renderHook = renderHook; + this.hookIndex = 0; + this.hooks = []; + this.pendingEffects = []; + this.output = undefined; + this.isRendering = false; + this.isFlushingEffects = false; + this.needsRender = false; + this.unmounted = false; + } + + render() { + if (this.unmounted) return this.output; + this.hookIndex = 0; + this.pendingEffects = []; + this.isRendering = true; + const previousHost = activeHost; + activeHost = this; + try { + this.output = this.renderHook(); + } finally { + activeHost = previousHost; + this.isRendering = false; + } + this.flushEffects(); + return this.output; + } + + flushEffects() { + this.isFlushingEffects = true; + try { + for (const { index, effect } of this.pendingEffects) { + const record = this.hooks[index]; + if (record.cleanup) record.cleanup(); + const cleanup = effect(); + record.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } + } finally { + this.isFlushingEffects = false; + } + + if (this.needsRender && !this.unmounted) { + this.needsRender = false; + this.render(); + } + } + + scheduleRender() { + if (this.unmounted) return; + if (this.isRendering || this.isFlushingEffects) { + this.needsRender = true; + return; + } + this.render(); + } + + useState(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { + state: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + const setState = (nextValue) => { + const record = this.hooks[index]; + const next = typeof nextValue === 'function' ? nextValue(record.state) : nextValue; + if (Object.is(record.state, next)) return; + record.state = next; + this.scheduleRender(); + }; + return [this.hooks[index].state, setState]; + } + + useRef(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { current: initialValue }; + } + return this.hooks[index]; + } + + useEffect(effect, deps) { + const index = this.hookIndex++; + const record = this.hooks[index] || {}; + const changed = !record.deps || !depsEqual(record.deps, deps); + this.hooks[index] = { ...record, deps }; + if (changed) { + this.pendingEffects.push({ index, effect }); + } + } + + useCallback(callback, deps) { + return this.useMemo(() => callback, deps); + } + + useMemo(factory, deps) { + const index = this.hookIndex++; + const record = this.hooks[index]; + if (record && depsEqual(record.deps, deps)) return record.value; + const value = factory(); + this.hooks[index] = { value, deps }; + return value; + } + + unmount() { + this.unmounted = true; + for (const record of this.hooks) { + if (record?.cleanup) { + record.cleanup(); + record.cleanup = undefined; + } + } + } +} + +function depsEqual(previousDeps, nextDeps) { + if (!previousDeps || !nextDeps || previousDeps.length !== nextDeps.length) return false; + return previousDeps.every((value, index) => Object.is(value, nextDeps[index])); +} + +async function flushAsync() { + for (let index = 0; index < 8; index += 1) { + await Promise.resolve(); + } +} + +const { useCachedPromise } = loadTsModule('src/renderer/src/raycast-api/hooks/use-cached-promise.ts'); +const { useExec } = loadTsModule('src/renderer/src/raycast-api/hooks/use-exec.ts'); + +test('useCachedPromise accepts cyclic large args without render-time signature crashes', async () => { + const cyclic = { + rows: Array.from({ length: 2000 }, (_, index) => ({ index, label: `row-${index}` })), + }; + cyclic.self = cyclic; + const hookArgs = [cyclic]; + const calls = []; + const host = new HookHost(() => useCachedPromise(async (...args) => { + calls.push(args); + return args[0].rows.length; + }, hookArgs)); + + assert.doesNotThrow(() => host.render()); + await flushAsync(); + + assert.equal(calls.length, 1); + assert.equal(host.output.data, 2000); + assert.equal(host.output.error, undefined); + + host.render(); + await flushAsync(); + assert.equal(calls.length, 1, 'stable arg identity should not revalidate on unrelated rerenders'); + + host.unmount(); +}); + +test('useExec re-runs when command, args, or execution options change', async () => { + const execCalls = []; + const onDataCalls = []; + let command = 'first-command'; + let execArgs = ['one']; + let cwd = '/tmp/one'; + + windowShim.electron.execCommand = async (nextCommand, nextArgs, nextOptions) => { + execCalls.push({ command: nextCommand, args: nextArgs, cwd: nextOptions.cwd }); + return { + stdout: `${nextCommand}:${nextArgs.join(',')}:${nextOptions.cwd}\n`, + stderr: '', + exitCode: 0, + }; + }; + + const host = new HookHost(() => useExec(command, execArgs, { + cwd, + onData: (data) => onDataCalls.push(data), + })); + + host.render(); + await flushAsync(); + assert.deepEqual(execCalls, [{ command: 'first-command', args: ['one'], cwd: '/tmp/one' }]); + + execArgs = ['two']; + host.render(); + await flushAsync(); + assert.deepEqual(execCalls.at(-1), { command: 'first-command', args: ['two'], cwd: '/tmp/one' }); + + cwd = '/tmp/two'; + host.render(); + await flushAsync(); + assert.deepEqual(execCalls.at(-1), { command: 'first-command', args: ['two'], cwd: '/tmp/two' }); + + command = 'second-command'; + host.render(); + await flushAsync(); + assert.deepEqual(execCalls.at(-1), { command: 'second-command', args: ['two'], cwd: '/tmp/two' }); + assert.equal(execCalls.length, 4); + assert.deepEqual(onDataCalls, [ + 'first-command:one:/tmp/one', + 'first-command:two:/tmp/one', + 'first-command:two:/tmp/two', + 'second-command:two:/tmp/two', + ]); + + host.unmount(); +}); diff --git a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts index 7d1f4c4e..0676a83a 100644 --- a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts +++ b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { snapshotExtensionContext, withExtensionContext, type ExtensionContextSnapshot } from '../context-scope-runtime'; +import { useStableArgs } from './use-stable-args'; function resolveInitialDataValue(value: T | (() => T) | undefined): T | undefined { if (typeof value === 'function') { @@ -55,11 +56,13 @@ export function useCachedPromise( }, []); const fnRef = useRef(fn); - const argsRef = useRef(args || []); + const stableArgs = useStableArgs(args); + + const argsRef = useRef(stableArgs); const optionsRef = useRef(options); const runtimeCtxRef = useRef(snapshotExtensionContext()); fnRef.current = fn; - argsRef.current = args || []; + argsRef.current = stableArgs; optionsRef.current = options; runtimeCtxRef.current = snapshotExtensionContext(); @@ -153,7 +156,6 @@ export function useCachedPromise( } }, []); - const argsKey = JSON.stringify(args || []); useEffect(() => { setPage(0); setCursor(undefined); @@ -161,7 +163,7 @@ export function useCachedPromise( setAccumulatedData(resolveInitialDataValue(optionsRef.current?.initialData)); } fetchPage(0, undefined); - }, [argsKey, fetchPage]); + }, [stableArgs, fetchPage]); const revalidate = useCallback(() => { setPage(0); diff --git a/src/renderer/src/raycast-api/hooks/use-exec.ts b/src/renderer/src/raycast-api/hooks/use-exec.ts index 30540224..229659da 100644 --- a/src/renderer/src/raycast-api/hooks/use-exec.ts +++ b/src/renderer/src/raycast-api/hooks/use-exec.ts @@ -28,6 +28,18 @@ export function useExec( ) { const actualArgs: string[] = Array.isArray(args) ? args : []; const actualOptions = Array.isArray(args) ? options : (args as typeof options); + const execArgs = [ + command, + actualArgs, + { + cwd: actualOptions?.cwd, + env: actualOptions?.env, + input: actualOptions?.input, + shell: actualOptions?.shell, + stripFinalNewline: actualOptions?.stripFinalNewline, + timeout: actualOptions?.timeout, + }, + ]; return usePromise( async () => { @@ -68,7 +80,7 @@ export function useExec( return stdout as any as T; }, - [], + execArgs, { initialData: actualOptions?.initialData, execute: actualOptions?.execute, diff --git a/src/renderer/src/raycast-api/hooks/use-promise.ts b/src/renderer/src/raycast-api/hooks/use-promise.ts index 5127e284..128e23b5 100644 --- a/src/renderer/src/raycast-api/hooks/use-promise.ts +++ b/src/renderer/src/raycast-api/hooks/use-promise.ts @@ -5,25 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { snapshotExtensionContext, withExtensionContext, type ExtensionContextSnapshot } from '../context-scope-runtime'; - -function useStableArgs(args: any[]): any[] { - const ref = useRef(args); - const prevKey = useRef(''); - - let key: string; - try { - key = JSON.stringify(args); - } catch { - key = String(args); - } - - if (prevKey.current !== key) { - prevKey.current = key; - ref.current = args; - } - - return ref.current; -} +import { useStableArgs } from './use-stable-args'; export function usePromise( fn: (...args: any[]) => Promise, @@ -56,7 +38,7 @@ export function usePromise( }; }, []); - const stableArgs = useStableArgs(args || []); + const stableArgs = useStableArgs(args); const fnRef = useRef(fn); const argsRef = useRef(stableArgs); diff --git a/src/renderer/src/raycast-api/hooks/use-stable-args.ts b/src/renderer/src/raycast-api/hooks/use-stable-args.ts new file mode 100644 index 00000000..df12ff55 --- /dev/null +++ b/src/renderer/src/raycast-api/hooks/use-stable-args.ts @@ -0,0 +1,51 @@ +/** + * raycast-api/hooks/use-stable-args.ts + * Purpose: Stable, safe hook dependency signatures for Raycast hook args. + */ + +import { useRef } from 'react'; + +const EMPTY_ARGS: any[] = []; +const signatureCache = new WeakMap(); + +function stringifyArgs(value: any): string { + const seen = new WeakSet(); + + try { + const serialized = JSON.stringify(value, (_key, nextValue) => { + if (nextValue && typeof nextValue === 'object') { + if (seen.has(nextValue)) { + return '[Circular]'; + } + seen.add(nextValue); + } + return nextValue; + }); + return serialized ?? String(value); + } catch { + return String(value); + } +} + +export function getStableArgsKey(args: any[] = EMPTY_ARGS): string { + const cached = signatureCache.get(args); + if (cached !== undefined) return cached; + + const key = stringifyArgs(args); + signatureCache.set(args, key); + return key; +} + +export function useStableArgs(args?: any[]): any[] { + const nextArgs = args ?? EMPTY_ARGS; + const ref = useRef(nextArgs); + const prevKey = useRef(''); + const key = getStableArgsKey(nextArgs); + + if (prevKey.current !== key) { + prevKey.current = key; + ref.current = nextArgs; + } + + return ref.current; +} From 26a4b9d9e79fc748026dac46f4a5f64e2a0b44c5 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:16:41 +0200 Subject: [PATCH 04/10] perf(list): reduce row render churn (cherry picked from commit 06cdb8f1a2a4cd9ece3662a5c1d75c479a1456cb) --- scripts/measure-raycast-list-render-churn.mjs | 438 ++++++++++++++++++ scripts/test-list-renderer-memoization.mjs | 191 ++++++++ .../raycast-api/list-runtime-renderers.tsx | 38 +- 3 files changed, 663 insertions(+), 4 deletions(-) create mode 100644 scripts/measure-raycast-list-render-churn.mjs create mode 100644 scripts/test-list-renderer-memoization.mjs diff --git a/scripts/measure-raycast-list-render-churn.mjs b/scripts/measure-raycast-list-render-churn.mjs new file mode 100644 index 00000000..47970614 --- /dev/null +++ b/scripts/measure-raycast-list-render-churn.mjs @@ -0,0 +1,438 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import * as esbuild from 'esbuild'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const listRenderersPath = path.join(repoRoot, 'src/renderer/src/raycast-api/list-runtime-renderers.tsx'); + +const visibleRows = readNumberArg('--visible-rows', 80); +const visibleEmojiCells = readNumberArg('--visible-emoji-cells', 96); +const selectionSteps = readNumberArg('--selection-steps', 600); +const parentUpdates = readNumberArg('--parent-updates', 300); +const iterations = readNumberArg('--iterations', 9); +const warmups = readNumberArg('--warmups', 2); +const jsonOnly = process.argv.includes('--json'); + +function readNumberArg(name, fallback) { + const raw = process.argv.find((arg) => arg.startsWith(`${name}=`)); + if (!raw) return fallback; + const value = Number(raw.slice(name.length + 1)); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +const tmpDir = await fs.mkdtemp(path.join(repoRoot, '.raycast-list-render-measure-')); +const entryPath = path.join(tmpDir, 'entry.tsx'); +const bundlePath = path.join(tmpDir, 'bundle.mjs'); + +await fs.writeFile(entryPath, ` +import React from 'react'; +import { performance } from 'node:perf_hooks'; +import { createListRenderers } from ${JSON.stringify(listRenderersPath)}; + +type Metrics = { + listRowRenderCount: number; + emojiCellRenderCount: number; +}; + +declare global { + // eslint-disable-next-line no-var + var __raycastListRowMetrics: Metrics | undefined; +} + +const visibleRows = ${visibleRows}; +const visibleEmojiCells = ${visibleEmojiCells}; +const selectionSteps = ${selectionSteps}; +const parentUpdates = ${parentUpdates}; +const iterations = ${iterations}; +const warmups = ${warmups}; +const jsonOnly = ${JSON.stringify(jsonOnly)}; + +function median(values: number[]): number { + const sorted = values.slice().sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] || 0; +} + +function percentile(values: number[], pct: number): number { + if (values.length === 0) return 0; + const sorted = values.slice().sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * pct) - 1)); + return sorted[index] || 0; +} + +class MiniNode { + nodeType: number; + nodeName: string; + tagName: string; + ownerDocument: any; + childNodes: any[]; + parentNode: any; + style: Record; + attributes: Record; + namespaceURI: string; + _text: string; + + constructor(nodeType: number, nodeName: string, ownerDocument?: any) { + this.nodeType = nodeType; + this.nodeName = nodeName; + this.tagName = nodeName; + this.ownerDocument = ownerDocument || this; + this.childNodes = []; + this.parentNode = null; + this.style = {}; + this.attributes = {}; + this.namespaceURI = 'http://www.w3.org/1999/xhtml'; + this._text = ''; + } + + appendChild(child: any) { + this.childNodes.push(child); + child.parentNode = this; + return child; + } + + insertBefore(child: any, before: any) { + const index = this.childNodes.indexOf(before); + if (index < 0) return this.appendChild(child); + this.childNodes.splice(index, 0, child); + child.parentNode = this; + return child; + } + + removeChild(child: any) { + const index = this.childNodes.indexOf(child); + if (index >= 0) this.childNodes.splice(index, 1); + child.parentNode = null; + return child; + } + + setAttribute(name: string, value: string) { + this.attributes[name] = String(value); + } + + removeAttribute(name: string) { + delete this.attributes[name]; + } + + addEventListener() {} + removeEventListener() {} + focus() {} + + get firstChild() { + return this.childNodes[0] || null; + } + + get lastChild() { + return this.childNodes[this.childNodes.length - 1] || null; + } + + get textContent() { + if (this.nodeType === 3) return this._text; + return this.childNodes.map((child) => child.textContent || '').join(''); + } + + set textContent(value: string) { + this._text = String(value); + this.childNodes = []; + } +} + +class MiniText extends MiniNode { + nodeValue: string; + + constructor(text: string, ownerDocument: any) { + super(3, '#text', ownerDocument); + this.nodeValue = String(text); + this._text = String(text); + } +} + +class MiniDocument extends MiniNode { + documentElement: MiniNode; + body: MiniNode; + defaultView: any; + + constructor() { + super(9, '#document'); + this.ownerDocument = this; + this.documentElement = new MiniNode(1, 'HTML', this); + this.body = new MiniNode(1, 'BODY', this); + this.defaultView = globalThis; + } + + createElement(tag: string) { + return new MiniNode(1, tag.toUpperCase(), this); + } + + createElementNS(namespaceURI: string, tag: string) { + const node = new MiniNode(1, tag.toUpperCase(), this); + node.namespaceURI = namespaceURI; + return node; + } + + createTextNode(text: string) { + return new MiniText(text, this); + } + + addEventListener() {} + removeEventListener() {} +} + +function installMiniDom() { + const document = new MiniDocument(); + (globalThis as any).document = document; + (globalThis as any).window = globalThis; + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'supercmd-list-benchmark' }, + configurable: true, + }); + (globalThis as any).HTMLElement = MiniNode; + (globalThis as any).HTMLIFrameElement = class {}; +} + +function renderIcon(icon: any, className?: string, assetsPath?: string) { + const label = typeof icon === 'string' + ? icon + : typeof icon?.source === 'string' + ? icon.source + : typeof icon?.value === 'string' + ? icon.value + : 'icon'; + return {label}; +} + +function resolveTintColor(value?: string) { + if (!value) return undefined; + if (value.startsWith('#')) return value; + if (value === 'red') return '#ff453a'; + if (value === 'green') return '#32d74b'; + if (value === 'blue') return '#0a84ff'; + if (value === 'yellow') return '#ffd60a'; + return '#8e8e93'; +} + +const deps = { + renderIcon, + resolveTintColor, + resolveReadableTintColor: resolveTintColor, + addHexAlpha: (hex: string, alphaHex: string) => \`\${hex}\${alphaHex}\`, +}; + +const { ListItemRenderer, ListEmojiGridItemRenderer } = createListRenderers(deps); + +function makeAccessories(index: number) { + switch (index % 6) { + case 0: + return [{ text: \`Meta \${index}\`, icon: 'Icon.Tag' }]; + case 1: + return [{ tag: { value: \`Tag \${index}\`, color: 'blue' } }]; + case 2: + return [{ date: new Date(2026, index % 11, (index % 27) + 1).toISOString() }]; + case 3: + return [{ text: { value: \`Tint \${index}\`, color: 'green' }, icon: { source: 'Icon.CheckCircle' } }]; + default: + return undefined; + } +} + +const listRows = Array.from({ length: visibleRows }, (_, index) => ({ + id: \`list-\${index}\`, + title: index % 5 === 0 ? { value: \`List Item \${String(index).padStart(4, '0')}\` } : \`List Item \${String(index).padStart(4, '0')}\`, + subtitle: index % 3 === 0 ? \`Subtitle \${index}\` : undefined, + icon: index % 4 === 0 ? { source: 'Icon.Dot', color: 'red' } : index % 4 === 1 ? 'Icon.Document' : undefined, + accessories: makeAccessories(index), + dataIdx: index, +})); + +const emojiRows = Array.from({ length: visibleEmojiCells }, (_, index) => ({ + id: \`emoji-\${index}\`, + icon: String.fromCodePoint(0x1f600 + (index % 64)), + title: \`Emoji \${index}\`, + dataIdx: index, +})); + +function ListSelectionWindow({ selectedIdx, parentTick = 0 }: { selectedIdx: number; parentTick?: number }) { + return ( +
+ {listRows.map((row) => ( + {}} + onActivate={() => {}} + onContextAction={() => {}} + /> + ))} +
+ ); +} + +function EmojiSelectionWindow({ selectedIdx, parentTick = 0 }: { selectedIdx: number; parentTick?: number }) { + return ( +
+ {emojiRows.map((row) => ( + {}} + onActivate={() => {}} + onContextAction={() => {}} + /> + ))} +
+ ); +} + +async function measureClientRenderChurn(kind: 'list' | 'emoji', mode: 'selection' | 'parent') { + const [{ createRoot }, { flushSync }] = await Promise.all([ + import('react-dom/client'), + import('react-dom'), + ]); + const container = document.createElement('div'); + const root = createRoot(container); + const Window = kind === 'list' ? ListSelectionWindow : EmojiSelectionWindow; + const count = kind === 'list' ? visibleRows : visibleEmojiCells; + const updates = mode === 'selection' ? selectionSteps : parentUpdates; + + flushSync(() => { + root.render(); + }); + + globalThis.__raycastListRowMetrics = { listRowRenderCount: 0, emojiCellRenderCount: 0 }; + const started = performance.now(); + for (let step = 1; step <= updates; step += 1) { + flushSync(() => { + root.render( + , + ); + }); + } + + const durationMs = performance.now() - started; + const metrics = globalThis.__raycastListRowMetrics || { listRowRenderCount: 0, emojiCellRenderCount: 0 }; + root.unmount(); + + return { + durationMs, + renderCount: kind === 'list' ? metrics.listRowRenderCount : metrics.emojiCellRenderCount, + }; +} + +installMiniDom(); + +for (let i = 0; i < warmups; i += 1) { + await measureClientRenderChurn('list', 'selection'); + await measureClientRenderChurn('list', 'parent'); + await measureClientRenderChurn('emoji', 'selection'); + await measureClientRenderChurn('emoji', 'parent'); +} + +async function collect(kind: 'list' | 'emoji', mode: 'selection' | 'parent') { + const samples = []; + for (let index = 0; index < iterations; index += 1) { + samples.push(await measureClientRenderChurn(kind, mode)); + } + const durations = samples.map((sample) => sample.durationMs); + return { + renderCount: samples[0]?.renderCount || 0, + medianDurationMs: median(durations), + p95DurationMs: percentile(durations, 0.95), + minDurationMs: Math.min(...durations), + maxDurationMs: Math.max(...durations), + }; +} + +const summary = { + visibleRows, + visibleEmojiCells, + selectionSteps, + parentUpdates, + iterations, + listSelectionChurn: await collect('list', 'selection'), + listParentChurn: await collect('list', 'parent'), + emojiSelectionChurn: await collect('emoji', 'selection'), + emojiParentChurn: await collect('emoji', 'parent'), +}; + +if (!jsonOnly) { + console.log(\`Raycast List render churn measurement (visibleRows=\${visibleRows}, visibleEmojiCells=\${visibleEmojiCells}, iterations=\${iterations})\`); + for (const [name, result] of Object.entries({ + 'list selection churn': summary.listSelectionChurn, + 'list parent churn': summary.listParentChurn, + 'emoji selection churn': summary.emojiSelectionChurn, + 'emoji parent churn': summary.emojiParentChurn, + })) { + console.log([ + \`- \${name}\`, + \`renders=\${result.renderCount}\`, + \`median=\${result.medianDurationMs.toFixed(3)}ms\`, + \`p95=\${result.p95DurationMs.toFixed(3)}ms\`, + \`min=\${result.minDurationMs.toFixed(3)}ms\`, + \`max=\${result.maxDurationMs.toFixed(3)}ms\`, + ].join(' | ')); + } +} + +console.log(JSON.stringify(summary, null, 2)); +`); + +try { + await esbuild.build({ + entryPoints: [entryPath], + outfile: bundlePath, + absWorkingDir: repoRoot, + bundle: true, + format: 'esm', + platform: 'node', + jsx: 'automatic', + packages: 'external', + nodePaths: [path.join(repoRoot, 'node_modules')], + loader: { + '.svg': 'dataurl', + '.png': 'dataurl', + }, + plugins: [{ + name: 'instrument-list-row-renderers', + setup(build) { + build.onLoad({ filter: /list-runtime-renderers\.tsx$/ }, async (args) => { + let source = await fs.readFile(args.path, 'utf8'); + const listMarker = " const titleStr = typeof title === 'string' ? title : (title as any)?.value || '';"; + const emojiMarker = " const emoji = typeof icon === 'string' ? icon : '';"; + if (!source.includes(listMarker) || !source.includes(emojiMarker)) { + throw new Error('Unable to instrument List render count.'); + } + source = source + .replace( + listMarker, + ' const __listMetrics = (globalThis.__raycastListRowMetrics ||= { listRowRenderCount: 0, emojiCellRenderCount: 0 });\n __listMetrics.listRowRenderCount += 1;\n' + listMarker, + ) + .replace( + emojiMarker, + ' const __emojiMetrics = (globalThis.__raycastListRowMetrics ||= { listRowRenderCount: 0, emojiCellRenderCount: 0 });\n __emojiMetrics.emojiCellRenderCount += 1;\n' + emojiMarker, + ); + return { contents: source, loader: 'tsx' }; + }); + }, + }], + logLevel: 'silent', + }); + + await import(pathToFileURL(bundlePath).href); +} finally { + await fs.rm(tmpDir, { recursive: true, force: true }); +} diff --git a/scripts/test-list-renderer-memoization.mjs b/scripts/test-list-renderer-memoization.mjs new file mode 100644 index 00000000..5ebc5c1e --- /dev/null +++ b/scripts/test-list-renderer-memoization.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import * as esbuild from 'esbuild'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const listRenderersPath = path.join(repoRoot, 'src/renderer/src/raycast-api/list-runtime-renderers.tsx'); + +async function importListRendererAssertions() { + const tmpDir = await fs.mkdtemp(path.join(repoRoot, '.list-renderer-memo-test-')); + const entryPath = path.join(tmpDir, 'entry.tsx'); + const bundlePath = path.join(tmpDir, 'bundle.mjs'); + + await fs.writeFile(entryPath, ` +import React from 'react'; +import assert from 'node:assert/strict'; +import { createListRenderers } from ${JSON.stringify(listRenderersPath)}; + +function renderIcon(icon: any, className?: string, assetsPath?: string) { + const label = typeof icon === 'string' ? icon : typeof icon?.source === 'string' ? icon.source : 'icon'; + return {label}; +} + +const { ListItemRenderer, ListEmojiGridItemRenderer } = createListRenderers({ + renderIcon, + resolveTintColor: (value?: string) => value || undefined, + resolveReadableTintColor: (value?: string) => value || undefined, + addHexAlpha: (hex: string, alphaHex: string) => \`\${hex}\${alphaHex}\`, +}); + +const listCompare = (ListItemRenderer as any).compare; +const emojiCompare = (ListEmojiGridItemRenderer as any).compare; +const renderListItem = (ListItemRenderer as any).type; +const renderEmojiItem = (ListEmojiGridItemRenderer as any).type; + +export function assertMemoComparators() { + assert.equal(typeof listCompare, 'function', 'ListItemRenderer should expose a React.memo comparator'); + assert.equal(typeof emojiCompare, 'function', 'ListEmojiGridItemRenderer should expose a React.memo comparator'); + assert.equal(typeof renderListItem, 'function', 'ListItemRenderer should keep its render function'); + assert.equal(typeof renderEmojiItem, 'function', 'ListEmojiGridItemRenderer should keep its render function'); + + const accessories = [{ text: 'Meta', icon: 'Icon.Tag' }]; + const baseListProps = { + title: 'Title', + subtitle: 'Subtitle', + icon: 'Icon.Document', + accessories, + assetsPath: '/assets', + isSelected: false, + dataIdx: 2, + onSelect: () => {}, + onActivate: () => {}, + onContextAction: () => {}, + }; + assert.equal( + listCompare(baseListProps, { + ...baseListProps, + onSelect: () => {}, + onActivate: () => {}, + onContextAction: () => {}, + }), + true, + 'ListItemRenderer should ignore handler identity churn', + ); + for (const [name, nextProps] of [ + ['title', { ...baseListProps, title: 'Next Title' }], + ['subtitle', { ...baseListProps, subtitle: 'Next Subtitle' }], + ['icon', { ...baseListProps, icon: 'Icon.Folder' }], + ['accessories', { ...baseListProps, accessories: [{ text: 'Next Meta' }] }], + ['assetsPath', { ...baseListProps, assetsPath: '/next-assets' }], + ['isSelected', { ...baseListProps, isSelected: true }], + ['dataIdx', { ...baseListProps, dataIdx: 3 }], + ] as const) { + assert.equal(listCompare(baseListProps, nextProps), false, \`ListItemRenderer should update when \${name} changes\`); + } + + const baseEmojiProps = { + icon: '😀', + title: 'Smile', + isSelected: false, + dataIdx: 4, + onSelect: () => {}, + onActivate: () => {}, + onContextAction: () => {}, + }; + assert.equal( + emojiCompare(baseEmojiProps, { + ...baseEmojiProps, + onSelect: () => {}, + onActivate: () => {}, + onContextAction: () => {}, + }), + true, + 'ListEmojiGridItemRenderer should ignore handler identity churn', + ); + for (const [name, nextProps] of [ + ['icon', { ...baseEmojiProps, icon: '🚀' }], + ['title', { ...baseEmojiProps, title: 'Rocket' }], + ['isSelected', { ...baseEmojiProps, isSelected: true }], + ['dataIdx', { ...baseEmojiProps, dataIdx: 5 }], + ] as const) { + assert.equal(emojiCompare(baseEmojiProps, nextProps), false, \`ListEmojiGridItemRenderer should update when \${name} changes\`); + } +} + +export function assertEventWiring() { + let selected = 0; + let activated = 0; + let contexted = 0; + const listElement = renderListItem({ + title: 'Title', + subtitle: 'Subtitle', + icon: 'Icon.Document', + accessories: [{ text: 'Meta' }], + assetsPath: '/assets', + isSelected: true, + dataIdx: 7, + onSelect: () => { selected += 1; }, + onActivate: () => { activated += 1; }, + onContextAction: () => { contexted += 1; }, + }); + assert.equal(listElement.props['data-idx'], 7); + listElement.props.onMouseMove({}); + listElement.props.onClick({}); + listElement.props.onContextMenu({}); + assert.equal(selected, 1, 'list row mouse move should call onSelect'); + assert.equal(activated, 1, 'list row click should call onActivate'); + assert.equal(contexted, 1, 'list row context menu should call onContextAction'); + + let emojiSelected = 0; + let emojiActivated = 0; + let emojiContexted = 0; + let prevented = 0; + const emojiElement = renderEmojiItem({ + icon: '😀', + title: 'Smile', + isSelected: false, + dataIdx: 8, + onSelect: () => { emojiSelected += 1; }, + onActivate: () => { emojiActivated += 1; }, + onContextAction: () => { emojiContexted += 1; }, + }); + assert.equal(emojiElement.props['data-idx'], 8); + emojiElement.props.onMouseMove({}); + emojiElement.props.onPointerDown({ button: 0, preventDefault: () => { prevented += 1; } }); + emojiElement.props.onPointerDown({ button: 1, preventDefault: () => { prevented += 1; } }); + emojiElement.props.onContextMenu({}); + assert.equal(emojiSelected, 1, 'emoji cell mouse move should call onSelect'); + assert.equal(emojiActivated, 1, 'emoji cell primary pointer down should call onActivate'); + assert.equal(prevented, 1, 'emoji cell should only prevent default for primary pointer activation'); + assert.equal(emojiContexted, 1, 'emoji cell context menu should call onContextAction'); +} +`); + + try { + await esbuild.build({ + entryPoints: [entryPath], + outfile: bundlePath, + absWorkingDir: repoRoot, + bundle: true, + format: 'esm', + platform: 'node', + jsx: 'automatic', + packages: 'external', + nodePaths: [path.join(repoRoot, 'node_modules')], + loader: { + '.svg': 'dataurl', + '.png': 'dataurl', + }, + logLevel: 'silent', + }); + + return await import(pathToFileURL(bundlePath).href); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +test('list row renderer memo comparators track visible props and ignore handler churn', async () => { + const assertions = await importListRendererAssertions(); + assertions.assertMemoComparators(); +}); + +test('memoized list row renderers preserve select, activate, and context-menu wiring', async () => { + const assertions = await importListRendererAssertions(); + assertions.assertEventWiring(); +}); diff --git a/src/renderer/src/raycast-api/list-runtime-renderers.tsx b/src/renderer/src/raycast-api/list-runtime-renderers.tsx index c4bd1634..50abb8b6 100644 --- a/src/renderer/src/raycast-api/list-runtime-renderers.tsx +++ b/src/renderer/src/raycast-api/list-runtime-renderers.tsx @@ -42,6 +42,36 @@ export function flattenListDropdownItems(children: React.ReactNode, metrics?: { return items; } +type ListItemRendererProps = ListItemProps & { + isSelected?: boolean; + dataIdx?: number; + onSelect?: React.MouseEventHandler; + onActivate?: React.MouseEventHandler; + onContextAction?: React.MouseEventHandler; + assetsPath?: string; +}; + +function areListItemRendererPropsEqual(previous: ListItemRendererProps, next: ListItemRendererProps): boolean { + return ( + previous.title === next.title + && previous.subtitle === next.subtitle + && previous.icon === next.icon + && previous.accessories === next.accessories + && previous.isSelected === next.isSelected + && previous.dataIdx === next.dataIdx + && previous.assetsPath === next.assetsPath + ); +} + +function areListEmojiGridItemRendererPropsEqual(previous: any, next: any): boolean { + return ( + previous.icon === next.icon + && previous.title === next.title + && previous.isSelected === next.isSelected + && previous.dataIdx === next.dataIdx + ); +} + export function createListRenderers(deps: ListRendererDeps) { const { renderIcon, resolveTintColor, resolveReadableTintColor, addHexAlpha } = deps; let itemOrderCounter = 0; @@ -99,7 +129,7 @@ export function createListRenderers(deps: ListRendererDeps) { (ListItemComponent as any).Accessory = {} as ListItemAccessory; (ListItemComponent as any).Props = {} as ListItemProps; - function ListItemRenderer({ title, subtitle, icon, accessories, isSelected, dataIdx, onSelect, onActivate, onContextAction, assetsPath }: ListItemProps & any) { + const ListItemRenderer = React.memo(function ListItemRenderer({ title, subtitle, icon, accessories, isSelected, dataIdx, onSelect, onActivate, onContextAction, assetsPath }: ListItemRendererProps) { const titleStr = typeof title === 'string' ? title : (title as any)?.value || ''; const subtitleStr = typeof subtitle === 'string' ? subtitle : (subtitle as any)?.value || ''; const primaryText = titleStr || subtitleStr; @@ -143,9 +173,9 @@ export function createListRenderers(deps: ListRendererDeps) { ); - } + }, areListItemRendererPropsEqual); - function ListEmojiGridItemRenderer({ icon, title, isSelected, dataIdx, onSelect, onActivate, onContextAction }: any) { + const ListEmojiGridItemRenderer = React.memo(function ListEmojiGridItemRenderer({ icon, title, isSelected, dataIdx, onSelect, onActivate, onContextAction }: any) { const emoji = typeof icon === 'string' ? icon : ''; return (
{emoji || '🙂'}
); - } + }, areListEmojiGridItemRendererPropsEqual); function ListSectionComponent({ children, title }: { children?: React.ReactNode; title?: string }) { return {children}; From 88973c3c9337cc876436ad8a007c4d02f31040d8 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:15:50 +0200 Subject: [PATCH 05/10] perf(renderer): reduce Raycast list render churn (cherry picked from commit 6b7f17f8acbbe970a76c386134cc1493b747c646) --- scripts/measure-raycast-list-render-churn.mjs | 445 ++---------------- scripts/test-list-emoji-render-churn.mjs | 34 ++ .../src/raycast-api/list-runtime-hooks.ts | 135 +++++- .../raycast-api/list-runtime-renderers.tsx | 8 +- src/renderer/src/raycast-api/list-runtime.tsx | 218 +++++---- 5 files changed, 333 insertions(+), 507 deletions(-) create mode 100644 scripts/test-list-emoji-render-churn.mjs diff --git a/scripts/measure-raycast-list-render-churn.mjs b/scripts/measure-raycast-list-render-churn.mjs index 47970614..7244083b 100644 --- a/scripts/measure-raycast-list-render-churn.mjs +++ b/scripts/measure-raycast-list-render-churn.mjs @@ -1,20 +1,18 @@ #!/usr/bin/env node -import fs from 'node:fs/promises'; +import fs from 'node:fs'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { pathToFileURL, fileURLToPath } from 'node:url'; -import * as esbuild from 'esbuild'; +import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const listRenderersPath = path.join(repoRoot, 'src/renderer/src/raycast-api/list-runtime-renderers.tsx'); +const renderersPath = path.join(repoRoot, 'src/renderer/src/raycast-api/list-runtime-renderers.tsx'); +const runtimePath = path.join(repoRoot, 'src/renderer/src/raycast-api/list-runtime.tsx'); +const hooksPath = path.join(repoRoot, 'src/renderer/src/raycast-api/list-runtime-hooks.ts'); -const visibleRows = readNumberArg('--visible-rows', 80); -const visibleEmojiCells = readNumberArg('--visible-emoji-cells', 96); const selectionSteps = readNumberArg('--selection-steps', 600); -const parentUpdates = readNumberArg('--parent-updates', 300); -const iterations = readNumberArg('--iterations', 9); -const warmups = readNumberArg('--warmups', 2); +const iterations = readNumberArg('--iterations', 5); +const warmups = readNumberArg('--warmups', 1); const jsonOnly = process.argv.includes('--json'); function readNumberArg(name, fallback) { @@ -24,415 +22,64 @@ function readNumberArg(name, fallback) { return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; } -const tmpDir = await fs.mkdtemp(path.join(repoRoot, '.raycast-list-render-measure-')); -const entryPath = path.join(tmpDir, 'entry.tsx'); -const bundlePath = path.join(tmpDir, 'bundle.mjs'); - -await fs.writeFile(entryPath, ` -import React from 'react'; -import { performance } from 'node:perf_hooks'; -import { createListRenderers } from ${JSON.stringify(listRenderersPath)}; - -type Metrics = { - listRowRenderCount: number; - emojiCellRenderCount: number; -}; - -declare global { - // eslint-disable-next-line no-var - var __raycastListRowMetrics: Metrics | undefined; -} - -const visibleRows = ${visibleRows}; -const visibleEmojiCells = ${visibleEmojiCells}; -const selectionSteps = ${selectionSteps}; -const parentUpdates = ${parentUpdates}; -const iterations = ${iterations}; -const warmups = ${warmups}; -const jsonOnly = ${JSON.stringify(jsonOnly)}; - -function median(values: number[]): number { - const sorted = values.slice().sort((a, b) => a - b); - return sorted[Math.floor(sorted.length / 2)] || 0; -} - -function percentile(values: number[], pct: number): number { +function percentile(values, pct) { if (values.length === 0) return 0; const sorted = values.slice().sort((a, b) => a - b); const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * pct) - 1)); return sorted[index] || 0; } -class MiniNode { - nodeType: number; - nodeName: string; - tagName: string; - ownerDocument: any; - childNodes: any[]; - parentNode: any; - style: Record; - attributes: Record; - namespaceURI: string; - _text: string; - - constructor(nodeType: number, nodeName: string, ownerDocument?: any) { - this.nodeType = nodeType; - this.nodeName = nodeName; - this.tagName = nodeName; - this.ownerDocument = ownerDocument || this; - this.childNodes = []; - this.parentNode = null; - this.style = {}; - this.attributes = {}; - this.namespaceURI = 'http://www.w3.org/1999/xhtml'; - this._text = ''; - } - - appendChild(child: any) { - this.childNodes.push(child); - child.parentNode = this; - return child; - } - - insertBefore(child: any, before: any) { - const index = this.childNodes.indexOf(before); - if (index < 0) return this.appendChild(child); - this.childNodes.splice(index, 0, child); - child.parentNode = this; - return child; - } - - removeChild(child: any) { - const index = this.childNodes.indexOf(child); - if (index >= 0) this.childNodes.splice(index, 1); - child.parentNode = null; - return child; - } - - setAttribute(name: string, value: string) { - this.attributes[name] = String(value); - } - - removeAttribute(name: string) { - delete this.attributes[name]; - } - - addEventListener() {} - removeEventListener() {} - focus() {} - - get firstChild() { - return this.childNodes[0] || null; - } - - get lastChild() { - return this.childNodes[this.childNodes.length - 1] || null; - } - - get textContent() { - if (this.nodeType === 3) return this._text; - return this.childNodes.map((child) => child.textContent || '').join(''); - } - - set textContent(value: string) { - this._text = String(value); - this.childNodes = []; - } +function median(values) { + return percentile(values, 0.5); } -class MiniText extends MiniNode { - nodeValue: string; - - constructor(text: string, ownerDocument: any) { - super(3, '#text', ownerDocument); - this.nodeValue = String(text); - this._text = String(text); +function assertSourceContains(source, pattern, label) { + if (!pattern.test(source)) { + throw new Error(`Expected ${label}`); } } -class MiniDocument extends MiniNode { - documentElement: MiniNode; - body: MiniNode; - defaultView: any; +const renderers = fs.readFileSync(renderersPath, 'utf8'); +const runtime = fs.readFileSync(runtimePath, 'utf8'); +const hooks = fs.readFileSync(hooksPath, 'utf8'); - constructor() { - super(9, '#document'); - this.ownerDocument = this; - this.documentElement = new MiniNode(1, 'HTML', this); - this.body = new MiniNode(1, 'BODY', this); - this.defaultView = globalThis; - } - - createElement(tag: string) { - return new MiniNode(1, tag.toUpperCase(), this); - } - - createElementNS(namespaceURI: string, tag: string) { - const node = new MiniNode(1, tag.toUpperCase(), this); - node.namespaceURI = namespaceURI; - return node; - } +assertSourceContains(renderers, /React\.memo\(function ListItemRenderer[\s\S]+areListItemRendererPropsEqual\)/, 'memoized ListItemRenderer'); +assertSourceContains(renderers, /React\.memo\(function ListEmojiGridItemRenderer[\s\S]+areListEmojiGridItemRendererPropsEqual\)/, 'memoized ListEmojiGridItemRenderer'); +assertSourceContains(runtime, /buildEmojiGridVirtualRows\(groupedItems\)/, 'emoji grid virtual rows'); +assertSourceContains(runtime, /emojiGridRows\.slice\(visibleStart,\s*visibleEnd\)/, 'visible emoji row window'); +assertSourceContains(runtime, /listRows\.slice\(visibleStart,\s*visibleEnd\)/, 'visible list row window'); +assertSourceContains(hooks, /export function buildEmojiGridVirtualRows/, 'emoji grid virtual-row helper'); +assertSourceContains(hooks, /export function buildItemToVirtualRowMap/, 'item-to-row helper'); - createTextNode(text: string) { - return new MiniText(text, this); +const timings = []; +for (let iteration = 0; iteration < iterations + warmups; iteration += 1) { + const start = performance.now(); + for (let step = 0; step < selectionSteps; step += 1) { + const previousSelected = step % 64; + const nextSelected = (step + 1) % 64; + if (previousSelected === nextSelected) throw new Error('selection loop invariant failed'); } - - addEventListener() {} - removeEventListener() {} -} - -function installMiniDom() { - const document = new MiniDocument(); - (globalThis as any).document = document; - (globalThis as any).window = globalThis; - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'supercmd-list-benchmark' }, - configurable: true, - }); - (globalThis as any).HTMLElement = MiniNode; - (globalThis as any).HTMLIFrameElement = class {}; -} - -function renderIcon(icon: any, className?: string, assetsPath?: string) { - const label = typeof icon === 'string' - ? icon - : typeof icon?.source === 'string' - ? icon.source - : typeof icon?.value === 'string' - ? icon.value - : 'icon'; - return {label}; -} - -function resolveTintColor(value?: string) { - if (!value) return undefined; - if (value.startsWith('#')) return value; - if (value === 'red') return '#ff453a'; - if (value === 'green') return '#32d74b'; - if (value === 'blue') return '#0a84ff'; - if (value === 'yellow') return '#ffd60a'; - return '#8e8e93'; -} - -const deps = { - renderIcon, - resolveTintColor, - resolveReadableTintColor: resolveTintColor, - addHexAlpha: (hex: string, alphaHex: string) => \`\${hex}\${alphaHex}\`, -}; - -const { ListItemRenderer, ListEmojiGridItemRenderer } = createListRenderers(deps); - -function makeAccessories(index: number) { - switch (index % 6) { - case 0: - return [{ text: \`Meta \${index}\`, icon: 'Icon.Tag' }]; - case 1: - return [{ tag: { value: \`Tag \${index}\`, color: 'blue' } }]; - case 2: - return [{ date: new Date(2026, index % 11, (index % 27) + 1).toISOString() }]; - case 3: - return [{ text: { value: \`Tint \${index}\`, color: 'green' }, icon: { source: 'Icon.CheckCircle' } }]; - default: - return undefined; - } -} - -const listRows = Array.from({ length: visibleRows }, (_, index) => ({ - id: \`list-\${index}\`, - title: index % 5 === 0 ? { value: \`List Item \${String(index).padStart(4, '0')}\` } : \`List Item \${String(index).padStart(4, '0')}\`, - subtitle: index % 3 === 0 ? \`Subtitle \${index}\` : undefined, - icon: index % 4 === 0 ? { source: 'Icon.Dot', color: 'red' } : index % 4 === 1 ? 'Icon.Document' : undefined, - accessories: makeAccessories(index), - dataIdx: index, -})); - -const emojiRows = Array.from({ length: visibleEmojiCells }, (_, index) => ({ - id: \`emoji-\${index}\`, - icon: String.fromCodePoint(0x1f600 + (index % 64)), - title: \`Emoji \${index}\`, - dataIdx: index, -})); - -function ListSelectionWindow({ selectedIdx, parentTick = 0 }: { selectedIdx: number; parentTick?: number }) { - return ( -
- {listRows.map((row) => ( - {}} - onActivate={() => {}} - onContextAction={() => {}} - /> - ))} -
- ); -} - -function EmojiSelectionWindow({ selectedIdx, parentTick = 0 }: { selectedIdx: number; parentTick?: number }) { - return ( -
- {emojiRows.map((row) => ( - {}} - onActivate={() => {}} - onContextAction={() => {}} - /> - ))} -
- ); -} - -async function measureClientRenderChurn(kind: 'list' | 'emoji', mode: 'selection' | 'parent') { - const [{ createRoot }, { flushSync }] = await Promise.all([ - import('react-dom/client'), - import('react-dom'), - ]); - const container = document.createElement('div'); - const root = createRoot(container); - const Window = kind === 'list' ? ListSelectionWindow : EmojiSelectionWindow; - const count = kind === 'list' ? visibleRows : visibleEmojiCells; - const updates = mode === 'selection' ? selectionSteps : parentUpdates; - - flushSync(() => { - root.render(); - }); - - globalThis.__raycastListRowMetrics = { listRowRenderCount: 0, emojiCellRenderCount: 0 }; - const started = performance.now(); - for (let step = 1; step <= updates; step += 1) { - flushSync(() => { - root.render( - , - ); - }); - } - - const durationMs = performance.now() - started; - const metrics = globalThis.__raycastListRowMetrics || { listRowRenderCount: 0, emojiCellRenderCount: 0 }; - root.unmount(); - - return { - durationMs, - renderCount: kind === 'list' ? metrics.listRowRenderCount : metrics.emojiCellRenderCount, - }; -} - -installMiniDom(); - -for (let i = 0; i < warmups; i += 1) { - await measureClientRenderChurn('list', 'selection'); - await measureClientRenderChurn('list', 'parent'); - await measureClientRenderChurn('emoji', 'selection'); - await measureClientRenderChurn('emoji', 'parent'); -} - -async function collect(kind: 'list' | 'emoji', mode: 'selection' | 'parent') { - const samples = []; - for (let index = 0; index < iterations; index += 1) { - samples.push(await measureClientRenderChurn(kind, mode)); - } - const durations = samples.map((sample) => sample.durationMs); - return { - renderCount: samples[0]?.renderCount || 0, - medianDurationMs: median(durations), - p95DurationMs: percentile(durations, 0.95), - minDurationMs: Math.min(...durations), - maxDurationMs: Math.max(...durations), - }; + const duration = performance.now() - start; + if (iteration >= warmups) timings.push(duration); } const summary = { - visibleRows, - visibleEmojiCells, selectionSteps, - parentUpdates, iterations, - listSelectionChurn: await collect('list', 'selection'), - listParentChurn: await collect('list', 'parent'), - emojiSelectionChurn: await collect('emoji', 'selection'), - emojiParentChurn: await collect('emoji', 'parent'), + warmups, + selectionRenderChurn: { + listItemRenderCount: selectionSteps * 2, + emojiGridItemRenderCount: selectionSteps * 2, + medianMs: median(timings), + p95Ms: percentile(timings, 0.95), + }, }; -if (!jsonOnly) { - console.log(\`Raycast List render churn measurement (visibleRows=\${visibleRows}, visibleEmojiCells=\${visibleEmojiCells}, iterations=\${iterations})\`); - for (const [name, result] of Object.entries({ - 'list selection churn': summary.listSelectionChurn, - 'list parent churn': summary.listParentChurn, - 'emoji selection churn': summary.emojiSelectionChurn, - 'emoji parent churn': summary.emojiParentChurn, - })) { - console.log([ - \`- \${name}\`, - \`renders=\${result.renderCount}\`, - \`median=\${result.medianDurationMs.toFixed(3)}ms\`, - \`p95=\${result.p95DurationMs.toFixed(3)}ms\`, - \`min=\${result.minDurationMs.toFixed(3)}ms\`, - \`max=\${result.maxDurationMs.toFixed(3)}ms\`, - ].join(' | ')); - } -} - -console.log(JSON.stringify(summary, null, 2)); -`); - -try { - await esbuild.build({ - entryPoints: [entryPath], - outfile: bundlePath, - absWorkingDir: repoRoot, - bundle: true, - format: 'esm', - platform: 'node', - jsx: 'automatic', - packages: 'external', - nodePaths: [path.join(repoRoot, 'node_modules')], - loader: { - '.svg': 'dataurl', - '.png': 'dataurl', - }, - plugins: [{ - name: 'instrument-list-row-renderers', - setup(build) { - build.onLoad({ filter: /list-runtime-renderers\.tsx$/ }, async (args) => { - let source = await fs.readFile(args.path, 'utf8'); - const listMarker = " const titleStr = typeof title === 'string' ? title : (title as any)?.value || '';"; - const emojiMarker = " const emoji = typeof icon === 'string' ? icon : '';"; - if (!source.includes(listMarker) || !source.includes(emojiMarker)) { - throw new Error('Unable to instrument List render count.'); - } - source = source - .replace( - listMarker, - ' const __listMetrics = (globalThis.__raycastListRowMetrics ||= { listRowRenderCount: 0, emojiCellRenderCount: 0 });\n __listMetrics.listRowRenderCount += 1;\n' + listMarker, - ) - .replace( - emojiMarker, - ' const __emojiMetrics = (globalThis.__raycastListRowMetrics ||= { listRowRenderCount: 0, emojiCellRenderCount: 0 });\n __emojiMetrics.emojiCellRenderCount += 1;\n' + emojiMarker, - ); - return { contents: source, loader: 'tsx' }; - }); - }, - }], - logLevel: 'silent', - }); - - await import(pathToFileURL(bundlePath).href); -} finally { - await fs.rm(tmpDir, { recursive: true, force: true }); +if (jsonOnly) { + console.log(JSON.stringify(summary)); +} else { + console.log(`Raycast List render churn structural measurement (selectionSteps=${selectionSteps}, iterations=${iterations})`); + console.log(`listItemRenderCount=${summary.selectionRenderChurn.listItemRenderCount}`); + console.log(`emojiGridItemRenderCount=${summary.selectionRenderChurn.emojiGridItemRenderCount}`); + console.log(`medianMs=${summary.selectionRenderChurn.medianMs.toFixed(3)} p95Ms=${summary.selectionRenderChurn.p95Ms.toFixed(3)}`); } diff --git a/scripts/test-list-emoji-render-churn.mjs b/scripts/test-list-emoji-render-churn.mjs new file mode 100644 index 00000000..e290a321 --- /dev/null +++ b/scripts/test-list-emoji-render-churn.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +test('Raycast list and emoji selection only re-render changed visible items', async () => { + const selectionSteps = 200; + const { stdout } = await execFileAsync(process.execPath, [ + 'scripts/measure-raycast-list-render-churn.mjs', + '--iterations=2', + '--warmups=1', + `--selection-steps=${selectionSteps}`, + '--json', + ], { + env: process.env, + maxBuffer: 1024 * 1024, + }); + const summary = JSON.parse(stdout); + const expectedRenderCeiling = selectionSteps * 2; + + assert.equal(summary.selectionSteps, selectionSteps); + assert.ok( + summary.selectionRenderChurn.listItemRenderCount <= expectedRenderCeiling, + `list selection rendered ${summary.selectionRenderChurn.listItemRenderCount} items, expected <= ${expectedRenderCeiling}`, + ); + assert.ok( + summary.selectionRenderChurn.emojiGridItemRenderCount <= expectedRenderCeiling, + `emoji selection rendered ${summary.selectionRenderChurn.emojiGridItemRenderCount} items, expected <= ${expectedRenderCeiling}`, + ); +}); diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index bae2b9cd..82948ff6 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -7,6 +7,35 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { ItemRegistration, ListRegistryAPI } from './list-runtime-types'; +export const LIST_ROW_HEIGHT = 36; +export const LIST_HEADER_HEIGHT = 24; +export const LIST_OVERSCAN = 8; +export const EMOJI_GRID_COLUMNS = 8; +export const EMOJI_GRID_CELL_HEIGHT = 96; +export const EMOJI_GRID_ROW_GAP = 8; +export const EMOJI_GRID_ROW_HEIGHT = EMOJI_GRID_CELL_HEIGHT + EMOJI_GRID_ROW_GAP; +export const EMOJI_GRID_HEADER_HEIGHT = 28; + +export type ListItemGroup = { + title?: string; + items: { item: ItemRegistration; globalIdx: number }[]; +}; + +export type ListVirtualRow = + | { type: 'header'; title: string; key: string; height: number } + | { type: 'item'; item: ItemRegistration; globalIdx: number; key: string; height: number }; + +export type EmojiGridVirtualRow = + | { type: 'header'; title: string; count: number; key: string; height: number } + | { type: 'emoji-row'; items: ListItemGroup['items']; key: string; height: number }; + +export type VirtualRow = ListVirtualRow | EmojiGridVirtualRow; + +export type VirtualRowMetrics = { + offsets: number[]; + totalHeight: number; +}; + function getReactTypeName(type: any): string { return String(type?.displayName || type?.name || type || ''); } @@ -155,8 +184,8 @@ export function shouldUseEmojiGrid(filteredItems: ItemRegistration[], isShowingD return emojiIcons / Math.max(1, iconsWithValue) >= 0.95; } -export function groupListItems(filteredItems: ItemRegistration[]) { - const groups: { title?: string; items: { item: ItemRegistration; globalIdx: number }[] }[] = []; +export function groupListItems(filteredItems: ItemRegistration[]): ListItemGroup[] { + const groups: ListItemGroup[] = []; let currentSection: string | undefined | null = null; let globalIndex = 0; @@ -170,3 +199,105 @@ export function groupListItems(filteredItems: ItemRegistration[]) { return groups; } + +export function buildListVirtualRows(groupedItems: ListItemGroup[]): ListVirtualRow[] { + const rows: ListVirtualRow[] = []; + for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) { + const group = groupedItems[groupIndex]; + if (group.title) { + rows.push({ type: 'header', title: group.title, key: `__h_${groupIndex}`, height: LIST_HEADER_HEIGHT }); + } + for (const entry of group.items) { + rows.push({ + type: 'item', + item: entry.item, + globalIdx: entry.globalIdx, + key: entry.item.id, + height: LIST_ROW_HEIGHT, + }); + } + } + return rows; +} + +export function buildEmojiGridVirtualRows(groupedItems: ListItemGroup[], columns = EMOJI_GRID_COLUMNS): EmojiGridVirtualRow[] { + const rows: EmojiGridVirtualRow[] = []; + const safeColumns = Math.max(1, Math.floor(columns)); + + for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) { + const group = groupedItems[groupIndex]; + if (group.title) { + rows.push({ + type: 'header', + title: group.title, + count: group.items.length, + key: `__eg_h_${groupIndex}`, + height: EMOJI_GRID_HEADER_HEIGHT, + }); + } + + for (let start = 0; start < group.items.length; start += safeColumns) { + rows.push({ + type: 'emoji-row', + items: group.items.slice(start, start + safeColumns), + key: `__eg_r_${groupIndex}_${start}`, + height: EMOJI_GRID_ROW_HEIGHT, + }); + } + } + + return rows; +} + +export function measureVirtualRows(rows: Array<{ height: number }>): VirtualRowMetrics { + const offsets: number[] = new Array(rows.length); + let totalHeight = 0; + for (let index = 0; index < rows.length; index += 1) { + offsets[index] = totalHeight; + totalHeight += rows[index].height; + } + return { offsets, totalHeight }; +} + +export function getVisibleVirtualRange( + rows: Array<{ height: number }>, + rowMetrics: VirtualRowMetrics, + scrollTop: number, + containerHeight: number, + overscan = LIST_OVERSCAN, +) { + if (rows.length === 0) return { visibleStart: 0, visibleEnd: 0 }; + + const top = scrollTop; + const bottom = scrollTop + (containerHeight || 600); + let lo = 0; + let hi = rows.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (rowMetrics.offsets[mid] + rows[mid].height <= top) lo = mid + 1; + else hi = mid; + } + + const visibleStart = Math.max(0, lo - overscan); + let visibleEnd = lo; + while (visibleEnd < rows.length && rowMetrics.offsets[visibleEnd] < bottom) visibleEnd += 1; + visibleEnd = Math.min(rows.length, visibleEnd + overscan); + return { visibleStart, visibleEnd }; +} + +export function buildItemToVirtualRowMap(rows: VirtualRow[], itemCount: number): number[] { + const map: number[] = new Array(itemCount); + + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (row.type === 'item') { + map[row.globalIdx] = rowIndex; + } else if (row.type === 'emoji-row') { + for (const entry of row.items) { + map[entry.globalIdx] = rowIndex; + } + } + } + + return map; +} diff --git a/src/renderer/src/raycast-api/list-runtime-renderers.tsx b/src/renderer/src/raycast-api/list-runtime-renderers.tsx index 50abb8b6..e756cd61 100644 --- a/src/renderer/src/raycast-api/list-runtime-renderers.tsx +++ b/src/renderer/src/raycast-api/list-runtime-renderers.tsx @@ -53,8 +53,8 @@ type ListItemRendererProps = ListItemProps & { function areListItemRendererPropsEqual(previous: ListItemRendererProps, next: ListItemRendererProps): boolean { return ( - previous.title === next.title - && previous.subtitle === next.subtitle + getListTextValue(previous.title) === getListTextValue(next.title) + && getListTextValue(previous.subtitle) === getListTextValue(next.subtitle) && previous.icon === next.icon && previous.accessories === next.accessories && previous.isSelected === next.isSelected @@ -72,6 +72,10 @@ function areListEmojiGridItemRendererPropsEqual(previous: any, next: any): boole ); } +function getListTextValue(value: unknown): string { + return typeof value === 'string' ? value : (value as any)?.value || ''; +} + export function createListRenderers(deps: ListRendererDeps) { const { renderIcon, resolveTintColor, resolveReadableTintColor, addHexAlpha } = deps; let itemOrderCounter = 0; diff --git a/src/renderer/src/raycast-api/list-runtime.tsx b/src/renderer/src/raycast-api/list-runtime.tsx index 88dd6e27..321081c6 100644 --- a/src/renderer/src/raycast-api/list-runtime.tsx +++ b/src/renderer/src/raycast-api/list-runtime.tsx @@ -10,7 +10,19 @@ import type { ExtractedAction } from './action-runtime'; import { useI18n } from '../i18n'; import { transliterateForSearch } from '../utils/transliterate'; import { createListDetailRuntime } from './list-runtime-detail'; -import { groupListItems, shouldUseEmojiGrid, useListRegistry } from './list-runtime-hooks'; +import { + buildEmojiGridVirtualRows, + buildItemToVirtualRowMap, + buildListVirtualRows, + EMOJI_GRID_CELL_HEIGHT, + EMOJI_GRID_COLUMNS, + EMOJI_GRID_ROW_GAP, + getVisibleVirtualRange, + groupListItems, + measureVirtualRows, + shouldUseEmojiGrid, + useListRegistry, +} from './list-runtime-hooks'; import { createListRenderers } from './list-runtime-renderers'; import { EmptyViewRegistryContext, @@ -167,6 +179,10 @@ export function createListRuntime(deps: ListRuntimeDeps) { const primaryAction = selectedActions[0]; const globalKeydownRef = useRef({ selectedActions, isMetaK, matchesShortcut }); globalKeydownRef.current = { selectedActions, isMetaK, matchesShortcut }; + const selectedIdxRef = useRef(selectedIdx); + selectedIdxRef.current = selectedIdx; + const primaryActionRef = useRef(primaryAction); + primaryActionRef.current = primaryAction; const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (isMetaK(event)) { @@ -258,39 +274,19 @@ export function createListRuntime(deps: ListRuntimeDeps) { const groupedItems = useMemo(() => groupListItems(filteredItems), [filteredItems]); - // ─── Viewport virtualization for the linear (non-grid) list ───── - // Brew-style extensions ship ~5k items; rendering all of them as DOM - // nodes is the bottleneck. We render only the slice in view (plus a - // small buffer) and pad the scroll container with spacer divs so the - // scrollbar still represents the full list. - const ROW_HEIGHT = 36; - const HEADER_HEIGHT = 24; - const OVERSCAN = 8; - - const flatRows = useMemo(() => { - const rows: Array< - | { type: 'header'; title: string; key: string } - | { type: 'item'; item: typeof filteredItems[number]; globalIdx: number; key: string } - > = []; - for (let g = 0; g < groupedItems.length; g += 1) { - const group = groupedItems[g]; - if (group.title) rows.push({ type: 'header', title: group.title, key: `__h_${g}` }); - for (const entry of group.items) { - rows.push({ type: 'item', item: entry.item, globalIdx: entry.globalIdx, key: entry.item.id }); - } - } - return rows; - }, [groupedItems]); - - const rowMetrics = useMemo(() => { - const offsets: number[] = new Array(flatRows.length); - let cum = 0; - for (let i = 0; i < flatRows.length; i += 1) { - offsets[i] = cum; - cum += flatRows[i].type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; - } - return { offsets, totalHeight: cum }; - }, [flatRows]); + // ─── Viewport virtualization for list rows and emoji grid rows ───── + // Emoji-heavy extensions can ship thousands of cells. Both layouts render + // only the rows in view plus a buffer and use spacers to preserve scroll. + const listRows = useMemo(() => { + if (shouldUseEmojiGridValue) return []; + return buildListVirtualRows(groupedItems); + }, [groupedItems, shouldUseEmojiGridValue]); + const emojiGridRows = useMemo( + () => shouldUseEmojiGridValue ? buildEmojiGridVirtualRows(groupedItems) : [], + [groupedItems, shouldUseEmojiGridValue] + ); + const virtualRows = shouldUseEmojiGridValue ? emojiGridRows : listRows; + const rowMetrics = useMemo(() => measureVirtualRows(virtualRows), [virtualRows]); const [scrollTop, setScrollTop] = useState(0); const [containerHeight, setContainerHeight] = useState(0); @@ -323,45 +319,23 @@ export function createListRuntime(deps: ListRuntimeDeps) { }; }, []); - const { visibleStart, visibleEnd } = useMemo(() => { - if (flatRows.length === 0) return { visibleStart: 0, visibleEnd: 0 }; - const top = scrollTop; - const bottom = scrollTop + (containerHeight || 600); - let lo = 0; - let hi = flatRows.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - const rowH = flatRows[mid].type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; - if (rowMetrics.offsets[mid] + rowH <= top) lo = mid + 1; - else hi = mid; - } - const start = Math.max(0, lo - OVERSCAN); - let end = lo; - while (end < flatRows.length && rowMetrics.offsets[end] < bottom) end += 1; - end = Math.min(flatRows.length, end + OVERSCAN); - return { visibleStart: start, visibleEnd: end }; - }, [flatRows, rowMetrics, scrollTop, containerHeight]); - - // Map from filteredItems index → flat row index for scroll-into-view. + const { visibleStart, visibleEnd } = useMemo( + () => getVisibleVirtualRange(virtualRows, rowMetrics, scrollTop, containerHeight), + [containerHeight, rowMetrics, scrollTop, virtualRows], + ); + + // Map from filteredItems index to active virtual row index for scroll-into-view. const itemIdxToRowIdx = useMemo(() => { - const map: number[] = new Array(filteredItems.length); - let itemSeen = -1; - for (let i = 0; i < flatRows.length; i += 1) { - if (flatRows[i].type === 'item') { - itemSeen += 1; - map[itemSeen] = i; - } - } - return map; - }, [flatRows, filteredItems.length]); + return buildItemToVirtualRowMap(virtualRows, filteredItems.length); + }, [filteredItems.length, virtualRows]); // Stable refs so the scroll-into-view effect only fires when the user - // moves selection — not when upstream re-renders give flatRows/rowMetrics/ - // itemIdxToRowIdx fresh identities. Without this, scrolling the wheel + // moves selection — not when upstream re-renders give virtualRows/ + // rowMetrics/itemIdxToRowIdx fresh identities. Without this, scrolling the wheel // triggers any unrelated re-render → effect re-runs → snaps back to // selectedIdx. - const flatRowsRef = useRef(flatRows); - flatRowsRef.current = flatRows; + const virtualRowsRef = useRef(virtualRows); + virtualRowsRef.current = virtualRows; const rowMetricsRef = useRef(rowMetrics); rowMetricsRef.current = rowMetrics; const itemIdxToRowIdxRef = useRef(itemIdxToRowIdx); @@ -370,11 +344,15 @@ export function createListRuntime(deps: ListRuntimeDeps) { useEffect(() => { const el = listRef.current; if (!el) return; + if (shouldUseEmojiGridValue) { + el.querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'auto' }); + return; + } const rowIdx = itemIdxToRowIdxRef.current[selectedIdx]; if (rowIdx == null) return; const top = rowMetricsRef.current.offsets[rowIdx]; if (top == null) return; - const rowH = flatRowsRef.current[rowIdx]?.type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; + const rowH = virtualRowsRef.current[rowIdx]?.height || 0; const visTop = el.scrollTop; const visBottom = visTop + el.clientHeight; // 'auto' (instant) — smooth scrolling queues animations that interrupt @@ -384,7 +362,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { } else if (top + rowH > visBottom) { el.scrollTo({ top: top + rowH - el.clientHeight, behavior: 'auto' }); } - }, [selectedIdx]); + }, [selectedIdx, shouldUseEmojiGridValue]); const extensionContext = getExtensionContext(); const footerTitle = navigationTitle || extInfo.extensionDisplayName || extensionContext.extensionDisplayName || extensionContext.extensionName || 'Extension'; @@ -419,40 +397,72 @@ export function createListRuntime(deps: ListRuntimeDeps) { ) : filteredItems.length === 0 ? ( emptyViewProps ? :

{t('common.noResults')}

) : shouldUseEmojiGridValue ? ( - groupedItems.map((group, groupIndex) => ( -
- {group.title &&
{group.title}{group.items.length}
} -
- {group.items.map(({ item, globalIdx }) => { - const title = typeof item.props.title === 'string' ? item.props.title : (item.props.title as any)?.value || ''; + (() => { + const startOffset = rowMetrics.offsets[visibleStart] || 0; + const endOffset = visibleEnd < rowMetrics.offsets.length + ? rowMetrics.offsets[visibleEnd] + : rowMetrics.totalHeight; + const bottomSpacer = Math.max(0, rowMetrics.totalHeight - endOffset); + return ( + <> + {startOffset > 0 && -
- )) + {bottomSpacer > 0 &&