diff --git a/scripts/test-emoji-grid-virtualization.mjs b/scripts/test-emoji-grid-virtualization.mjs
new file mode 100644
index 00000000..a75cc3e5
--- /dev/null
+++ b/scripts/test-emoji-grid-virtualization.mjs
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { build } from 'esbuild';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+async function importListHooks() {
+ const result = await build({
+ entryPoints: [path.join(root, 'src/renderer/src/raycast-api/list-runtime-hooks.ts')],
+ bundle: true,
+ write: false,
+ platform: 'node',
+ format: 'esm',
+ });
+ const code = result.outputFiles[0].text;
+ return import('data:text/javascript;base64,' + Buffer.from(code).toString('base64'));
+}
+
+function makeEmojiItems(totalItems) {
+ return Array.from({ length: totalItems }, (_, index) => ({
+ id: `emoji-${index}`,
+ order: index,
+ sectionTitle: index < totalItems / 2 ? 'Smileys' : 'Symbols',
+ props: {
+ title: `Emoji ${index}`,
+ icon: '😀',
+ },
+ }));
+}
+
+function countEmojiCells(rows) {
+ return rows.reduce((count, row) => count + (row.type === 'emoji-row' ? row.items.length : 0), 0);
+}
+
+const hooks = await importListHooks();
+
+test('Emoji grid virtualization', async (t) => {
+ const totalItems = 4096;
+ const items = makeEmojiItems(totalItems);
+ const groupedItems = hooks.groupListItems(items);
+ const gridRows = hooks.buildEmojiGridVirtualRows(groupedItems);
+ const rowMetrics = hooks.measureVirtualRows(gridRows);
+
+ await t.test('renders only the visible emoji cells plus overscan', () => {
+ assert.equal(hooks.shouldUseEmojiGrid(items, false, () => true), true, 'fixture should use the emoji grid path');
+ assert.equal(countEmojiCells(gridRows), totalItems, 'all items are represented by virtual rows');
+ assert.ok(gridRows.length < totalItems, 'grid rows compact many cells into fixed rows');
+
+ const range = hooks.getVisibleVirtualRange(gridRows, rowMetrics, 0, 600);
+ const visibleCells = countEmojiCells(gridRows.slice(range.visibleStart, range.visibleEnd));
+
+ assert.equal(visibleCells, 112);
+ assert.ok(visibleCells < totalItems * 0.05, 'initial window renders less than 5% of the list');
+ console.log(JSON.stringify({
+ mode: 'after-test',
+ totalItems,
+ virtualRows: gridRows.length,
+ visibleCells,
+ visibleStart: range.visibleStart,
+ visibleEnd: range.visibleEnd,
+ }));
+ });
+
+ await t.test('maps selected indices to their grouped grid rows', () => {
+ const itemToRow = hooks.buildItemToVirtualRowMap(gridRows, totalItems);
+
+ assert.equal(itemToRow[0], 1, 'first section starts after its header');
+ assert.equal(itemToRow[7], 1, 'first row contains eight cells');
+ assert.equal(itemToRow[8], 2, 'ninth cell starts the next grid row');
+ assert.equal(itemToRow[2047], 256, 'last item in the first section stays in its final row');
+ assert.equal(itemToRow[2048], 258, 'second section starts after the next header');
+
+ const selectedRow = itemToRow[3000];
+ const selectedTop = rowMetrics.offsets[selectedRow];
+ const selectedRange = hooks.getVisibleVirtualRange(gridRows, rowMetrics, selectedTop, 600);
+ const visibleCells = countEmojiCells(gridRows.slice(selectedRange.visibleStart, selectedRange.visibleEnd));
+
+ assert.ok(selectedRow >= selectedRange.visibleStart && selectedRow < selectedRange.visibleEnd, 'selected item row is visible');
+ assert.ok(visibleCells < 200, 'selection scroll window stays bounded');
+ });
+});
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.tsx b/src/renderer/src/raycast-api/list-runtime.tsx
index d7b665bd..f1462b25 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,
@@ -188,8 +200,8 @@ export function createListRuntime(deps: ListRuntimeDeps) {
if (event.key === 'ArrowRight' && shouldUseEmojiGridValue) setSelectedIdx((value) => Math.min(value + 1, filteredItems.length - 1));
else if (event.key === 'ArrowLeft' && shouldUseEmojiGridValue) setSelectedIdx((value) => Math.max(value - 1, 0));
- else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + (shouldUseEmojiGridValue ? 8 : 1), filteredItems.length - 1));
- else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - (shouldUseEmojiGridValue ? 8 : 1), 0));
+ else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + (shouldUseEmojiGridValue ? EMOJI_GRID_COLUMNS : 1), filteredItems.length - 1));
+ else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - (shouldUseEmojiGridValue ? EMOJI_GRID_COLUMNS : 1), 0));
else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute();
else return;
@@ -255,39 +267,13 @@ 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(() => buildListVirtualRows(groupedItems), [groupedItems]);
+ const emojiGridRows = useMemo(() => buildEmojiGridVirtualRows(groupedItems), [groupedItems]);
+ const virtualRows = shouldUseEmojiGridValue ? emojiGridRows : listRows;
+ const rowMetrics = useMemo(() => measureVirtualRows(virtualRows), [virtualRows]);
const [scrollTop, setScrollTop] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
@@ -320,45 +306,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 → 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 +335,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
@@ -390,16 +354,22 @@ 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 rawDetailElement = rawDetail as React.ReactElement<{ children?: React.ReactNode }>;
+ const children = React.Children.toArray(rawDetailElement.props.children);
let mergedMarkdown: string | undefined;
let mergedMetadata: React.ReactElement | undefined;
let mergedIsLoading: boolean | undefined;
for (const child of children) {
if (!React.isValidElement(child)) continue;
if ((child.type as any) !== ListItemDetail) continue;
- if (child.props.markdown !== undefined) mergedMarkdown = child.props.markdown;
- if (child.props.metadata !== undefined) mergedMetadata = child.props.metadata;
- if (child.props.isLoading !== undefined) mergedIsLoading = child.props.isLoading;
+ const detailProps = child.props as {
+ markdown?: string;
+ metadata?: React.ReactElement;
+ isLoading?: boolean;
+ };
+ if (detailProps.markdown !== undefined) mergedMarkdown = detailProps.markdown;
+ if (detailProps.metadata !== undefined) mergedMetadata = detailProps.metadata;
+ if (detailProps.isLoading !== undefined) mergedIsLoading = detailProps.isLoading;
}
if (mergedMarkdown === undefined && mergedMetadata === undefined) return rawDetail;
return React.createElement(ListItemDetail, {
@@ -416,40 +386,72 @@ export function createListRuntime(deps: ListRuntimeDeps) {
) : filteredItems.length === 0 ? (
emptyViewProps ?
{t('common.noResults')}