modern-cmdk 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,27 +16,68 @@ function ensureWellFormed(str) {
16
16
  return str.replace(LONE_SURROGATE_RE, REPLACEMENT_CHAR);
17
17
  }
18
18
  // src/core/frecency/idb-storage.ts
19
- /** Serialize a FrecencyRecord to a JSON-safe format */
20
- function serializeRecord(record) {
21
- return {
22
- frequency: record.frequency,
23
- lastUsedMs: record.lastUsed
24
- };
25
- }
26
- /** Deserialize a FrecencyRecord from its JSON-safe form */
27
- function deserializeRecord(serialized) {
28
- return {
29
- frequency: serialized.frequency,
30
- lastUsed: serialized.lastUsedMs
31
- };
32
- }
33
- /** Serialize FrecencyData for IndexedDB storage — uses Iterator Helpers */
19
+ Symbol.dispose ??= Symbol.for("Symbol.dispose");
20
+ Symbol.asyncDispose ??= Symbol.for("Symbol.asyncDispose");
21
+ /** Serialize FrecencyData as v1 for IndexedDB storage — uses Iterator Helpers */
34
22
  function serializeData(data) {
35
- return { records: data.records.entries().map(([id, record]) => [id, serializeRecord(record)]).toArray() };
23
+ return { records: data.records.entries().map(([id, { frequency, lastUsed }]) => [id, {
24
+ frequency,
25
+ lastUsedMs: lastUsed
26
+ }]).toArray() };
27
+ }
28
+ const isNonNegative = (n) => Number.isFinite(n) && n >= 0;
29
+ /**
30
+ * Validate a persisted payload (untrusted: it may be corrupt, foreign or from a
31
+ * newer schema). Keeps the rows that are `[string, { frequency, lastUsedMs }]`
32
+ * with finite numbers >= 0 and counts the rest in a single warning; a repeated id
33
+ * counts toward that number and the last row with it wins. A payload
34
+ * that is not a v1 or v2 record map counts as one dropped row and yields empty
35
+ * data; `undefined` (nothing stored) is not a warning.
36
+ */
37
+ function deserializeData(raw) {
38
+ const payload = raw;
39
+ const rows = raw === void 0 ? [] : Array.isArray(payload?.records) && [
40
+ void 0,
41
+ 1,
42
+ 2
43
+ ].includes(payload.version) ? payload.records : [null];
44
+ const records = new Map(rows.values().filter((row) => Array.isArray(row) && typeof row[0] === "string" && isNonNegative(row[1]?.frequency) && isNonNegative(row[1].lastUsedMs)).map(([id, { frequency, lastUsedMs }]) => [id, {
45
+ frequency,
46
+ lastUsed: lastUsedMs
47
+ }]));
48
+ if (records.size < rows.length) console.warn("[modern-cmdk] dropped invalid frecency rows:", rows.length - records.size);
49
+ return { records };
36
50
  }
