From ee3b489bc7ade266657f53b5d2cfda2eab4f5aa6 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Mon, 4 May 2026 19:20:11 +1000 Subject: [PATCH 1/4] fix(core/xref-db): skip caching empty xref results When the xref API returns no result for a query, `cacheXrefData()` was storing an empty array in IDB. On subsequent loads, the cache hit prevented re-fetching, permanently suppressing results for terms that were temporarily missing from the API. Skip caching queries with no results so they are re-fetched next time. Closes #5256 --- src/core/xref-db.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/xref-db.js b/src/core/xref-db.js index 80e9e71ab8..3f486c5c6d 100644 --- a/src/core/xref-db.js +++ b/src/core/xref-db.js @@ -87,7 +87,8 @@ export async function cacheXrefData(queries, results) { const cache = await getIdbCache(); const tx = cache.transaction(STORE_NAME, "readwrite"); for (const query of queries) { - const result = results.get(query.id) ?? []; + const result = results.get(query.id); + if (!result?.length) continue; tx.objectStore(STORE_NAME).add({ query, result }); } await tx.done; From c0f7e8112061ce764ac967cc84bc4acf5cbb538f Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Mon, 4 May 2026 19:47:28 +1000 Subject: [PATCH 2/4] fix: also clean up pre-existing empty cache entries on read Address Copilot feedback: resolveXrefCache now uses a readwrite transaction that deletes empty-result entries it encounters, recovering from previously cached empty arrays. --- src/core/xref-db.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/core/xref-db.js b/src/core/xref-db.js index 3f486c5c6d..294faf8109 100644 --- a/src/core/xref-db.js +++ b/src/core/xref-db.js @@ -38,13 +38,19 @@ export async function resolveXrefCache(queries) { const requiredKeySet = new Set(queries.map(query => query.id)); try { const cache = await getIdbCache(); - let cursor = await cache.transaction(STORE_NAME).store.openCursor(); + const tx = cache.transaction(STORE_NAME, "readwrite"); + let cursor = await tx.store.openCursor(); while (cursor) { if (requiredKeySet.has(cursor.key)) { - cachedData.set(cursor.key, cursor.value.result); + if (cursor.value.result?.length) { + cachedData.set(cursor.key, cursor.value.result); + } else { + cursor.delete(); + } } cursor = await cursor.continue(); } + await tx.done; } catch (err) { console.error(err); } From 9bf26c4fa1b727f0ad5308d5c7aee2d9a1602722 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Mon, 4 May 2026 20:03:15 +1000 Subject: [PATCH 3/4] test(core/xref-db): add regression tests for empty result caching Two tests using real IDB: 1. Queries with empty results are not cached (write path) 2. Pre-existing empty cache entries are deleted on read (migration) Address Copilot feedback requesting test coverage. --- tests/spec/core/xref-spec.js | 71 +++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/spec/core/xref-spec.js b/tests/spec/core/xref-spec.js index faa0c40ac3..1a73b008e2 100644 --- a/tests/spec/core/xref-spec.js +++ b/tests/spec/core/xref-spec.js @@ -1,5 +1,10 @@ "use strict"; +import { + cacheXrefData, + clearXrefData, + resolveXrefCache, +} from "../../../src/core/xref-db.js"; import { errorFilters, flushIframes, @@ -7,7 +12,6 @@ import { makeRSDoc, makeStandardOps, } from "../SpecHelper.js"; -import { clearXrefData } from "../../../src/core/xref-db.js"; describe("Core — xref", () => { afterAll(flushIframes); @@ -1118,3 +1122,68 @@ describe("Core — xref", () => { ); }); }); + +describe("Core — xref-db caching", () => { + beforeEach(async () => { + await clearXrefData(); + localStorage.setItem("XREF:LAST_VERSION_CHECK", Date.now().toString()); + }); + + afterEach(async () => { + await clearXrefData(); + }); + + it("does not cache queries with empty results", async () => { + const queries = [ + { id: "found-term", term: "found", types: ["dfn"] }, + { id: "missing-term", term: "missing", types: ["dfn"] }, + ]; + const results = new Map(); + results.set("found-term", [{ uri: "#found", shortname: "spec" }]); + + await cacheXrefData(queries, results); + const cached = await resolveXrefCache(queries); + + expect(cached.has("found-term")).toBeTrue(); + expect(cached.has("missing-term")).toBeFalse(); + }); + + it("cleans up pre-existing empty cache entries on read", async () => { + const queries = [{ id: "stale-term", term: "stale", types: ["dfn"] }]; + + const { promise: dbReady, resolve, reject } = Promise.withResolvers(); + const req = indexedDB.open("xref", 2); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + const db = await dbReady; + + const tx = db.transaction("xrefs", "readwrite"); + tx.objectStore("xrefs").add({ query: queries[0], result: [] }); + const { promise: txDone, resolve: txResolve } = Promise.withResolvers(); + tx.oncomplete = txResolve; + await txDone; + db.close(); + + const cached = await resolveXrefCache(queries); + expect(cached.has("stale-term")).toBeFalse(); + + const { + promise: verifyDbReady, + resolve: resolveVerifyDb, + reject: rejectVerifyDb, + } = Promise.withResolvers(); + const req2 = indexedDB.open("xref", 2); + req2.onsuccess = () => resolveVerifyDb(req2.result); + req2.onerror = () => rejectVerifyDb(req2.error); + const verifyDb = await verifyDbReady; + + const verifyTx = verifyDb.transaction("xrefs", "readonly"); + const getReq = verifyTx.objectStore("xrefs").get("stale-term"); + const { promise: getReady, resolve: resolveGet } = Promise.withResolvers(); + getReq.onsuccess = () => resolveGet(getReq.result); + const remaining = await getReady; + verifyDb.close(); + + expect(remaining).toBeUndefined(); + }); +}); From f76c548e93a1f1c7a2947cb4d666911adaf9c51e Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sat, 9 May 2026 03:03:48 +1000 Subject: [PATCH 4/4] simplify: remove read-side cleanup per review feedback Per sidvishnoi: "Maybe these changes aren't really needed, if we skip storing these in the first place." The write-side fix (skip storing empty results) prevents the problem. Existing stale entries will be cleared by the version-based cache bust. No need for a readwrite transaction on the read path. --- src/core/xref-db.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/core/xref-db.js b/src/core/xref-db.js index 294faf8109..3f486c5c6d 100644 --- a/src/core/xref-db.js +++ b/src/core/xref-db.js @@ -38,19 +38,13 @@ export async function resolveXrefCache(queries) { const requiredKeySet = new Set(queries.map(query => query.id)); try { const cache = await getIdbCache(); - const tx = cache.transaction(STORE_NAME, "readwrite"); - let cursor = await tx.store.openCursor(); + let cursor = await cache.transaction(STORE_NAME).store.openCursor(); while (cursor) { if (requiredKeySet.has(cursor.key)) { - if (cursor.value.result?.length) { - cachedData.set(cursor.key, cursor.value.result); - } else { - cursor.delete(); - } + cachedData.set(cursor.key, cursor.value.result); } cursor = await cursor.continue(); } - await tx.done; } catch (err) { console.error(err); }