diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..93b44df5 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,8 @@ on: jobs: claude-review: + # Fork PRs cannot receive the OIDC token required by this action. + if: github.event.pull_request.head.repo.full_name == github.repository # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || @@ -41,4 +43,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecfdadf2..04e21a91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --force - name: Run node test suite run: npm test 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/measure-raycast-list-render-churn.mjs b/scripts/measure-raycast-list-render-churn.mjs new file mode 100644 index 00000000..7244083b --- /dev/null +++ b/scripts/measure-raycast-list-render-churn.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +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 selectionSteps = readNumberArg('--selection-steps', 600); +const iterations = readNumberArg('--iterations', 5); +const warmups = readNumberArg('--warmups', 1); +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; +} + +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; +} + +function median(values) { + return percentile(values, 0.5); +} + +function assertSourceContains(source, pattern, label) { + if (!pattern.test(source)) { + throw new Error(`Expected ${label}`); + } +} + +const renderers = fs.readFileSync(renderersPath, 'utf8'); +const runtime = fs.readFileSync(runtimePath, 'utf8'); +const hooks = fs.readFileSync(hooksPath, 'utf8'); + +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'); + +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'); + } + const duration = performance.now() - start; + if (iteration >= warmups) timings.push(duration); +} + +const summary = { + selectionSteps, + iterations, + warmups, + selectionRenderChurn: { + listItemRenderCount: selectionSteps * 2, + emojiGridItemRenderCount: selectionSteps * 2, + medianMs: median(timings), + p95Ms: percentile(timings, 0.95), + }, +}; + +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-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..f4d0524a --- /dev/null +++ b/scripts/test-list-dropdown-memoization.mjs @@ -0,0 +1,260 @@ +#!/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 memo(type, compare) { + return { $$typeof: Symbol.for('react.memo'), type, compare }; + } + 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, + memo, + 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/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/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/scripts/test-menubar-native-update-cache.mjs b/scripts/test-menubar-native-update-cache.mjs new file mode 100644 index 00000000..894228e7 --- /dev/null +++ b/scripts/test-menubar-native-update-cache.mjs @@ -0,0 +1,26 @@ +#!/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)), '..'); +const source = fs.readFileSync(path.join(repoRoot, 'src/main/main.ts'), 'utf8'); + +test('native menubar skips unchanged file-backed tray image refreshes', () => { + assert.match(source, /const menuBarTrayUpdateStates = new Map/); + assert.match(source, /function getMenuBarTrayIconKey\(data: any\): string/); + assert.match(source, /function getMenuBarFileIconIdentityKey\(iconPath: unknown\): string \| null/); + assert.match(source, /statSync\(pathValue\)/); + assert.match(source, /mtimeMs=\$\{mtimeMs\}\|bytes=\$\{size\}/); + assert.match(source, /updateState\.iconKey !== nextIconKey \|\| updateState\.iconFileIdentityKey !== nextIconFileIdentityKey/); +}); + +test('native menubar preserves title fallback state when image refresh is skipped', () => { + assert.match(source, /lastResolvedTrayIconOk: boolean/); + assert.match(source, /let lastResolvedTrayIconOk = updateState\.lastResolvedTrayIconOk;/); + assert.match(source, /updateState\.lastResolvedTrayIconOk = lastResolvedTrayIconOk;/); + assert.match(source, /menuBarTrayUpdateStates\.delete\(extId\);/); +}); diff --git a/scripts/test-menubar-renderer-serialization-cache.mjs b/scripts/test-menubar-renderer-serialization-cache.mjs new file mode 100644 index 00000000..53dcbf1f --- /dev/null +++ b/scripts/test-menubar-renderer-serialization-cache.mjs @@ -0,0 +1,26 @@ +#!/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)), '..'); +const source = fs.readFileSync(path.join(repoRoot, 'src/renderer/src/raycast-api/menubar-runtime-parent.tsx'), 'utf8'); + +test('MenuBarExtra renderer reuses serialized items for title-only ticks', () => { + assert.match(source, /type SerializedMenuBarItemsCache = \{[\s\S]*registryVersion: number;[\s\S]*assetsPath: string;[\s\S]*items: any\[\];[\s\S]*actions: Map void>;/); + assert.match(source, /const serializedItemsCacheRef = useRef\(null\);/); + assert.match(source, /cachedItems\?\.registryVersion === registryVersion[\s\S]*cachedItems\.assetsPath === assetsPath/); + assert.match(source, /actions = cachedItems\.actions;[\s\S]*dividedSerialized = cachedItems\.items;/); + assert.match(source, /serializedItemsCacheRef\.current = \{[\s\S]*registryVersion,[\s\S]*assetsPath,[\s\S]*actions,[\s\S]*items: dividedSerialized,[\s\S]*\};/); +}); + +test('MenuBarExtra renderer still installs actions before sending payloads', () => { + const setActionsIndex = source.indexOf('setMenuBarActions(extId, actions'); + const updateIndex = source.indexOf('updateMenuBar?.(payload)'); + assert.notEqual(setActionsIndex, -1, 'expected setMenuBarActions call'); + assert.notEqual(updateIndex, -1, 'expected updateMenuBar call'); + assert.ok(setActionsIndex < updateIndex, 'actions should be refreshed before visible payload send'); +}); 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/scripts/test-virtual-selection-scroll-behavior.mjs b/scripts/test-virtual-selection-scroll-behavior.mjs new file mode 100644 index 00000000..c88b98c1 --- /dev/null +++ b/scripts/test-virtual-selection-scroll-behavior.mjs @@ -0,0 +1,81 @@ +#!/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 listSelectionScroll = sourceWindow( + readRepoFile('src/renderer/src/raycast-api/list-runtime.tsx'), + 'const rowIdx = itemIdxToRowIdxRef.current[selectedIdx]' + ); + assert.match( + listSelectionScroll, + /el\.scrollTo\(\{\s*top,\s*behavior:\s*'auto'\s*\}\);/, + 'Raycast list selection correction should scroll instantly to the selected virtual row' + ); + assert.match( + listSelectionScroll, + /el\.scrollTo\(\{\s*top:\s*top \+ rowH - el\.clientHeight,\s*behavior:\s*'auto'\s*\}\);/, + 'Raycast list selection correction should scroll instantly when the selected virtual row is below the viewport' + ); + assert.doesNotMatch( + listSelectionScroll, + /querySelector\(`\[data-idx="\$\{selectedIdx\}"\]`\)/, + 'Raycast list selection correction must not rely on virtualized selected cells being mounted' + ); + assert.doesNotMatch( + listSelectionScroll, + /scrollIntoView/, + 'Raycast list selection correction must use virtual row offsets for large jumps' + ); + assert.doesNotMatch( + listSelectionScroll, + /behavior:\s*['"]smooth['"]/, + 'Raycast list selection correction must not queue smooth scroll 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/main/main.ts b/src/main/main.ts index 94985e40..2d45b490 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -7370,8 +7370,50 @@ app.on('open-url', (event: any, url: string) => { // ─── Menu Bar (Tray) Management ───────────────────────────────────── const menuBarTrays = new Map>(); +const menuBarTrayUpdateStates = new Map(); let appTray: InstanceType | null = null; +function getMenuBarTrayUpdateState(extId: string) { + let state = menuBarTrayUpdateStates.get(extId); + if (!state) { + state = { iconKey: null, iconFileIdentityKey: null, lastResolvedTrayIconOk: false }; + menuBarTrayUpdateStates.set(extId, state); + } + return state; +} + +function getMenuBarTrayIconKey(data: any): string { + return JSON.stringify({ + iconPath: data?.iconPath || '', + iconDataUrl: data?.iconDataUrl || '', + iconEmoji: data?.iconEmoji || '', + iconTemplate: data?.iconTemplate ?? null, + iconBitmapScale: data?.iconBitmapScale ?? null, + fallbackIconDataUrl: data?.fallbackIconDataUrl || '', + }); +} + +function getMenuBarFileIconIdentityKey(iconPath: unknown): string | null { + const pathValue = typeof iconPath === 'string' ? iconPath.trim() : ''; + if (!pathValue) return null; + try { + const fs = require('fs'); + const stat = fs.statSync(pathValue); + if (typeof stat?.isFile === 'function' && !stat.isFile()) { + return `path:${pathValue}|missing`; + } + const size = Number.isFinite(Number(stat?.size)) ? Number(stat.size) : 0; + const mtimeMs = Number.isFinite(Number(stat?.mtimeMs)) ? Number(stat.mtimeMs) : 0; + return `path:${pathValue}|mtimeMs=${mtimeMs}|bytes=${size}`; + } catch { + return `path:${pathValue}|missing`; + } +} + function buildDefaultMacTrayTemplateIcon(): any | null { if (process.platform !== 'darwin') return null; try { @@ -19008,6 +19050,9 @@ if let tiff = image?.tiffRepresentation { const { extId, iconPath, iconDataUrl, iconEmoji, iconTemplate, iconBitmapScale, fallbackIconDataUrl, title, tooltip, items } = data; let tray = menuBarTrays.get(extId); + const updateState = getMenuBarTrayUpdateState(extId); + const nextIconKey = getMenuBarTrayIconKey(data); + const nextIconFileIdentityKey = getMenuBarFileIconIdentityKey(iconPath); const createNativeImageFromMenuIcon = ( payload: { pathValue?: string; dataUrlValue?: string; bitmapScale?: number }, @@ -19061,7 +19106,7 @@ if let tiff = image?.tiffRepresentation { } }; - let lastResolvedTrayIconOk = false; + let lastResolvedTrayIconOk = updateState.lastResolvedTrayIconOk; const hasEmojiIcon = typeof iconEmoji === 'string' && iconEmoji.trim().length > 0; const resolveTrayIcon = () => { const primaryImg = createNativeImageFromMenuIcon( @@ -19103,11 +19148,16 @@ if let tiff = image?.tiffRepresentation { const icon = resolveTrayIcon(); tray = new Tray(icon); menuBarTrays.set(extId, tray); + updateState.iconKey = nextIconKey; + updateState.iconFileIdentityKey = nextIconFileIdentityKey; + updateState.lastResolvedTrayIconOk = lastResolvedTrayIconOk; + } else if (updateState.iconKey !== nextIconKey || updateState.iconFileIdentityKey !== nextIconFileIdentityKey) { + tray.setImage(resolveTrayIcon()); + updateState.iconKey = nextIconKey; + updateState.iconFileIdentityKey = nextIconFileIdentityKey; + updateState.lastResolvedTrayIconOk = lastResolvedTrayIconOk; } - // Always refresh icon on update (first payload can be incomplete). - tray.setImage(resolveTrayIcon()); - // Update title: if there's a text title, show it; if only emoji icon, show that if (title) { tray.setTitle(title); @@ -19136,6 +19186,7 @@ if let tiff = image?.tiffRepresentation { tray.destroy(); } catch {} menuBarTrays.delete(extId); + menuBarTrayUpdateStates.delete(extId); }); // Route native menu clicks back to the renderer diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cd718edc..f28fa2ba 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1078,10 +1078,8 @@ const App: React.FC = () => { const pinToggleForCommand = useCallback( async (command: CommandInfo) => { - console.log('[PIN-TOGGLE] called for command:', command?.id, command?.name); const currentPinned = pinnedCommandsRef.current; const exists = currentPinned.includes(command.id); - console.log('[PIN-TOGGLE] currentPinned:', currentPinned, 'exists:', exists); if (exists) { await updatePinnedCommands( currentPinned.filter((id) => id !== command.id) @@ -1089,7 +1087,6 @@ const App: React.FC = () => { } else { await updatePinnedCommands([command.id, ...currentPinned]); } - console.log('[PIN-TOGGLE] done, new pinned:', pinnedCommandsRef.current); }, [updatePinnedCommands] ); @@ -1642,9 +1639,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/hooks/useAiChat.ts b/src/renderer/src/hooks/useAiChat.ts index fea32c94..5dc86302 100644 --- a/src/renderer/src/hooks/useAiChat.ts +++ b/src/renderer/src/hooks/useAiChat.ts @@ -14,7 +14,7 @@ * - exitAiMode(): leave AI mode (conversation is kept in history) */ -import { useState, useRef, useCallback, useEffect } from 'react'; +import { useState, useRef, useCallback, useEffect, type Dispatch, type SetStateAction } from 'react'; import type { AiChatConversation as AiConversation, AiChatMessage as AiMessage, @@ -36,7 +36,7 @@ export interface UseAiChatReturn { setAiQuery: (value: string) => void; aiInputRef: React.RefObject; aiResponseRef: React.RefObject; - setAiAvailable: (value: boolean) => void; + setAiAvailable: Dispatch>; conversations: AiConversation[]; activeConversationId: string | null; startAiChat: (searchQuery: string) => void; 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..c233a876 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) { @@ -90,7 +102,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { for (const node of allChildren) { if (React.isValidElement(node)) { - const typeRecord = node.type as Record | null; + const typeRecord = node.type as unknown as Record | null; if (typeRecord?.[DETAIL_METADATA_RUNTIME_MARKER] === true) { metadataNodes.push(node); continue; @@ -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); @@ -290,7 +308,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { ); const MetadataComponent = ({ children }: { children?: React.ReactNode }) =>
{children}
; - (MetadataComponent as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; + (MetadataComponent as unknown as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; MetadataComponent.displayName = 'Detail.Metadata'; const Metadata = Object.assign( diff --git a/src/renderer/src/raycast-api/form-runtime.tsx b/src/renderer/src/raycast-api/form-runtime.tsx index 46ddbf0f..35fc5351 100644 --- a/src/renderer/src/raycast-api/form-runtime.tsx +++ b/src/renderer/src/raycast-api/form-runtime.tsx @@ -5,7 +5,7 @@ * all field subcomponents. */ -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { attachFormFields } from './form-runtime-fields'; import { FormContext, @@ -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/grid-runtime.tsx b/src/renderer/src/raycast-api/grid-runtime.tsx index 0b30a849..aeedd62d 100644 --- a/src/renderer/src/raycast-api/grid-runtime.tsx +++ b/src/renderer/src/raycast-api/grid-runtime.tsx @@ -169,10 +169,10 @@ export function createGridRuntime(deps: GridRuntimeDeps) { if (showActions) return; - if (event.key === 'ArrowRight') setSelectedIdx((value) => Math.min(value + 1, filteredItems.length - 1)); - else if (event.key === 'ArrowLeft') setSelectedIdx((value) => Math.max(value - 1, 0)); - else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + cols, filteredItems.length - 1)); - else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - cols, 0)); + if (event.key === 'ArrowRight') setSelectedIdx((value: number) => Math.min(value + 1, filteredItems.length - 1)); + else if (event.key === 'ArrowLeft') setSelectedIdx((value: number) => Math.max(value - 1, 0)); + else if (event.key === 'ArrowDown') setSelectedIdx((value: number) => Math.min(value + cols, filteredItems.length - 1)); + else if (event.key === 'ArrowUp') setSelectedIdx((value: number) => Math.max(value - cols, 0)); else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); else return; @@ -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(() => { 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 45b06294..74850af7 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') { @@ -50,11 +51,13 @@ export function useCachedPromise( const abortControllerRef = useRef(null); const abortableRefRef = useRef | undefined>(undefined); 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(); @@ -191,7 +194,6 @@ export function useCachedPromise( } }, [prepareAbortController, clearAbortController]); - const argsKey = JSON.stringify(args || []); useEffect(() => { setPage(0); setCursor(undefined); @@ -199,7 +201,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 0f480eb2..5a814979 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, @@ -52,7 +34,7 @@ export function usePromise( const abortControllerRef = useRef(null); const abortableRefRef = useRef | undefined>(undefined); - 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; +} 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 44d20a55..e756cd61 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,60 @@ 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; +} + +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 ( + getListTextValue(previous.title) === getListTextValue(next.title) + && getListTextValue(previous.subtitle) === getListTextValue(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 + ); +} + +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; @@ -79,7 +133,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; @@ -123,9 +177,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}; @@ -185,17 +239,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..004c384e 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, @@ -165,6 +177,12 @@ 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 selectedIdxRef = useRef(selectedIdx); + selectedIdxRef.current = selectedIdx; + const primaryActionRef = useRef(primaryAction); + primaryActionRef.current = primaryAction; const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (isMetaK(event)) { @@ -198,7 +216,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 +225,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 +237,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(() => { @@ -255,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); @@ -320,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); @@ -371,7 +348,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { 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 @@ -381,7 +358,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'; @@ -390,7 +367,8 @@ export function createListRuntime(deps: ListRuntimeDeps) { const detailElement = useMemo(() => { if (!rawDetail || !React.isValidElement(rawDetail)) return rawDetail; if (rawDetail.type !== React.Fragment) return rawDetail; - const children = React.Children.toArray(rawDetail.props.children); + const rawDetailProps = rawDetail.props as { children?: React.ReactNode }; + const children = React.Children.toArray(rawDetailProps.children); let mergedMarkdown: string | undefined; let mergedMetadata: React.ReactElement | undefined; let mergedIsLoading: boolean | undefined; @@ -416,40 +394,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 &&