From 356d36e4aa2ddf8cffa07fafa74e2d5afb43458d Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Mon, 20 Jul 2026 15:49:26 -0400 Subject: [PATCH] Stop best-match scoring for superseded queries Refreshes are serialized, so with scoring slower than the quick-search debounce, intermediate queries queued instead of becoming obsolete. Bump a generation counter when a filter or the selected rows change and check it between scoring chunks, abandoning the stale pass and leaving the rows for the newer refresh to replace. --- .../content/zotero/collectionViewItemTree.jsx | 30 +++++++++++++++++-- chrome/content/zotero/xpcom/embeddings.js | 20 ++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx index a82b0b50a7..ceec992aa4 100644 --- a/chrome/content/zotero/collectionViewItemTree.jsx +++ b/chrome/content/zotero/collectionViewItemTree.jsx @@ -98,6 +98,9 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { constructor(itemTree) { super(itemTree); this.collectionTreeRows = []; + // Bumped when a filter changes, so an in-flight best-match scoring + // pass for a superseded query can stop (see _applyBestMatch()) + this._bestMatchGeneration = 0; } /** @@ -195,10 +198,18 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { } } let scores; + let generation = this._bestMatchGeneration; try { - scores = await Zotero.Embeddings.scoreItemIDs(query, [...new Set(sourceIDByItem.values())]); + scores = await Zotero.Embeddings.scoreItemIDs(query, [...new Set(sourceIDByItem.values())], { + // A newer filter (e.g. more typed search text) makes this + // query obsolete -- stop scoring and let its refresh take over + shouldCancel: () => generation !== this._bestMatchGeneration + }); } catch (e) { + if (e instanceof Zotero.Embeddings.ScoringCancelledError) { + throw e; + } // Scoring can fail while the model is still downloading or the // index is being rebuilt -- show no results rather than an // unranked scope @@ -374,6 +385,8 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { let resetColumns = viewMode != this._viewMode; this._viewMode = viewMode; this.collectionTreeRows = collectionTreeRows; + // Cancel any in-flight best-match scoring for the replaced selection + this._bestMatchGeneration++; // When the selection spans multiple libraries, group items by library in // collections-list order (the order of the selected rows), with a header @@ -440,6 +453,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { changed = changed || rowChanged; } if (changed) { + this._bestMatchGeneration++; this._filterRefreshPromise = this.refresh({ restoreSelection: true }); } // An unchanged filter can arrive while a previous filter's refresh is still in @@ -529,7 +543,19 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { } // The semantic stage: one scoring pass over the merged results if (bestMatchSearch) { - newSearchItems = await this._applyBestMatch(newSearchItems); + try { + newSearchItems = await this._applyBestMatch(newSearchItems); + } + catch (e) { + // A newer filter superseded this one mid-scoring -- leave the + // rows as they are and let the newer filter's refresh replace + // them + if (e instanceof Zotero.Embeddings.ScoringCancelledError) { + deferred.resolve(); + return; + } + throw e; + } } let newSearchItemIDs = new Set(newSearchItems.map(item => item.treeViewID)); // In Recently Read, the search matches parent items, but the items that were diff --git a/chrome/content/zotero/xpcom/embeddings.js b/chrome/content/zotero/xpcom/embeddings.js index 2f58e19c61..07fe12be2a 100644 --- a/chrome/content/zotero/xpcom/embeddings.js +++ b/chrome/content/zotero/xpcom/embeddings.js @@ -476,6 +476,17 @@ Zotero.Embeddings = new function () { } }; + /** + * Thrown when scoring is abandoned via the shouldCancel callback -- e.g. + * because a newer query superseded the one being scored + */ + this.ScoringCancelledError = class extends Error { + constructor(message = 'Scoring cancelled') { + super(message); + this.name = 'EmbeddingsScoringCancelledError'; + } + }; + function _ensureWorker() { if (_worker) { return; @@ -717,9 +728,13 @@ Zotero.Embeddings = new function () { * * @param {String} queryText * @param {Number[]} itemIDs - Candidate item IDs to score + * @param {Object} [options] + * @param {Function} [options.shouldCancel] - Checked between chunks; + * return true to abandon scoring with a ScoringCancelledError (e.g. + * because a newer query made this one obsolete) * @return {Promise} - itemID -> similarity score (higher is more similar) */ - this.scoreItemIDs = async function (queryText, itemIDs) { + this.scoreItemIDs = async function (queryText, itemIDs, { shouldCancel } = {}) { let scores = new Map(); if (!itemIDs.length || !this.isEnabled()) { return scores; @@ -749,6 +764,9 @@ Zotero.Embeddings = new function () { // parameter limit for large collections), scoring each as we go. let chunkSize = 500; for (let i = 0; i < itemIDs.length; i += chunkSize) { + if (shouldCancel && shouldCancel()) { + throw new this.ScoringCancelledError(); + } // If the model changed while we were scoring, the scores computed // so far mix models -- discard them if (generation !== _modelGeneration) {