37
- /** Deserialize FrecencyData from IndexedDB storage — Iterator passed directly to Map (no .toArray()) */
38
- function deserializeData(serialized) {
39
- return { records: new Map(serialized.records.values().map(([id, record]) => [id, deserializeRecord(record)])) };
51
+ /**
52
+ * Open `storeName` in `dbName` per operation, creating the store when missing.
53
+ * idb-keyval 6.3.0's createStore does `request.onupgradeneeded = () =>
54
+ * request.result.createObjectStore(storeName)`, which runs only when the database
55
+ * is new, so a second storeName in an existing database fails with NotFoundError
56
+ * (SEC-35). Here a database without the store is reopened at `version + 1`, whose
57
+ * upgrade creates it. The connection closes right after the transaction starts (the
58
+ * transaction still finishes), so it delays another instance's upgrade only until that
59
+ * transaction is done. An upgrade blocked by a connection that is never closed (for example an
60
+ * idb-keyval createStore connection held by app code, which has no versionchange handler)
61
+ * waits for it, so that load or save does not settle until the connection closes.
62
+ */
63
+ function openStore(dbName, storeName) {
64
+ const open = async (version) => {
65
+ const request = indexedDB.open(dbName, version);
66
+ request.onupgradeneeded = () => request.result.createObjectStore(storeName);
67
+ const db = await new Promise((resolve, reject) => {
68
+ request.onsuccess = () => resolve(request.result);
69
+ request.onerror = () => reject(request.error);
70
+ });
71
+ if (db.objectStoreNames.contains(storeName)) return db;
72
+ db.close();
73
+ return open(db.version + 1);
74
+ };
75
+ return async (mode, callback) => {
76
+ const db = await open();
77
+ const transaction = db.transaction(storeName, mode);
78
+ db.close();
79
+ return callback(transaction.objectStore(storeName));
80
+ };
40
81
  }
41
82
  /** Build the namespaced IDB key — ensures valid Unicode in key */
42
83
  function storageKey(namespace) {
@@ -44,7 +85,7 @@ function storageKey(namespace) {
44
85
  }
45
86
  let idbModule;
46
87
  /**
47
- * IndexedDB-backed frecency storage using idb-keyval.
88
+ * IndexedDB-backed frecency storage using idb-keyval's get, set and del.
48
89
  *
49
90
  * Persists frecency data across browser sessions. Implements both
50
91
  * `Disposable` and `AsyncDisposable` for use with `using` and `await using`.
@@ -78,35 +119,30 @@ var IdbFrecencyStorage = class {
78
119
  idbModule ??= import("idb-keyval");
79
120
  return idbModule;
80
121
  }
81
- /** Lazy-create the IDB store on first access */
82
- async #getStore() {
83
- if (!this.#store) {
84
- const { createStore } = await this.#idb();
85
- this.#store = createStore(this.#dbName, this.#storeName);
86
- }
122
+ /** Lazy-create the store opener on first access */
123
+ #getStore() {
124
+ this.#store ??= openStore(this.#dbName, this.#storeName);
87
125
  return this.#store;
88
126
  }
89
127
  /** Load frecency data from IndexedDB for the given namespace */
90
128
  async load(namespace) {
91
129
  if (this.#disposed) return { records: /* @__PURE__ */ new Map() };
92
130
  const { get } = await this.#idb();
93
- const store = await this.#getStore();
94
- const serialized = await get(storageKey(namespace), store);
95
- if (!serialized) return { records: /* @__PURE__ */ new Map() };
96
- return deserializeData(serialized);
131
+ const store = this.#getStore();
132
+ return deserializeData(await get(storageKey(namespace), store));
97
133
  }
98
134
  /** Save frecency data to IndexedDB for the given namespace */
99
135
  async save(namespace, data) {
100
136
  if (this.#disposed) return;
101
137
  const { set } = await this.#idb();
102
- const store = await this.#getStore();
138
+ const store = this.#getStore();
103
139
  await set(storageKey(namespace), serializeData(data), store);
104
140
  }
105
141
  /** Delete frecency data for a namespace from IndexedDB */
106
142
  async delete(namespace) {
107
143
  if (this.#disposed) return;
108
144
  const { del } = await this.#idb();
109
- const store = await this.#getStore();
145
+ const store = this.#getStore();
110
146
  await del(storageKey(namespace), store);
111
147
  }
112
148
  /** Synchronous dispose — marks as disposed (best-effort cleanup) */
@@ -119,6 +155,12 @@ var IdbFrecencyStorage = class {
119
155
  }
120
156
  };
121
157
  // src/core/types.ts
158
+ /**
159
+ * The `groupedIds` key of items that have no group. The value is an observable key of
160
+ * `CommandState.groupedIds` and stays `'__ungrouped'`. A group registered under this id shares the
161
+ * bucket: its items are listed with the ungrouped ones and the bucket takes its priority.
162
+ */
163
+ const UNGROUPED_GROUP_ID = "__ungrouped";
122
164
  /** Create a branded {@link ItemId} from a plain string. Ensures well-formed Unicode. */
123
165
  function itemId(id) {
124
166
  return ensureWellFormed(id);
@@ -135,7 +177,10 @@ const DEFAULT_FRECENCY_DECAY = {
135
177
  monthWeight: 1,
136
178
  olderWeight: .5
137
179
  };
138
- /** Default machine options */
180
+ /**
181
+ * Default machine options. The machine reads `loop` and `open` from here when its options leave them out
182
+ * (the other options are opt-ins whose absence is their default); `virtualizeThreshold` is not read yet.
183
+ */
139
184
  const DEFAULT_MACHINE_OPTIONS = {
140
185
  loop: true,
141
186
  virtualizeThreshold: 100,
@@ -152,7 +197,7 @@ function createInitialState(options) {
152
197
  loading: false,
153
198
  page: "root",
154
199
  pageStack: [],
155
- open: options?.open ?? false,
200
+ open: options?.open ?? DEFAULT_MACHINE_OPTIONS.open ?? false,
156
201
  lastUpdated: Date.now()
157
202
  };
158
203
  }
@@ -181,13 +226,6 @@ var MemoryFrecencyStorage = class {
181
226
  // src/core/frecency/index.ts
182
227
  /** Shared empty bonus map — avoids allocation when no frecency records exist */
183
228
  const EMPTY_BONUS_MAP = /* @__PURE__ */ new Map();
184
- /** Bucket boundaries as hour constants — no per-call unit conversion */
185
- const BUCKET_HOURS = {
186
- hour: 1,
187
- day: 24,
188
- week: 168,
189
- month: 720
190
- };
191
229
  /**
192
230
  * Computes a frecency bonus for an item based on usage frequency and recency.
193
231
  * Uses hour/day/week/month bucket boundaries with
@@ -196,32 +234,74 @@ const BUCKET_HOURS = {
196
234
  function computeFrecencyBonus(history, now = Date.now(), config = DEFAULT_FRECENCY_DECAY) {
197
235
  const hours = (now - history.lastUsed) / 36e5;
198
236
  const { hourWeight = 4, dayWeight = 2, weekWeight = 1.5, monthWeight = 1, olderWeight = .5 } = config;
199
- const recencyWeight = hours < BUCKET_HOURS.hour ? hourWeight : hours < BUCKET_HOURS.day ? dayWeight : hours < BUCKET_HOURS.week ? weekWeight : hours < BUCKET_HOURS.month ? monthWeight : olderWeight;
237
+ const recencyWeight = hours < 1 ? hourWeight : hours < 24 ? dayWeight : hours < 168 ? weekWeight : hours < 720 ? monthWeight : olderWeight;
200
238
  return history.frequency * recencyWeight;
201
239
  }
240
+ /** Trailing debounce in milliseconds between the last `recordSelection` and the save it schedules */
241
+ const FLUSH_DELAY_MS = 1e3;
202
242
  /**
203
243
  * Stateful frecency engine that tracks item selection history and provides
204
- * ranking boosts for search results. Supports pluggable storage backends
205
- * and automatic flush on dispose. Uses Date.now() for timestamps.
244
+ * ranking boosts for search results. Supports pluggable storage backends.
245
+ * Dirty data is saved 1000 ms after the last `recordSelection`, when the page
246
+ * becomes hidden or is hidden for good (`visibilitychange`, `pagehide`), on
247
+ * `flush()` and on dispose; a save waits for a `load()` in flight, so it writes the merged
248
+ * history. Dispose disposes the storage only when the engine created it (no `storage`
249
+ * option): a storage the caller passes is the caller's to dispose. Uses Date.now() for
250
+ * timestamps.
206
251
  */
207
252
  var FrecencyEngine = class {
208
253
  #storage;
254
+ /** True when the engine created its storage, so dispose disposes it; a caller's storage is left alone */
255
+ #ownsStorage;
209
256
  #namespace;
210
257
  #data;
211
258
  #config;
212
259
  #dirty = false;
260
+ #timer;
261
+ /** The last started save, never rejecting: saves queue behind it and flush awaits it */
262
+ #pending = Promise.resolve();
263
+ /** The last started load, never rejecting: a save starts only after it settles */
264
+ #loading = Promise.resolve();
265
+ /** Set by a rejected load: the engine writes nothing afterwards */
266
+ #loadFailed = false;
267
+ #abort = new AbortController();
213
268
  constructor(options) {
269
+ this.#ownsStorage = options.storage === void 0;
214
270
  this.#storage = options.storage ?? new MemoryFrecencyStorage();
215
271
  this.#namespace = options.namespace ?? "default";
216
272
  this.#config = options.decayConfig ?? DEFAULT_FRECENCY_DECAY;
217
273
  this.#data = { records: /* @__PURE__ */ new Map() };
274
+ const { signal } = this.#abort;
275
+ globalThis.addEventListener?.("pagehide", this.#save, { signal });
276
+ globalThis.document?.addEventListener("visibilitychange", () => document.hidden && this.#save(), { signal });
218
277
  }
219
- /** Load frecency data from storage */
220
- async load() {
221
- const data = await this.#storage.load(this.#namespace);
222
- this.#data = data;
278
+ /**
279
+ * Merge the data in storage into the engine: frequencies add, `lastUsed` takes the later
280
+ * time, and selections recorded before the load resolves are kept. Call it once per
281
+ * engine; a second load would add the persisted frequencies again. A save started while
282
+ * the load is in flight waits for it, so the history it writes is the merged one, and a load
283
+ * that never settles holds every save, and `await using`, forever. A failed load rejects here
284
+ * and turns persistence off for this engine: saving would replace a history it could not read.
285
+ */
286
+ load() {
287
+ const merged = this.#merge();
288
+ this.#loading = merged.catch(() => {
289
+ this.#loadFailed = true;
290
+ });
291
+ return merged;
292
+ }
293
+ async #merge() {
294
+ const { records } = await this.#storage.load(this.#namespace);
295
+ const mine = this.#data.records;
296
+ for (const [id, loaded] of records) {
297
+ const m = mine.get(id);
298
+ mine.set(id, m ? {
299
+ frequency: m.frequency + loaded.frequency,
300
+ lastUsed: Math.max(m.lastUsed, loaded.lastUsed)
301
+ } : loaded);
302
+ }
223
303
  }
224
- /** Record a selection event for an item */
304
+ /** Record a selection event for an item and schedule a save 1000 ms after the last one */
225
305
  recordSelection(itemId) {
226
306
  const now = Date.now();
227
307
  const updated = {
@@ -230,6 +310,8 @@ var FrecencyEngine = class {
230
310
  };
231
311
  this.#data.records.set(itemId, updated);
232
312
  this.#dirty = true;
313
+ clearTimeout(this.#timer);
314
+ if (!this.#abort.signal.aborted) this.#timer = setTimeout(this.#save, FLUSH_DELAY_MS);
233
315
  }
234
316
  /** Get frecency bonus for an item */
235
317
  getBonus(itemId, now) {
@@ -255,30 +337,49 @@ var FrecencyEngine = class {
255
337
  ...config
256
338
  };
257
339
  }
258
- /** Flush dirty data to storage */
340
+ /**
341
+ * Flush dirty data to storage and wait for every save in flight; saves run one after
342
+ * another, each after the load in flight (one that never settles holds them all), none after a
343
+ * rejected load, and a rejected save leaves the data dirty and rethrows to the flush that started it
344
+ */
259
345
  async flush() {
260
- if (!this.#dirty) return;
261
- await this.#storage.save(this.#namespace, this.#data);
346
+ clearTimeout(this.#timer);
347
+ const dirty = this.#dirty;
262
348
  this.#dirty = false;
349
+ const save = Promise.all([this.#pending, this.#loading]).then(() => dirty && !this.#loadFailed && this.#storage.save(this.#namespace, this.#data));
350
+ this.#pending = save.catch(() => {});
351
+ try {
352
+ await save;
353
+ } catch (error) {
354
+ this.#dirty = true;
355
+ throw error;
356
+ }
263
357
  }
358
+ /** Flush for the timer and the page listeners: a failure is reported, never thrown */
359
+ #save = () => this.flush().catch(this.#warn);
360
+ #warn = (error) => {
361
+ console.warn("[modern-cmdk] save failed:", error);
362
+ };
264
363
  /** Get the raw frecency data */
265
364
  getData() {
266
365
  return this.#data;
267
366
  }
268
- /** Async dispose — flushes dirty data before cleanup. Use with `await using`. */
269
- async [Symbol.asyncDispose]() {
270
- if (this.#dirty) {
271
- await this.#storage.save(this.#namespace, this.#data);
272
- this.#dirty = false;
273
- }
274
- this.#storage[Symbol.dispose]();
367
+ /**
368
+ * Async dispose — flushes dirty data, then disposes the storage the engine created, even
369
+ * when the save rejects (the rejection propagates). Use with `await using`.
370
+ */
371
+ [Symbol.asyncDispose]() {
372
+ this.#abort.abort();
373
+ return this.flush().finally(this.#disposeStorage);
275
374
  }
375
+ /** Sync dispose — starts the flush without awaiting it and disposes the storage the engine created once it settles */
276
376
  [Symbol.dispose]() {
277
- if (this.#dirty) Promise.resolve().then(() => this.#storage.save(this.#namespace, this.#data)).catch((err) => {
278
- console.warn("[modern-cmdk] FrecencyEngine dispose flush failed:", err);
279
- });
280
- this.#storage[Symbol.dispose]();
377
+ this.#abort.abort();
378
+ this.#save().finally(this.#disposeStorage).catch(this.#warn);
281
379
  }
380
+ #disposeStorage = () => {
381
+ if (this.#ownsStorage) this.#storage[Symbol.dispose]();
382
+ };
282
383
  };
283
384
  // src/core/utils/group-by.ts
284
385
  /**
@@ -397,10 +498,13 @@ function formatShortcut(parsed) {
397
498
  /**
398
499
  * Manages keyboard shortcut bindings with global keydown listening.
399
500
  * Supports `using` for automatic cleanup of both individual bindings and the
400
- * global document listener. Detects conflicts with the local mapGroupBy helper (core/utils/group-by.ts).
501
+ * global document listener. Every normalized shortcut keeps a stack of bindings, one per item: the later
502
+ * binding wins the keydown, disposing it restores the earlier one, and `getConflicts()` reports the keys with
503
+ * two or more bindings (through `detectConflicts`, which groups with the local mapGroupBy helper).
401
504
  */
402
505
  var KeyboardShortcutRegistry = class {
403
506
  #bindings = /* @__PURE__ */ new Map();
507
+ #keysByItem = /* @__PURE__ */ new Map();
404
508
  #globalListener = null;
405
509
  #enabled = true;
406
510
  constructor(options) {
@@ -410,45 +514,72 @@ var KeyboardShortcutRegistry = class {
410
514
  /** Register a shortcut binding — returns Disposable for `using` auto-deregister */
411
515
  register(shortcutStr, itemId, handler) {
412
516
  const shortcut = parseShortcut(shortcutStr);
413
- this.#bindings.set(shortcut.normalized, {
517
+ const binding = {
414
518
  shortcut,
415
519
  itemId,
416
520
  handler
417
- });
418
- return { [Symbol.dispose]: () => {
419
- this.#bindings.delete(shortcut.normalized);
420
- } };
521
+ };
522
+ const stack = this.#bindings.get(shortcut.normalized) ?? [];
523
+ const own = stack.findIndex((b) => b.itemId === itemId);
524
+ if (own < 0) stack.push(binding);
525
+ else stack[own] = binding;
526
+ this.#bindings.set(shortcut.normalized, stack);
527
+ const keys = this.#keysByItem.get(itemId) ?? /* @__PURE__ */ new Set();
528
+ this.#keysByItem.set(itemId, keys.add(shortcut.normalized));
529
+ return { [Symbol.dispose]: () => this.#remove(shortcut.normalized, (b) => b === binding) };
530
+ }
531
+ /** Drops the bindings of one key that the predicate selects; an emptied key goes away */
532
+ #remove(key, drop) {
533
+ const stack = this.#bindings.get(key);
534
+ if (!stack) return;
535
+ const rest = stack.filter((b) => !drop(b));
536
+ if (rest.length) this.#bindings.set(key, rest);
537
+ else this.#bindings.delete(key);
538
+ for (const { itemId } of stack) {
539
+ if (rest.some((b) => b.itemId === itemId)) continue;
540
+ const keys = this.#keysByItem.get(itemId);
541
+ keys?.delete(key);
542
+ if (!keys?.size) this.#keysByItem.delete(itemId);
543
+ }
421
544
  }
422
- /** Unregister a shortcut by its string representation */
545
+ /** Unregister a shortcut by its string representation — every binding of that key */
423
546
  unregister(shortcutStr) {
424
- const shortcut = parseShortcut(shortcutStr);
425
- this.#bindings.delete(shortcut.normalized);
547
+ this.#remove(parseShortcut(shortcutStr).normalized, () => true);
426
548
  }
427
- /** Unregister all shortcuts for a given item — Map spec allows deletion during iteration */
428
- unregisterByItem(itemId) {
429
- for (const [key, binding] of this.#bindings) if (binding.itemId === itemId) this.#bindings.delete(key);
549
+ /**
550
+ * Unregister all shortcuts for a given item — only the stacks of the keys it is bound under are touched.
551
+ * `keep` names one shortcut whose binding stays (a re-bind then replaces it in place, see register).
552
+ */
553
+ unregisterByItem(itemId, keep) {
554
+ const keys = this.#keysByItem.get(itemId);
555
+ if (!keys) return;
556
+ const kept = keep === void 0 ? void 0 : parseShortcut(keep).normalized;
557
+ for (const key of [...keys]) if (key !== kept) this.#remove(key, (b) => b.itemId === itemId);
430
558
  }
431
- /** Check for conflicting shortcuts */
559
+ /** Keys bound by two or more items (normalized shortcut -> the parsed shortcuts, earliest first) */
432
560
  getConflicts() {
433
- return detectConflicts(this.#bindings.values().map((b) => b.shortcut).toArray());
561
+ return detectConflicts(this.#bindings.values().flatMap((stack) => stack.map((b) => b.shortcut)).toArray());
434
562
  }
435
563
  /** Enable or disable global shortcut listening */
436
564
  setEnabled(enabled) {
437
565
  this.#enabled = enabled;
438
566
  }
439
- /** Get all registered bindings */
567
+ /** The winning binding of every key (a snapshot: the later of the bindings that share a key) */
440
568
  getBindings() {
441
- return this.#bindings;
569
+ return new Map(this.#bindings.entries().map(([key, stack]) => [key, stack.at(-1)]));
442
570
  }
443
571
  #attachGlobalListener() {
444
572
  if (typeof document === "undefined") return;
445
573
  this.#globalListener = (event) => {
446
574
  if (!this.#enabled) return;
447
- for (const binding of this.#bindings.values()) if (matchesShortcut(event, binding.shortcut)) {
448
- event.preventDefault();
449
- event.stopPropagation();
450
- binding.handler();
451
- return;
575
+ for (const stack of this.#bindings.values()) {
576
+ const binding = stack.at(-1);
577
+ if (matchesShortcut(event, binding.shortcut)) {
578
+ event.preventDefault();
579
+ event.stopPropagation();
580
+ binding.handler();
581
+ return;
582
+ }
452
583
  }
453
584
  };
454
585
  document.addEventListener("keydown", this.#globalListener);
@@ -462,6 +593,7 @@ var KeyboardShortcutRegistry = class {
462
593
  [Symbol.dispose]() {
463
594
  this.#detachGlobalListener();
464
595
  this.#bindings.clear();
596
+ this.#keysByItem.clear();
465
597
  }
466
598
  };
467
599
  // src/core/utils/set-ops.ts
@@ -516,7 +648,6 @@ function setSymmetricDifference(a, b) {
516
648
  var CommandRegistry = class {
517
649
  #items = /* @__PURE__ */ new Map();
518
650
  #groups = /* @__PURE__ */ new Map();
519
- #itemOrder = [];
520
651
  #itemOrderSet = /* @__PURE__ */ new Set();
521
652
  #cachedItems = null;
522
653
  #cachedGroups = null;
@@ -528,45 +659,32 @@ var CommandRegistry = class {
528
659
  }
529
660
  /** Register an item — returns Disposable for `using` auto-deregister */
530
661
  registerItem(item) {
531
- this.#items.set(item.id, item);
532
- if (!this.#itemOrderSet.has(item.id)) {
533
- this.#itemOrder.push(item.id);
534
- this.#itemOrderSet.add(item.id);
535
- }
536
- this.#invalidateCache();
537
- return { [Symbol.dispose]: () => {
538
- this.unregisterItem(item.id);
539
- } };
662
+ return this.registerItems([item]);
540
663
  }
541
664
  /** Register multiple items at once — single Disposable for batch cleanup. for...of for hot path. */
542
665
  registerItems(items) {
543
- for (const item of items) {
666
+ const owned = [...items];
667
+ for (const item of owned) {
544
668
  this.#items.set(item.id, item);
545
- if (!this.#itemOrderSet.has(item.id)) {
546
- this.#itemOrder.push(item.id);
547
- this.#itemOrderSet.add(item.id);
548
- }
669
+ this.#itemOrderSet.add(item.id);
549
670
  }
550
671
  this.#invalidateCache();
551
- const ids = new Set(items.values().map((i) => i.id));
552
672
  return { [Symbol.dispose]: () => {
553
- this.unregisterItems(ids);
673
+ for (const item of owned) if (this.#items.get(item.id) === item) this.unregisterItem(item.id);
554
674
  } };
555
675
  }
556
- /** Unregister a single item — O(1) Map/Set delete, indexOf+toSpliced for order */
676
+ /** Unregister a single item — O(1) Map/Set delete */
557
677
  unregisterItem(id) {
558
- if (!this.#items.has(id)) return;
559
- this.#items.delete(id);
678
+ if (!this.#items.delete(id)) return;
560
679
  this.#itemOrderSet.delete(id);
561
- const idx = this.#itemOrder.indexOf(id);
562
- if (idx !== -1) this.#itemOrder = this.#itemOrder.toSpliced(idx, 1);
563
680
  this.#invalidateCache();
564
681
  }
565
682
  /** Bulk unregister — for...of for hot path */
566
683
  unregisterItems(ids) {
567
- for (const id of ids) this.#items.delete(id);
568
- this.#itemOrderSet = setDifference(this.#itemOrderSet, ids);
569
- this.#itemOrder = this.#itemOrder.filter((id) => !ids.has(id));
684
+ for (const id of ids) {
685
+ this.#items.delete(id);
686
+ this.#itemOrderSet.delete(id);
687
+ }
570
688
  this.#invalidateCache();
571
689
  }
572
690
  /** Register a group */
@@ -574,8 +692,7 @@ var CommandRegistry = class {
574
692
  this.#groups.set(group.id, group);
575
693
  this.#invalidateGroupCache();
576
694
  return { [Symbol.dispose]: () => {
577
- this.#groups.delete(group.id);
578
- this.#invalidateGroupCache();
695
+ if (this.#groups.get(group.id) === group) this.unregisterGroup(group.id);
579
696
  } };
580
697
  }
581
698
  /** Unregister a group */
@@ -594,7 +711,7 @@ var CommandRegistry = class {
594
711
  /** Get all registered items in insertion order — cached between mutations */
595
712
  getItems() {
596
713
  if (this.#cachedItems !== null) return this.#cachedItems;
597
- this.#cachedItems = this.#itemOrder.values().map((id) => this.#items.get(id)).filter((item) => item != null).toArray();
714
+ this.#cachedItems = this.#items.values().toArray();
598
715
  return this.#cachedItems;
599
716
  }
600
717
  /** Get all registered groups sorted by priority — cached between group mutations */
@@ -611,8 +728,8 @@ var CommandRegistry = class {
611
728
  const groupItems = grouped.get(group.id);
612
729
  if (groupItems != null) result.set(group.id, groupItems);
613
730
  }
614
- const ungrouped = grouped.get("__ungrouped");
615
- if (ungrouped) result.set("__ungrouped", ungrouped);
731
+ const ungrouped = grouped.get(UNGROUPED_GROUP_ID);
732
+ if (ungrouped) result.set(UNGROUPED_GROUP_ID, ungrouped);
616
733
  return result;
617
734
  }
618
735
  /** Get item IDs as a Set — returns internal set (O(1), no allocation) */
@@ -659,7 +776,6 @@ var CommandRegistry = class {
659
776
  clear() {
660
777
  this.#items.clear();
661
778
  this.#groups.clear();
662
- this.#itemOrder = [];
663
779
  this.#itemOrderSet.clear();
664
780
  this.#invalidateCache();
665
781
  this.#invalidateGroupCache();
@@ -693,6 +809,11 @@ function isWordSeparator(code) {
693
809
  * Multi-strategy: exact → prefix → substring → word-boundary → fuzzy.
694
810
  * Returns null if no match. For batch use, prefer the search engine
695
811
  * which pre-caches lowercase and normalizes query once.
812
+ *
813
+ * `matches` are half-open ranges into `item.value` itself (UTF-16 indices of the original
814
+ * string, even where lowercasing changes its length, as 'İ' → 'i̇' does). A keyword has no
815
+ * position in the label: when a keyword outscores the value, `matches` keeps the value's own
816
+ * ranges if the value matched and is `[]` otherwise.
696
817
  */
697
818
  function scoreItem(query, item) {
698
819
  if (query === "") return {
@@ -700,45 +821,21 @@ function scoreItem(query, item) {
700
821
  score: 1,
701
822
  matches: []
702
823
  };
703
- const lowerQuery = query.toLowerCase();
704
- let bestScore = 0;
705
- let bestMatches = null;
706
- const valueResult = scoreTarget(lowerQuery, item.value.toLowerCase());
707
- if (valueResult !== null) {
708
- bestScore = valueResult.score;
709
- bestMatches = valueResult.matches;
710
- if (bestScore >= 1) return {
711
- id: item.id,
712
- score: 1,
713
- matches: bestMatches
714
- };
715
- }
716
- const kw = item.keywords;
717
- if (kw !== void 0) for (let i = 0; i < kw.length; i++) {
718
- const result = scoreTarget(lowerQuery, kw[i].toLowerCase());
719
- if (result !== null && result.score > bestScore) {
720
- bestScore = result.score;
721
- bestMatches = result.matches;
722
- if (bestScore >= 1) break;
723
- }
724
- }
725
- return bestMatches !== null ? {
726
- id: item.id,
727
- score: bestScore,
728
- matches: bestMatches
729
- } : null;
824
+ return scoreItemPreLowered(query.toLowerCase(), item.id, item.value, item.value.toLowerCase(), item.keywords?.map((keyword) => keyword.toLowerCase()));
730
825
  }
731
826
  /**
732
827
  * Internal fast path: called by search engine with pre-lowered query and targets.
733
- * Eliminates all toLowerCase() calls on the hot path.
828
+ * Eliminates the toLowerCase() calls on the hot path, except per character in toOriginalRanges
829
+ * when lowering changes the length of the value. `value` is the original text, which the
830
+ * ranges index (see {@link scoreItem}).
734
831
  */
735
- function scoreItemPreLowered(lowerQuery, id, lowerValue, lowerKeywords) {
832
+ function scoreItemPreLowered(lowerQuery, id, value, lowerValue, lowerKeywords) {
736
833
  let bestScore = 0;
737
834
  let bestMatches = null;
738
835
  const valueResult = scoreTarget(lowerQuery, lowerValue);
739
836
  if (valueResult !== null) {
740
837
  bestScore = valueResult.score;
741
- bestMatches = valueResult.matches;
838
+ bestMatches = toOriginalRanges(value, lowerValue, valueResult.matches);
742
839
  if (bestScore >= 1) return {
743
840
  id,
744
841
  score: 1,
@@ -749,7 +846,7 @@ function scoreItemPreLowered(lowerQuery, id, lowerValue, lowerKeywords) {
749
846
  const result = scoreTarget(lowerQuery, lowerKeywords[i]);
750
847
  if (result !== null && result.score > bestScore) {
751
848
  bestScore = result.score;
752
- bestMatches = result.matches;
849
+ bestMatches ??= [];
753
850
  if (bestScore >= 1) break;
754
851
  }
755
852
  }
@@ -759,6 +856,28 @@ function scoreItemPreLowered(lowerQuery, id, lowerValue, lowerKeywords) {
759
856
  matches: bestMatches
760
857
  } : null;
761
858
  }
859
+ /**
860
+ * Maps ranges found in `lowerValue` back onto `value`. Under toLowerCase() only U+0130
861
+ * ('İ' → 'i' + U+0307) changes the UTF-16 length of any code point, which shifts every later
862
+ * index, so when the lengths differ each lowered unit is traced to the character it came from:
863
+ * a range starts at the start of the character holding its first unit and ends at the end of
864
+ * the character holding its last.
865
+ * Equal lengths (the common case; the final sigma lowers to 'ς' or 'σ', both one unit) need no map.
866
+ */
867
+ function toOriginalRanges(value, lowerValue, ranges) {
868
+ if (value.length === lowerValue.length) return ranges;
869
+ const locate = (unit, after) => {
870
+ let from = 0;
871
+ let lowered = 0;
872
+ for (const char of value) {
873
+ lowered += char.toLowerCase().length;
874
+ if (lowered > unit) return from + after * char.length;
875
+ from += char.length;
876
+ }
877
+ return from;
878
+ };
879
+ return ranges.map(([start, end]) => [locate(start, 0), locate(end - 1, 1)]);
880
+ }
762
881
  function scoreTarget(query, lowerTarget) {
763
882
  const tLen = lowerTarget.length;
764
883
  if (tLen === 0) return null;
@@ -790,28 +909,41 @@ function scoreTarget(query, lowerTarget) {
790
909
  return scoreFuzzy(query, qLen, lowerTarget, tLen);
791
910
  }
792
911
  function scoreWordBoundary(query, qLen, lowerTarget, tLen) {
793
- let queryIdx = 0;
912
+ if (qLen > 512) return null;
794
913
  const matches = [];
795
- let totalWeightedLen = 0;
796
- let i = 0;
797
- while (i < tLen && queryIdx < qLen) if ((i === 0 || isWordSeparator(lowerTarget.charCodeAt(i - 1))) && lowerTarget.charCodeAt(i) === query.charCodeAt(queryIdx)) {
798
- const matchStart = i;
799
- let matchLen = 0;
800
- while (queryIdx < qLen && i < tLen && !isWordSeparator(lowerTarget.charCodeAt(i)) && lowerTarget.charCodeAt(i) === query.charCodeAt(queryIdx)) {
801
- queryIdx++;
802
- i++;
803
- matchLen++;
804
- }
805
- matches.push([matchStart, matchStart + matchLen]);
806
- totalWeightedLen += matchLen * 2;
807
- } else i++;
808
- if (queryIdx !== qLen) return null;
809
- const maxPossible = qLen * 2;
914
+ failedFrom = void 0;
915
+ squares = 0;
916
+ if (!coverQuery(query, 0, lowerTarget, 0, matches)) return null;
810
917
  return {
811
- score: .3 + .4 * (totalWeightedLen / maxPossible),
918
+ score: .65 + .03 * (squares / (qLen * qLen)) + .02 * (qLen / tLen),
812
919
  matches
813
920
  };
814
921
  }
922
+ let failedFrom;
923
+ let squares = 0;
924
+ /**
925
+ * Covers query[queryIdx..] with segments that each start at a word start at or after `from`,
926
+ * stay inside one word and match contiguously. Each word start tries its longest run first, then
927
+ * shorter ones, so the first cover found is the one a greedy scan finds whenever that succeeds.
928
+ * Prepends the segments of the cover to `matches`, in order; charCode scan, no regex, no split.
929
+ */
930
+ function coverQuery(query, queryIdx, lowerTarget, from, matches) {
931
+ const end = failedFrom?.[queryIdx] ?? lowerTarget.length;
932
+ for (let i = from; i < end; i++) if ((i === 0 || isWordSeparator(lowerTarget.charCodeAt(i - 1))) && lowerTarget.charCodeAt(i) === query.charCodeAt(queryIdx)) {
933
+ let run = 0;
934
+ while (!isWordSeparator(lowerTarget.charCodeAt(i + run)) && lowerTarget.charCodeAt(i + run) === query.charCodeAt(queryIdx + run)) run++;
935
+ for (; run > 0; run--) if (queryIdx + run === query.length || coverQuery(query, queryIdx + run, lowerTarget, i + run, matches)) {
936
+ matches.unshift([i, i + run]);
937
+ squares += run * run;
938
+ return true;
939
+ }
940
+ }
941
+ if (queryIdx > 0 && from < end) {
942
+ failedFrom ??= [];
943
+ failedFrom[queryIdx] = from;
944
+ }
945
+ return false;
946
+ }
815
947
  function scoreFuzzy(query, qLen, lowerTarget, tLen) {
816
948
  let queryIdx = 0;
817
949
  let targetIdx = 0;
@@ -852,9 +984,10 @@ if (isDev && typeof window !== "undefined") leakRegistry = new FinalizationRegis
852
984
  console.warn(`[modern-cmdk] ${label} was garbage collected without being disposed. Use \`using\` or call [Symbol.dispose]() to prevent leaks.`);
853
985
  });
854
986
  /**
855
- * Creates a search engine with pluggable scorer and incremental filtering.
856
- * When the query extends the previous one, only prior matches are re-scored.
857
- * Pre-caches lowercase at index time — zero toLowerCase() calls during search.
987
+ * Creates a search engine with pluggable scorer.
988
+ * With the default scorer, a query that extends the previous one (compared lowercased) re-scores
989
+ * only the prior matches; a custom scorer is called for every item on every non-empty query.
990
+ * Pre-caches lowercase at index time; indexed items are not re-lowered during search.
858
991
  * Uses Iterator Helpers (ES2025) for result pipelines.
859
992
  */
860
993
  function createSearchEngine(options) {
@@ -886,11 +1019,11 @@ function createSearchEngine(options) {
886
1019
  }));
887
1020
  }
888
1021
  const lowerQuery = query.toLowerCase();
889
- const candidateItems = query.startsWith(previousQuery) && previousQuery.length > 0 ? items.values().filter((item) => previousResults.has(item.id)) : items;
1022
+ const candidateItems = useDefaultScorer && previousQuery.length > 0 && lowerQuery.startsWith(previousQuery) ? items.values().filter((item) => previousResults.has(item.id)) : items;
890
1023
  const results = [];
891
1024
  if (useDefaultScorer) for (const item of candidateItems) {
892
1025
  const entry = indexedEntries.get(item.id);
893
- const result = entry ? scoreItemPreLowered(lowerQuery, item.id, entry.lowerValue, entry.lowerKeywords) : scoreItemPreLowered(lowerQuery, item.id, item.value.toLowerCase(), item.keywords?.map((k) => k.toLowerCase()));
1026
+ const result = entry ? scoreItemPreLowered(lowerQuery, item.id, item.value, entry.lowerValue, entry.lowerKeywords) : scoreItemPreLowered(lowerQuery, item.id, item.value, item.value.toLowerCase(), item.keywords?.map((k) => k.toLowerCase()));
894
1027
  if (result !== null) results.push(result);
895
1028
  }
896
1029
  else for (const item of candidateItems) {
@@ -898,13 +1031,15 @@ function createSearchEngine(options) {
898
1031
  if (result != null) results.push(result);
899
1032
  }
900
1033
  results.sort((a, b) => b.score - a.score);
901
- previousQuery = query;
1034
+ previousQuery = lowerQuery;
902
1035
  previousResults = new Set(results.values().map((r) => r.id));
903
1036
  return results.values();
904
1037
  },
905
1038
  remove(ids) {
906
- for (const id of ids) indexedEntries.delete(id);
907
- previousResults = setDifference(previousResults, ids);
1039
+ for (const id of ids) {
1040
+ indexedEntries.delete(id);
1041
+ previousResults.delete(id);
1042
+ }
908
1043
  },
909
1044
  clear() {
910
1045
  indexedEntries.clear();
@@ -921,6 +1056,7 @@ function createSearchEngine(options) {
921
1056
  // src/core/utils/event-emitter.ts
922
1057
  /**
923
1058
  * Type-safe event emitter with strong references and Disposable subscriptions.
1059
+ * `T` maps event names to payload types; an interface or a type alias both work (`object`, not an index signature, which an interface lacks).
924
1060
  * Returns Disposable from `on()` for automatic unsubscription via `using`.
925
1061
  */
926
1062
  var TypedEmitter = class {
@@ -938,11 +1074,20 @@ var TypedEmitter = class {
938
1074
  set.delete(fn);
939
1075
  } };
940
1076
  }
941
- /** Emit an event to all listeners. Snapshots the set to prevent live-iteration issues. */
1077
+ /**
1078
+ * Emit an event to all listeners. Snapshots the set to prevent live-iteration issues.
1079
+ * A throwing listener does not stop the later ones: its error is rethrown from a microtask, as the scheduler does.
1080
+ */
942
1081
  emit(event, data) {
943
1082
  const set = this.#listeners.get(event);
944
1083
  if (!set || set.size === 0) return;
945
- for (const fn of [...set]) fn(data);
1084
+ for (const fn of [...set]) try {
1085
+ fn(data);
1086
+ } catch (error) {
1087
+ queueMicrotask(() => {
1088
+ throw error;
1089
+ });
1090
+ }
946
1091
  }
947
1092
  /** Check if any listeners exist for an event */
948
1093
  has(event) {
@@ -964,13 +1109,9 @@ var TypedEmitter = class {
964
1109
  // src/core/utils/scheduler.ts
965
1110
  /** Whether we're in a browser environment with rAF available */
966
1111
  const hasRAF = typeof requestAnimationFrame === "function";
967
- /** Feature detection for cutting-edge scheduling APIs, read through globalThis: typeof guards only a bare
968
- * identifier, so `typeof navigator?.scheduling` would throw where navigator is undeclared (runtimes without one). */
969
- const canYield = typeof globalThis.scheduler?.yield === "function";
970
- const hasInputPending = typeof globalThis.navigator?.scheduling?.isInputPending === "function";
971
1112
  /**
972
1113
  * Creates a batched update scheduler. Coalesces rapid state updates into a single
973
- * execution pass using rAF (browser) or queueMicrotask (Node.js).
1114
+ * execution pass using rAF (browser) or queueMicrotask (where rAF is absent: Node.js, SSR).
974
1115
  */
975
1116
  function createScheduler() {
976
1117
  let pending = [];
@@ -981,12 +1122,6 @@ function createScheduler() {
981
1122
  pending = [];
982
1123
  rafId = null;
983
1124
  microtaskScheduled = false;
984
- if (batch.length > 10 && hasInputPending && navigator.scheduling?.isInputPending?.()) {
985
- pending = [...batch, ...pending];
986
- if (canYield) globalThis.scheduler?.yield?.().then(() => void executeBatch());
987
- else rafId ??= requestAnimationFrame(() => void executeBatch());
988
- return;
989
- }
990
1125
  for (const update of batch) try {
991
1126
  update();
992
1127
  } catch (error) {
@@ -1026,27 +1161,33 @@ function createScheduler() {
1026
1161
  // src/core/machine.ts
1027
1162
  /**
1028
1163
  * Creates a headless command palette state machine with search, frecency,
1029
- * and keyboard shortcut support. Implements Disposable for `using` cleanup.
1164
+ * and keyboard shortcut support. Implements Disposable and AsyncDisposable for
1165
+ * `using` and `await using` cleanup (the latter awaits the pending frecency save).
1030
1166
  *
1031
1167
  * @example
1032
1168
  * ```ts
1033
- * using machine = createCommandMachine({ items, loop: true });
1034
- * machine.subscribe(() => console.log(machine.getState()));
1169
+ * const machine = createCommandMachine({ items, loop: true });
1170
+ * const unsubscribe = machine.subscribe(() => console.log(machine.getState()));
1171
+ * machine.send({ type: 'SEARCH_CHANGE', query: 'app' }); // handled in the next frame
1172
+ *
1173
+ * // on teardown
1174
+ * unsubscribe();
1175
+ * machine[Symbol.dispose]();
1035
1176
  * ```
1036
1177
  */
1037
1178
  function createCommandMachine(options = {}) {
1038
1179
  let state = createInitialState(options);
1039
1180
  let filteredIdSet = /* @__PURE__ */ new Set();
1040
1181
  let filteredIdIndex = /* @__PURE__ */ new Map();
1182
+ let navIds = [];
1041
1183
  const registry = new CommandRegistry();
1042
1184
  const searchEngine = options.search ?? createSearchEngine(typeof options.filter === "function" ? { scorer: (query, item) => {
1043
1185
  const score = options.filter(item, query);
1044
- if (score === false) return null;
1045
- return {
1186
+ return score && score > 0 ? {
1046
1187
  id: item.id,
1047
1188
  score,
1048
1189
  matches: []
1049
- };
1190
+ } : null;
1050
1191
  } } : void 0);
1051
1192
  const scheduler = createScheduler();
1052
1193
  const emitter = new TypedEmitter();
@@ -1055,24 +1196,47 @@ function createCommandMachine(options = {}) {
1055
1196
  let frecencyEngine = null;
1056
1197
  if (options.frecency?.enabled) {
1057
1198
  frecencyEngine = new FrecencyEngine(options.frecency);
1058
- frecencyEngine.load();
1199
+ frecencyEngine.load().then(() => {
1200
+ if (disposed || state.search === "" || options.filter === false) return;
1201
+ dirty = true;
1202
+ if (queued === 0) commit();
1203
+ }, (error) => {
1204
+ console.warn("[modern-cmdk] frecency load failed:", error);
1205
+ });
1059
1206
  }
1207
+ let queued = 0;
1208
+ let nextQueued = 0;
1209
+ let inHandler = false;
1210
+ let dirty = false;
1211
+ let changed = false;
1212
+ let disposed = false;
1060
1213
  if (options.items) {
1061
1214
  registry.registerItems(options.items);
1062
1215
  searchEngine.index(options.items);
1063
- for (const item of options.items) if (item.shortcut != null && item.onSelect != null) keyboardRegistry.register(item.shortcut, item.id, item.onSelect);
1216
+ for (const item of options.items) bind(item);
1064
1217
  }
1065
1218
  if (options.groups) for (const group of options.groups) registry.registerGroup(group);
1066
1219
  refilter("");
1067
- function setState(newState) {
1068
- state = newState;
1069
- emitter.emit("stateChange", state);
1220
+ changed = false;
1221
+ function setState(patch) {
1222
+ state = {
1223
+ ...state,
1224
+ ...patch,
1225
+ lastUpdated: Date.now()
1226
+ };
1227
+ changed = true;
1228
+ }
1229
+ function setOpen(open) {
1230
+ setState({ open });
1231
+ emitter.emit("openChange", open);
1232
+ options.onOpenChange?.(open);
1070
1233
  }
1071
1234
  function refilter(query) {
1235
+ dirty = false;
1072
1236
  const items = registry.getItems();
1073
1237
  const disableFilter = options.filter === false;
1074
1238
  let filteredIds;
1075
- if (disableFilter || query === "") filteredIds = items.values().filter((i) => !i.disabled).map((i) => i.id).toArray();
1239
+ if (disableFilter || query === "") filteredIds = items.map((i) => i.id);
1076
1240
  else {
1077
1241
  let results = searchEngine.search(query, items).toArray();
1078
1242
  if (frecencyEngine) {
@@ -1085,68 +1249,76 @@ function createCommandMachine(options = {}) {
1085
1249
  }
1086
1250
  filteredIds = results.map((r) => r.id);
1087
1251
  }
1252
+ if (options.disabledItems === "hide") filteredIds = filteredIds.filter(isEnabled);
1088
1253
  filteredIdSet = new Set(filteredIds);
1089
1254
  filteredIdIndex = new Map(filteredIds.entries().map(([i, id]) => [id, i]));
1090
- const groupedIds = mapGroupBy(filteredIds, (id) => {
1091
- return registry.getItem(id)?.groupId ?? "__ungrouped";
1092
- });
1093
- const activeId = state.activeId && filteredIdSet.has(state.activeId) ? state.activeId : filteredIds[0] ?? null;
1255
+ const priority = (g) => registry.getGroup(g)?.priority ?? 0;
1256
+ const groupedIds = new Map([...mapGroupBy(filteredIds, (id) => registry.getItem(id)?.groupId ?? "__ungrouped")].sort((x, y) => priority(x[0]) - priority(y[0])));
1257
+ navIds = filteredIds.filter(isEnabled);
1258
+ const activeId = state.activeId && filteredIdSet.has(state.activeId) && isEnabled(state.activeId) ? state.activeId : navIds[0] ?? null;
1094
1259
  setState({
1095
- ...state,
1096
1260
  search: query,
1097
1261
  filteredIds,
1098
1262
  groupedIds,
1099
1263
  filteredCount: filteredIds.length,
1100
- activeId,
1101
- lastUpdated: Date.now()
1264
+ activeId
1102
1265
  });
1103
1266
  }
1267
+ function isEnabled(id) {
1268
+ return !registry.getItem(id)?.disabled;
1269
+ }
1270
+ /**
1271
+ * (Re)binds an item's keyboard shortcut — the one place that does it, for initial items, ITEMS_LOADED,
1272
+ * REGISTER_ITEM and UPDATE_ITEM. The item's bindings on other keys go first; one on the same key is replaced in place, so
1273
+ * it keeps its place in that key's stack (CE-108). A disabled item is not bound at all (its key
1274
+ * stays free for the page and for other items on it); UPDATE_ITEM re-binds, so enabling an item binds it. Under
1275
+ * 'select' the binding dispatches ITEM_SELECT, whose guard checks the item; under 'direct' it only binds an item
1276
+ * with an onSelect and checks the registry entry again before calling it.
1277
+ */
1278
+ function bind({ id, shortcut, disabled, onSelect }) {
1279
+ const select = options.shortcutAction === "select";
1280
+ const bound = shortcut && !disabled && (select || onSelect) ? shortcut : void 0;
1281
+ keyboardRegistry.unregisterByItem(id, bound);
1282
+ if (!bound) return;
1283
+ const handler = () => {
1284
+ if (select) machine.send({
1285
+ type: "ITEM_SELECT",
1286
+ id
1287
+ });
1288
+ else if (isEnabled(id)) onSelect?.();
1289
+ };
1290
+ keyboardRegistry.register(bound, id, handler);
1291
+ }
1104
1292
  function navigate(direction) {
1105
- const { filteredIds, activeId } = state;
1106
- if (filteredIds.length === 0) return;
1107
- if (direction === "first") {
1108
- const newActiveId = filteredIds[0] ?? null;
1109
- if (newActiveId !== activeId) {
1110
- setState({
1111
- ...state,
1112
- activeId: newActiveId,
1113
- lastUpdated: Date.now()
1114
- });
1115
- options.onActiveChange?.(newActiveId);
1116
- }
1117
- return;
1118
- }
1119
- if (direction === "last") {
1120
- const newActiveId = filteredIds[filteredIds.length - 1] ?? null;
1121
- if (newActiveId !== activeId) {
1122
- setState({
1123
- ...state,
1124
- activeId: newActiveId,
1125
- lastUpdated: Date.now()
1126
- });
1127
- options.onActiveChange?.(newActiveId);
1128
- }
1129
- return;
1130
- }
1131
- const loop = options.loop ?? true;
1132
- const currentIdx = activeId ? filteredIdIndex.get(activeId) ?? -1 : -1;
1133
- let newIdx;
1134
- if (direction === "next") {
1135
- if (currentIdx === -1 || currentIdx >= filteredIds.length - 1) newIdx = loop ? 0 : filteredIds.length - 1;
1136
- else newIdx = currentIdx + 1;
1137
- } else if (currentIdx <= 0) newIdx = loop ? filteredIds.length - 1 : 0;
1138
- else newIdx = currentIdx - 1;
1139
- const newActiveId = filteredIds[newIdx] ?? null;
1293
+ const { activeId } = state;
1294
+ const last = navIds.length - 1;
1295
+ if (last < 0) return;
1296
+ const loop = options.loop ?? DEFAULT_MACHINE_OPTIONS.loop;
1297
+ const current = activeId ? navIds.indexOf(activeId) : -1;
1298
+ let index;
1299
+ if (direction === "first") index = 0;
1300
+ else if (direction === "last") index = last;
1301
+ else if (direction === "next") index = current < 0 || current >= last ? loop ? 0 : last : current + 1;
1302
+ else index = current <= 0 ? loop ? last : 0 : current - 1;
1303
+ const newActiveId = navIds[index] ?? null;
1140
1304
  if (newActiveId !== activeId) {
1141
- setState({
1142
- ...state,
1143
- activeId: newActiveId,
1144
- lastUpdated: Date.now()
1145
- });
1305
+ setState({ activeId: newActiveId });
1146
1306
  options.onActiveChange?.(newActiveId);
1147
1307
  }
1148
1308
  }
1309
+ /** Ends a batch: one refilter if a registry event ran, then one stateChange even if the refilter throws. */
1310
+ function commit() {
1311
+ try {
1312
+ if (dirty) refilter(state.search);
1313
+ } finally {
1314
+ if (changed) {
1315
+ changed = false;
1316
+ emitter.emit("stateChange", state);
1317
+ }
1318
+ }
1319
+ }
1149
1320
  function handleEvent(event) {
1321
+ if (dirty && (event.type === "NAVIGATE" || event.type === "ITEM_ACTIVATE")) refilter(state.search);
1150
1322
  switch (event.type) {
1151
1323
  case "SEARCH_CHANGE":
1152
1324
  refilter(event.query);
@@ -1163,12 +1335,8 @@ function createCommandMachine(options = {}) {
1163
1335
  break;
1164
1336
  }
1165
1337
  case "ITEM_ACTIVATE":
1166
- if (filteredIdSet.has(event.id)) {
1167
- setState({
1168
- ...state,
1169
- activeId: event.id,
1170
- lastUpdated: Date.now()
1171
- });
1338
+ if (filteredIdSet.has(event.id) && isEnabled(event.id)) {
1339
+ setState({ activeId: event.id });
1172
1340
  options.onActiveChange?.(event.id);
1173
1341
  }
1174
1342
  break;
@@ -1177,94 +1345,81 @@ function createCommandMachine(options = {}) {
1177
1345
  break;
1178
1346
  case "PAGE_PUSH":
1179
1347
  setState({
1180
- ...state,
1181
1348
  page: event.page,
1182
- pageStack: [...state.pageStack, state.page],
1183
- lastUpdated: Date.now()
1349
+ pageStack: [...state.pageStack, state.page]
1184
1350
  });
1185
1351
  break;
1186
1352
  case "PAGE_POP": {
1187
1353
  const prevPage = state.pageStack.at(-1);
1188
1354
  if (prevPage != null) setState({
1189
- ...state,
1190
1355
  page: prevPage,
1191
- pageStack: state.pageStack.toSpliced(-1, 1),
1192
- lastUpdated: Date.now()
1356
+ pageStack: state.pageStack.toSpliced(-1, 1)
1193
1357
  });
1194
1358
  break;
1195
1359
  }
1196
1360
  case "OPEN":
1197
- setState({
1198
- ...state,
1199
- open: true,
1200
- lastUpdated: Date.now()
1201
- });
1202
- emitter.emit("openChange", true);
1203
- options.onOpenChange?.(true);
1361
+ setOpen(true);
1204
1362
  break;
1205
1363
  case "CLOSE":
1206
- setState({
1207
- ...state,
1208
- open: false,
1209
- lastUpdated: Date.now()
1210
- });
1211
- emitter.emit("openChange", false);
1212
- options.onOpenChange?.(false);
1364
+ setOpen(false);
1213
1365
  break;
1214
- case "TOGGLE": {
1215
- const newOpen = !state.open;
1216
- setState({
1217
- ...state,
1218
- open: newOpen,
1219
- lastUpdated: Date.now()
1220
- });
1221
- emitter.emit("openChange", newOpen);
1222
- options.onOpenChange?.(newOpen);
1366
+ case "TOGGLE":
1367
+ setOpen(!state.open);
1223
1368
  break;
1224
- }
1225
1369
  case "ITEMS_LOADED":
1226
1370
  registry.registerItems(event.items);
1227
1371
  searchEngine.index(event.items);
1228
- refilter(state.search);
1372
+ for (const item of event.items) bind(item);
1373
+ dirty = true;
1229
1374
  break;
1230
1375
  case "LOADING": {
1231
1376
  if (event.pending) pendingSources.add(event.source);
1232
1377
  else pendingSources.delete(event.source);
1233
1378
  const loading = pendingSources.size > 0;
1234
- if (loading !== state.loading) setState({
1235
- ...state,
1236
- loading,
1237
- lastUpdated: Date.now()
1238
- });
1379
+ if (loading !== state.loading) setState({ loading });
1239
1380
  break;
1240
1381
  }
1241
1382
  case "REGISTER_ITEM":
1383
+ case "UPDATE_ITEM":
1242
1384
  registry.registerItem(event.item);
1243
1385
  searchEngine.index([event.item]);
1244
- if (event.item.shortcut && event.item.onSelect) keyboardRegistry.register(event.item.shortcut, event.item.id, event.item.onSelect);
1245
- refilter(state.search);
1386
+ bind(event.item);
1387
+ dirty = true;
1246
1388
  break;
1247
1389
  case "UNREGISTER_ITEM":
1248
1390
  registry.unregisterItem(event.id);
1249
1391
  searchEngine.remove(/* @__PURE__ */ new Set([event.id]));
1250
1392
  keyboardRegistry.unregisterByItem(event.id);
1251
- refilter(state.search);
1393
+ dirty = true;
1252
1394
  break;
1253
1395
  case "REGISTER_GROUP":
1254
1396
  registry.registerGroup(event.group);
1255
- refilter(state.search);
1397
+ dirty = true;
1256
1398
  break;
1257
1399
  case "UNREGISTER_GROUP":
1258
1400
  registry.unregisterGroup(event.id);
1259
- refilter(state.search);
1401
+ dirty = true;
1260
1402
  }
1261
1403
  }
1262
- return {
1404
+ const machine = {
1263
1405
  getState() {
1264
1406
  return state;
1265
1407
  },
1266
1408
  send(event) {
1267
- scheduler.schedule(() => handleEvent(event));
1409
+ queued++;
1410
+ if (inHandler) nextQueued++;
1411
+ scheduler.schedule(() => {
1412
+ inHandler = true;
1413
+ try {
1414
+ handleEvent(event);
1415
+ } finally {
1416
+ inHandler = false;
1417
+ if (--queued === nextQueued) {
1418
+ nextQueued = 0;
1419
+ commit();
1420
+ }
1421
+ }
1422
+ });
1268
1423
  },
1269
1424
  /** useSyncExternalStore-compatible subscribe — returns cleanup function */
1270
1425
  subscribe(listener) {
@@ -1288,14 +1443,25 @@ function createCommandMachine(options = {}) {
1288
1443
  return filteredIdIndex;
1289
1444
  },
1290
1445
  [Symbol.dispose]() {
1446
+ disposed = true;
1447
+ queued = nextQueued = 0;
1291
1448
  scheduler[Symbol.dispose]();
1292
1449
  registry[Symbol.dispose]();
1293
1450
  searchEngine[Symbol.dispose]();
1294
1451
  emitter[Symbol.dispose]();
1295
1452
  keyboardRegistry[Symbol.dispose]();
1296
1453
  frecencyEngine?.[Symbol.dispose]();
1454
+ },
1455
+ /** Awaits the pending frecency save, then disposes like [Symbol.dispose] — for `await using` */
1456
+ async [Symbol.asyncDispose]() {
1457
+ try {
1458
+ await frecencyEngine?.flush();
1459
+ } finally {
1460
+ machine[Symbol.dispose]();
1461
+ }
1297
1462
  }
1298
1463
  };
1464
+ return machine;
1299
1465
  }
1300
1466
  // src/core/search/fuzzy-scorer.ts
1301
1467
  /**
@@ -1354,4 +1520,4 @@ function createTelemetryMiddleware(hooks) {
1354
1520
  }
1355
1521
  };
1356
1522
  }
1357
- export { CommandRegistry, DEFAULT_FRECENCY_DECAY, DEFAULT_MACHINE_OPTIONS, FrecencyEngine, IdbFrecencyStorage, KeyboardShortcutRegistry, MemoryFrecencyStorage, TypedEmitter, batchScoreItems, computeFrecencyBonus, createCommandMachine, createInitialState, createScheduler, createSearchEngine, createTelemetryMiddleware, detectConflicts, findMatchingShortcut, formatShortcut, groupId, itemId, matchesShortcut, parseShortcut, scoreItem, scoreItemAsync };
1523
+ export { CommandRegistry, DEFAULT_FRECENCY_DECAY, DEFAULT_MACHINE_OPTIONS, FrecencyEngine, IdbFrecencyStorage, KeyboardShortcutRegistry, MemoryFrecencyStorage, TypedEmitter, UNGROUPED_GROUP_ID, batchScoreItems, computeFrecencyBonus, createCommandMachine, createInitialState, createScheduler, createSearchEngine, createTelemetryMiddleware, detectConflicts, findMatchingShortcut, formatShortcut, groupId, itemId, matchesShortcut, parseShortcut, scoreItem, scoreItemAsync };