From 290a0f539f63f4af71bd21aecd3baf8e5b186111 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:29:42 +0200 Subject: [PATCH] perf(raycast): cache registry dirty signatures --- scripts/test-registry-dirty-checks.mjs | 339 ++++++++++++++++++ .../src/raycast-api/grid-runtime-hooks.ts | 132 ++++++- .../src/raycast-api/list-runtime-hooks.ts | 81 +++-- 3 files changed, 509 insertions(+), 43 deletions(-) create mode 100644 scripts/test-registry-dirty-checks.mjs diff --git a/scripts/test-registry-dirty-checks.mjs b/scripts/test-registry-dirty-checks.mjs new file mode 100644 index 00000000..60795512 --- /dev/null +++ b/scripts/test-registry-dirty-checks.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ITEM_COUNT = Number.parseInt(process.env.SUPERCMD_REGISTRY_DIRTY_ITEMS || '5000', 10); +const REACT_ELEMENT_TYPE = Symbol.for('react.element'); + +async function importHookModule(absPath) { + const result = await build({ + entryPoints: [absPath], + 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')); +} + +const listHooks = await importHookModule(path.join(root, 'src/renderer/src/raycast-api/list-runtime-hooks.ts')); +const gridHooks = await importHookModule(path.join(root, 'src/renderer/src/raycast-api/grid-runtime-hooks.ts')); + +function namedType(displayName) { + const component = function RuntimeElement() {}; + component.displayName = displayName; + return component; +} + +function makeElement(displayName, props = {}) { + return { + $$typeof: REACT_ELEMENT_TYPE, + type: namedType(displayName), + key: null, + props, + }; +} + +function makeListItem(index, overrides = {}) { + const props = { + id: `list-visible-${index}`, + title: { value: `Emoji ${index}`, tooltip: `Emoji title ${index}` }, + subtitle: { value: `Subtitle ${index}`, tooltip: `Subtitle tip ${index}` }, + icon: { + source: index % 2 === 0 ? '\u{1F600}' : { light: `icon-${index}.png`, dark: `icon-${index}-dark.png` }, + tintColor: index % 3 === 0 ? 'blue' : undefined, + }, + accessories: [ + { + text: { value: `Accessory ${index}`, color: 'red' }, + icon: { source: 'Icon.Star' }, + tooltip: `Accessory tip ${index}`, + }, + { + tag: { value: `Tag ${index % 17}`, color: 'green' }, + date: new Date(1_700_000_000_000 + index * 1000).toISOString(), + }, + ], + keywords: [`emoji-${index}`, `group-${index % 25}`, 'common'], + detail: makeElement('ListItemDetail', { + markdown: `# Emoji ${index}`, + metadata: makeElement('Metadata', { children: [`meta-${index}`] }), + isLoading: false, + }), + quickLook: { name: `Emoji ${index}`, path: `/tmp/emoji-${index}.png` }, + actions: makeElement('ActionPanel', { + children: [makeElement('Action.CopyToClipboard', { title: 'Copy', content: `emoji-${index}` })], + }), + ...(overrides.props || {}), + }; + + return { + id: overrides.id || `list-${index}`, + order: overrides.order ?? index, + sectionTitle: overrides.sectionTitle ?? (index < ITEM_COUNT / 2 ? 'Smileys' : 'Symbols'), + props, + }; +} + +function makeGridItem(index, overrides = {}) { + const props = { + id: `grid-visible-${index}`, + title: `Grid ${index}`, + subtitle: `Grid subtitle ${index}`, + content: index % 3 === 0 + ? { source: `asset-${index}.png`, tintColor: 'blue', fallback: 'Icon.Image' } + : { value: `Icon.Circle.${index}`, color: index % 2 === 0 ? 'purple' : 'orange' }, + accessory: { + icon: { value: 'Icon.Star', color: 'yellow' }, + tooltip: `Pinned ${index}`, + }, + keywords: [`grid-${index}`, `bucket-${index % 20}`], + quickLook: { name: `Grid ${index}`, path: `/tmp/grid-${index}.png` }, + actions: makeElement('ActionPanel', { + children: [makeElement('Action.Open', { title: 'Open', target: `/tmp/grid-${index}.png` })], + }), + ...(overrides.props || {}), + }; + + return { + id: overrides.id || `grid-${index}`, + order: overrides.order ?? index, + sectionTitle: overrides.sectionTitle ?? (index < ITEM_COUNT / 2 ? 'Section A' : 'Section B'), + props, + }; +} + +function getReactTypeName(type) { + return String(type?.displayName || type?.name || type || ''); +} + +function isReactElement(value) { + return Boolean(value && value.$$typeof === REACT_ELEMENT_TYPE); +} + +function legacyBuildSnapshotSignature(value, seen = new WeakSet()) { + 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) => legacyBuildSnapshotSignature(item, seen)).join(',')}]`; + } + + if (isReactElement(value)) { + return `element:${getReactTypeName(value.type)}:${legacyBuildSnapshotSignature(value.props, seen)}`; + } + + if (typeof value === 'object') { + if (seen.has(value)) return '[circular]'; + seen.add(value); + + const entries = Object.entries(value) + .filter(([key]) => key !== '_owner' && key !== '_store' && key !== 'ref' && key !== 'key') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${key}:${legacyBuildSnapshotSignature(entryValue, seen)}`); + + return `{${entries.join(',')}}`; + } + + return String(value); +} + +function legacyListSnapshot(items) { + let fullSnapshotItemWalks = 0; + const snapshot = items.map((item) => { + fullSnapshotItemWalks += 1; + const actionType = item.props.actions?.type; + const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; + return legacyBuildSnapshotSignature({ + actionName, + accessories: item.props.accessories, + detail: item.props.detail, + icon: item.props.icon, + id: item.id, + keywords: item.props.keywords, + order: item.order, + sectionTitle: item.sectionTitle || '', + subtitle: item.props.subtitle, + title: item.props.title, + }); + }).join('|'); + return { snapshot, fullSnapshotItemWalks }; +} + +function legacyGridSnapshot(items) { + let fullSnapshotItemWalks = 0; + const snapshot = items.map((entry) => { + fullSnapshotItemWalks += 1; + const actionType = entry.props.actions?.type; + const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; + return `${entry.id}:${entry.props.title || ''}:${entry.sectionTitle || ''}:${actionName}`; + }).join('|'); + return { snapshot, fullSnapshotItemWalks }; +} + +function measureLegacySnapshot(baseItems, nextItems, snapshotFn) { + const beforeSnapshot = snapshotFn(baseItems).snapshot; + const start = performance.now(); + const after = snapshotFn(nextItems); + const durationMs = performance.now() - start; + return { + fullSnapshotItemWalks: after.fullSnapshotItemWalks, + itemSignatureBuilds: 0, + versionUpdates: after.snapshot === beforeSnapshot ? 0 : 1, + durationMs: Number(durationMs.toFixed(3)), + }; +} + +function measurePerItemSignatures(baseItems, nextItems, signatureFn) { + const signatureById = new Map(baseItems.map((item) => [item.id, signatureFn(item)])); + let itemSignatureBuilds = 0; + let visibleChanges = 0; + const start = performance.now(); + for (const item of nextItems) { + itemSignatureBuilds += 1; + const nextSignature = signatureFn(item); + if (signatureById.get(item.id) !== nextSignature) { + visibleChanges += 1; + signatureById.set(item.id, nextSignature); + } + } + const durationMs = performance.now() - start; + return { + fullSnapshotItemWalks: 0, + itemSignatureBuilds, + versionUpdates: visibleChanges > 0 ? 1 : 0, + visibleChanges, + durationMs: Number(durationMs.toFixed(3)), + }; +} + +function collectMetrics() { + const listBaseItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeListItem(index)); + const listEquivalentItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeListItem(index)); + const listSingleEquivalentItems = listBaseItems.slice(); + listSingleEquivalentItems[ITEM_COUNT - 1] = makeListItem(ITEM_COUNT - 1); + const listOneTitleChange = Array.from({ length: ITEM_COUNT }, (_, index) => ( + index === ITEM_COUNT - 1 + ? makeListItem(index, { props: { title: { value: `Changed ${index}`, tooltip: `Emoji title ${index}` } } }) + : makeListItem(index) + )); + + const gridBaseItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeGridItem(index)); + const gridEquivalentItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeGridItem(index)); + const gridSingleEquivalentItems = gridBaseItems.slice(); + gridSingleEquivalentItems[ITEM_COUNT - 1] = makeGridItem(ITEM_COUNT - 1); + const gridOneContentChange = Array.from({ length: ITEM_COUNT }, (_, index) => ( + index === ITEM_COUNT - 1 + ? makeGridItem(index, { props: { content: { source: `changed-${index}.png`, tintColor: 'red' } } }) + : makeGridItem(index) + )); + + return { + itemCount: ITEM_COUNT, + list: { + singleRecreatedEquivalent: { + before: measureLegacySnapshot(listBaseItems, listSingleEquivalentItems, legacyListSnapshot), + after: measurePerItemSignatures(listBaseItems, [makeListItem(ITEM_COUNT - 1)], listHooks.buildListItemVisibleSignature), + }, + recreatedEquivalent: { + before: measureLegacySnapshot(listBaseItems, listEquivalentItems, legacyListSnapshot), + after: measurePerItemSignatures(listBaseItems, listEquivalentItems, listHooks.buildListItemVisibleSignature), + }, + oneVisibleChange: { + before: measureLegacySnapshot(listBaseItems, listOneTitleChange, legacyListSnapshot), + after: measurePerItemSignatures(listBaseItems, listOneTitleChange, listHooks.buildListItemVisibleSignature), + }, + }, + grid: { + singleRecreatedEquivalent: { + before: measureLegacySnapshot(gridBaseItems, gridSingleEquivalentItems, legacyGridSnapshot), + after: measurePerItemSignatures(gridBaseItems, [makeGridItem(ITEM_COUNT - 1)], gridHooks.buildGridItemVisibleSignature), + }, + recreatedEquivalent: { + before: measureLegacySnapshot(gridBaseItems, gridEquivalentItems, legacyGridSnapshot), + after: measurePerItemSignatures(gridBaseItems, gridEquivalentItems, gridHooks.buildGridItemVisibleSignature), + }, + oneVisibleChange: { + before: measureLegacySnapshot(gridBaseItems, gridOneContentChange, legacyGridSnapshot), + after: measurePerItemSignatures(gridBaseItems, gridOneContentChange, gridHooks.buildGridItemVisibleSignature), + }, + }, + }; +} + +function expectSignatureChange(signatureFn, base, next, label) { + assert.notEqual(signatureFn(base), signatureFn(next), `${label} should change the visible signature`); +} + +if (process.argv.includes('--report')) { + console.log(JSON.stringify(collectMetrics(), null, 2)); +} else { + test('List item visible signatures ignore recreated equivalent props and catch visible fields', () => { + const base = makeListItem(7); + assert.equal(listHooks.buildListItemVisibleSignature(base), listHooks.buildListItemVisibleSignature(makeListItem(7))); + + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { title: 'Changed' } }), 'title'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { subtitle: 'Changed' } }), 'subtitle'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { icon: '\u{1F680}' } }), 'icon'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { accessories: [{ text: 'Changed' }] } }), 'accessories'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { keywords: ['changed'] } }), 'keywords'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { detail: makeElement('ListItemDetail', { markdown: 'Changed' }) } }), 'detail'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { quickLook: { path: '/tmp/changed.png' } } }), 'quickLook'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { actions: makeElement('DifferentActionPanel') } }), 'action type'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { sectionTitle: 'Changed section' }), 'section'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { order: 999 }), 'order'); + }); + + test('Grid item visible signatures ignore recreated equivalent props and catch visible fields', () => { + const base = makeGridItem(9); + assert.equal(gridHooks.buildGridItemVisibleSignature(base), gridHooks.buildGridItemVisibleSignature(makeGridItem(9))); + + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { title: 'Changed' } }), 'title'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { subtitle: 'Changed' } }), 'subtitle'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { content: { source: 'changed.png' } } }), 'content'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { accessory: { tooltip: 'Changed' } } }), 'accessory'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { keywords: ['changed'] } }), 'keywords'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { quickLook: { path: '/tmp/changed.png' } } }), 'quickLook'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { actions: makeElement('DifferentActionPanel') } }), 'action type'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { sectionTitle: 'Changed section' }), 'section'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { order: 999 }), 'order'); + }); + + test('Registry dirty-check metrics avoid full-registry walks for recreated equivalent props', () => { + const metrics = collectMetrics(); + + assert.equal(metrics.list.singleRecreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.list.singleRecreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.list.singleRecreatedEquivalent.after.itemSignatureBuilds, 1); + assert.equal(metrics.list.singleRecreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.list.recreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.list.recreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.list.recreatedEquivalent.before.versionUpdates, 0); + assert.equal(metrics.list.recreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.list.oneVisibleChange.before.versionUpdates, 1); + assert.equal(metrics.list.oneVisibleChange.after.versionUpdates, 1); + + assert.equal(metrics.grid.singleRecreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.grid.singleRecreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.grid.singleRecreatedEquivalent.after.itemSignatureBuilds, 1); + assert.equal(metrics.grid.singleRecreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.grid.recreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.grid.recreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.grid.recreatedEquivalent.before.versionUpdates, 0); + assert.equal(metrics.grid.recreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.grid.oneVisibleChange.after.versionUpdates, 1); + + console.log(JSON.stringify({ mode: 'registry-dirty-checks', metrics }, null, 2)); + }); +} diff --git a/src/renderer/src/raycast-api/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index aada480b..a2802b83 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -4,31 +4,124 @@ * Extracted registry/grouping logic for the grid runtime container. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { GridItemRegistration, GridRegistryAPI } from './grid-runtime-items'; +export interface GridItemGroup { + title?: string; + items: { item: GridItemRegistration; globalIdx: number }[]; +} + +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); +} + +function buildActionTypeSignature(actions: unknown): string { + if (!React.isValidElement(actions)) return buildValueSignature(actions); + return getReactTypeName((actions as React.ReactElement).type); +} + +function buildImageLikeSignature(value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value) || React.isValidElement(value)) { + return buildValueSignature(value); + } + const image = value as Record; + return [ + buildValueSignature(image.source), + buildValueSignature(image.value), + buildValueSignature(image.fileIcon), + buildValueSignature(image.light), + buildValueSignature(image.dark), + buildValueSignature(image.color), + buildValueSignature(image.tintColor), + buildValueSignature(image.mask), + buildValueSignature(image.fallback), + ].join('\u001e'); +} + +function buildGridContentSignature(content: unknown): string { + return buildImageLikeSignature(content); +} + +function buildGridAccessorySignature(accessory: unknown): string { + if (!accessory || typeof accessory !== 'object' || Array.isArray(accessory) || React.isValidElement(accessory)) { + return buildValueSignature(accessory); + } + const value = accessory as Record; + return [ + buildImageLikeSignature(value.icon), + buildValueSignature(value.tooltip), + buildValueSignature(value.text), + buildValueSignature(value.tag), + ].join('\u001e'); +} + +function buildQuickLookSignature(quickLook: GridItemRegistration['props']['quickLook']): string { + if (!quickLook) return ''; + return `${quickLook.name || ''}\u001e${quickLook.path || ''}`; +} + +export function buildGridItemVisibleSignature(entry: GridItemRegistration): string { + const props = entry.props; + return [ + entry.id, + String(entry.order), + entry.sectionTitle || '', + buildValueSignature(props.id), + buildValueSignature(props.title), + buildValueSignature(props.subtitle), + buildGridContentSignature(props.content), + buildGridAccessorySignature(props.accessory), + props.keywords?.map((keyword) => JSON.stringify(keyword)).join('\u001d') || '', + buildQuickLookSignature(props.quickLook), + buildActionTypeSignature(props.actions), + ].join('\u001f'); +} + export function useGridRegistry() { const registryRef = useRef(new Map()); + const visibleSignatureRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); const pendingRef = useRef(false); - const lastSnapshotRef = useRef(''); const scheduleRegistryUpdate = useCallback(() => { if (pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { pendingRef.current = false; - const snapshot = Array.from(registryRef.current.values()) - .map((entry) => { - const actionType = entry.props.actions?.type as any; - const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; - return `${entry.id}:${entry.props.title || ''}:${entry.sectionTitle || ''}:${actionName}`; - }) - .join('|'); - if (snapshot !== lastSnapshotRef.current) { - lastSnapshotRef.current = snapshot; - setRegistryVersion((value) => value + 1); - } + setRegistryVersion((value) => value + 1); }); }, []); @@ -37,17 +130,28 @@ export function useGridRegistry() { set(id, data) { const existing = registryRef.current.get(id); if (existing) { + const propsChanged = existing.props !== data.props; + const sectionChanged = existing.sectionTitle !== data.sectionTitle; + const orderChanged = existing.order !== data.order; existing.props = data.props; existing.sectionTitle = data.sectionTitle; existing.order = data.order; + if (!propsChanged && !sectionChanged && !orderChanged) return; + + const nextSignature = buildGridItemVisibleSignature(existing); + if (visibleSignatureRef.current.get(id) === nextSignature) return; + 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, buildGridItemVisibleSignature(entry)); } scheduleRegistryUpdate(); }, delete(id) { if (!registryRef.current.has(id)) return; registryRef.current.delete(id); + visibleSignatureRef.current.delete(id); scheduleRegistryUpdate(); }, }), diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index bae2b9cd..94bb33bc 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -11,7 +11,7 @@ function getReactTypeName(type: any): string { return String(type?.displayName || type?.name || type || ''); } -function buildSnapshotSignature(value: unknown, seen = new WeakSet()): string { +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); @@ -20,21 +20,22 @@ function buildSnapshotSignature(value: unknown, seen = new WeakSet()): s if (typeof value === 'symbol') return value.toString(); if (Array.isArray(value)) { - return `[${value.map((item) => buildSnapshotSignature(item, seen)).join(',')}]`; + return `[${value.map((item) => buildValueSignature(item, seen)).join(',')}]`; } if (React.isValidElement(value)) { - return `element:${getReactTypeName(value.type)}:${buildSnapshotSignature((value as any).props, seen)}`; + 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}:${buildSnapshotSignature(entryValue, seen)}`); + .map(([key, entryValue]) => `${key}:${buildValueSignature(entryValue, seen)}`); return `{${entries.join(',')}}`; } @@ -42,37 +43,51 @@ function buildSnapshotSignature(value: unknown, seen = new WeakSet()): s return String(value); } +function buildActionTypeSignature(actions: unknown): string { + if (!React.isValidElement(actions)) return buildValueSignature(actions); + return getReactTypeName((actions as React.ReactElement).type); +} + +function buildListAccessorySignature(accessory: NonNullable[number]): string { + return [ + buildValueSignature(accessory?.text), + buildValueSignature(accessory?.icon), + buildValueSignature(accessory?.tag), + buildValueSignature(accessory?.date), + buildValueSignature(accessory?.tooltip), + ].join('\u001e'); +} + +export function buildListItemVisibleSignature(item: ItemRegistration): string { + const props = item.props; + return [ + item.id, + String(item.order), + item.sectionTitle || '', + buildValueSignature(props.id), + buildValueSignature(props.title), + buildValueSignature(props.subtitle), + buildValueSignature(props.icon), + props.accessories?.map(buildListAccessorySignature).join('\u001d') || '', + props.keywords?.map((keyword) => JSON.stringify(keyword)).join('\u001d') || '', + buildValueSignature(props.detail), + buildValueSignature(props.quickLook), + buildActionTypeSignature(props.actions), + ].join('\u001f'); +} + export function useListRegistry() { const registryRef = useRef(new Map()); + const visibleSignatureRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); const pendingRef = useRef(false); - const lastSnapshotRef = useRef(''); const scheduleRegistryUpdate = useCallback(() => { if (pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { pendingRef.current = false; - const snapshot = Array.from(registryRef.current.values()).map((item) => { - const actionType = item.props.actions?.type as any; - const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; - return buildSnapshotSignature({ - actionName, - accessories: item.props.accessories, - detail: item.props.detail, - icon: item.props.icon, - id: item.id, - keywords: item.props.keywords, - order: item.order, - sectionTitle: item.sectionTitle || '', - subtitle: item.props.subtitle, - title: item.props.title, - }); - }).join('|'); - if (snapshot !== lastSnapshotRef.current) { - lastSnapshotRef.current = snapshot; - setRegistryVersion((value) => value + 1); - } + setRegistryVersion((value) => value + 1); }); }, []); @@ -84,22 +99,30 @@ export function useListRegistry() { // re-runs every render even though no content actually // changed. The renderOrder counter shifts uniformly so relative // order is preserved; just write it through and skip the costly - // snapshot recompute. Only schedule an update when props identity - // or section actually changed. + // visible-signature work. Only publish when the id's visible + // signature, section, or order actually changed. const propsChanged = existing.props !== data.props; const sectionChanged = existing.sectionTitle !== data.sectionTitle; + const orderChanged = existing.order !== data.order; existing.props = data.props; existing.sectionTitle = data.sectionTitle; existing.order = data.order; - if (!propsChanged && !sectionChanged) return; + if (!propsChanged && !sectionChanged && !orderChanged) return; + + const nextSignature = buildListItemVisibleSignature(existing); + if (visibleSignatureRef.current.get(id) === nextSignature) return; + visibleSignatureRef.current.set(id, nextSignature); } else { - registryRef.current.set(id, { id, ...data }); + const item = { id, ...data }; + registryRef.current.set(id, item); + visibleSignatureRef.current.set(id, buildListItemVisibleSignature(item)); } scheduleRegistryUpdate(); }, delete(id) { if (!registryRef.current.has(id)) return; registryRef.current.delete(id); + visibleSignatureRef.current.delete(id); scheduleRegistryUpdate(); }, }), [scheduleRegistryUpdate]);