Name the responsible plugin when the items list fails to load

A plugin that monkey-patches a method in the item list's load path --
say, Zotero.CollectionTreeRow.prototype.getItems() -- can throw and
leave the pane showing only "Error loading items list", with nothing in
the error report to identify it. Update the message to name the culprit.

https://forums.zotero.org/discussion/133565/
This commit is contained in:
Dan Stillman 2026-09-02 17:22:54 -04:00
parent 08ed64f17f
commit fac6c8ebf6
3 changed files with 123 additions and 1 deletions

View file

@ -50,6 +50,25 @@ const { ZOTERO_CONFIG } = ChromeUtils.importESModule('resource://zotero/config.m
const COLORED_TAGS_RE = new RegExp("^(?:Numpad|Digit)([0-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1})$");
/**
* Message for a failed items list load, naming the plugin responsible if the error came
* from plugin code
*
* @param {Error} e
* @return {Promise<String>}
*/
async function getLoadErrorMessage(e) {
let plugin = await Zotero.Plugins.getPluginFromError(e);
if (!plugin) {
return Zotero.getString('pane.items.loadError');
}
Zotero.warn(`Items list failed to load in a call from plugin ${plugin.name} (${plugin.id})`);
return Zotero.ftl.formatValueSync('items-list-load-error-plugin', {
// The message is rendered as HTML
plugin: Zotero.Utilities.htmlSpecialChars(plugin.name)
});
}
// Minimal CollectionTreeRow-like object for callers that pass plain objects to
// changeCollectionTreeRow()/setCollectionTreeRow() (e.g. advanced search).
const STUB_COLLECTION_TREE_ROW = {
@ -628,7 +647,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
}
await Zotero.Promise.delay();
this.runListeners('update', true, {
message: Zotero.getString('pane.items.loadError')
message: await getLoadErrorMessage(e)
});
throw e;
}

View file

@ -334,6 +334,107 @@ Zotero.Plugins = new function () {
};
/**
* Identify the plugin, if any, whose code appears in an error's stack
*
* The topmost plugin frame wins, so an error thrown by Zotero code that a plugin called
* is attributed to the plugin that called it.
*
* @param {Error|String} error - An error or a stack string
* @return {Promise<Object|false>} - { id, name }, or false if no plugin frame was found
*/
this.getPluginFromError = async function (error) {
try {
var stack = typeof error == 'string' ? error : error?.stack;
if (!stack) {
return false;
}
var { addons } = await AddonManager.getActiveAddons(["extension"]);
if (!addons?.length) {
return false;
}
var roots = addons.map(addon => [addon, _archiveOrDirURI(addon.getResourceURI().spec)]);
for (let line of stack.split('\n')) {
let uri = _stackFrameURI(line);
if (!uri) {
continue;
}
uri = _resolveChromeOrResourceURI(uri);
if (!uri) {
continue;
}
uri = _archiveOrDirURI(uri);
for (let [addon, root] of roots) {
if (root && uri.startsWith(root)) {
return { id: addon.id, name: addon.name };
}
}
}
return false;
}
catch (e) {
Zotero.logError(e);
return false;
}
};
/**
* Resolve a chrome:// or resource:// URI to the file or JAR URI it points at, since
* plugins can register packages and substitutions under both
*
* @param {String} uri
* @return {String} - The resolved URI, or an empty string if it can't be resolved
*/
function _resolveChromeOrResourceURI(uri) {
try {
if (uri.startsWith('chrome://')) {
return Cc["@mozilla.org/chrome/chrome-registry;1"]
.getService(Ci.nsIChromeRegistry)
.convertChromeURL(Services.io.newURI(uri))
.spec;
}
if (uri.startsWith('resource://')) {
return Services.io.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler)
.resolveURI(Services.io.newURI(uri));
}
}
catch {
return '';
}
return uri;
}
/**
* Reduce a URI to something comparable between plugin root URIs and stack frames:
* the containing archive for a jar: URI (which points inside an XPI), or the URI itself
* for a plugin installed unpacked
*
* @param {String} uri
* @return {String}
*/
function _archiveOrDirURI(uri) {
if (uri.startsWith('jar:')) {
let pos = uri.indexOf('!/');
return pos == -1 ? uri.substring(4) : uri.substring(4, pos);
}
return uri;
}
/**
* @param {String} line - A line from an error stack, "name@uri:line:column"
* @return {String} - The URI, without the line and column
*/
function _stackFrameURI(line) {
// A function name can't contain "@", while a URI can (plugin IDs are email-like)
return line.slice(line.indexOf('@') + 1).replace(/:\d+(?::\d+)?$/, '');
}
this.getAllPluginIDs = async function () {
let addons = await AddonManager.getAddonsByTypes(["extension"]);
return addons.map(addon => addon.id);

View file

@ -206,6 +206,8 @@ collections-menu-clear-all-last-read =
.label = Clear All Last Read Dates…
recently-read-clear-all-confirm = All Last Read dates in this library will be erased.
items-list-load-error-plugin = Error loading items list. Disabling the “{ $plugin }” plugin and restarting { -app-name } may fix this.
# Item list section headers for a multiple-row collection-tree selection within one library
items-section-collections-selected =
{ $count ->