Skip to content

Commit 39ed705

Browse files
piyalbasuclaude
andcommitted
fix(icons): serialize icon-cache writes, and stop a test stub outliving its test
Two more from PR review. The whole icon cache lives under one storage key, and every update reads it, changes an entry, and writes it back. Resolving icons concurrently means those updates overlap, and storage hands each handler its own deserialized copy — so two handlers read the same map, add different assets, and the later write discards the earlier one's icon, or undoes a clear. Updates are now chained so each works from the result of the last. The regression test needed care to be honest: a fake store that hands back the same object reference, or one that merely staggers reads with timers, both hide the race. It holds every read open until all handlers have read, and returns a copy per read, the way browser.storage does. The Account icon test stubbed the probe and never restored it. The suite's afterEach calls clearAllMocks, which drops call history but keeps the implementation, so the stub stood in for the real thing in every test after it and made the file order-dependent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fd9cd35 commit 39ed705

3 files changed

Lines changed: 140 additions & 14 deletions

File tree

extension/src/background/messageListener/handlers/__tests__/cacheAssetIcon.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,99 @@ describe("cacheAssetIcon", () => {
6565
});
6666
});
6767
});
68+
69+
describe("cacheAssetIcon concurrency", () => {
70+
/**
71+
* localStore whose reads are held open until released, so every handler is
72+
* guaranteed to have read before any of them writes — the interleaving that
73+
* concurrent icon resolution produces, made deterministic.
74+
*
75+
* Reads hand back a copy, as browser.storage.local does: one handler's
76+
* mutation is invisible to another that already read.
77+
*/
78+
const makeGatedLocalStore = (
79+
assetIconCache: Record<string, unknown> = {},
80+
) => {
81+
const store: Record<string, unknown> = {
82+
[CACHED_ASSET_ICONS_ID]: assetIconCache,
83+
};
84+
let release!: () => void;
85+
const gate = new Promise<void>((resolve) => {
86+
release = resolve;
87+
});
88+
89+
return {
90+
localStore: {
91+
getItem: jest.fn(async (key: string) => {
92+
await gate;
93+
return store[key] === undefined
94+
? undefined
95+
: JSON.parse(JSON.stringify(store[key]));
96+
}),
97+
setItem: jest.fn(async (key: string, value: unknown) => {
98+
store[key] = value;
99+
}),
100+
remove: jest.fn(),
101+
clear: jest.fn(),
102+
} as any,
103+
releaseReads: () => release(),
104+
read: () => store,
105+
};
106+
};
107+
108+
it("keeps every icon when writes overlap", async () => {
109+
// getAssetIcons resolves icons concurrently, so several CACHE_ASSET_ICON
110+
// messages are in flight at once. A read-modify-write of one shared object
111+
// loses all but the last unless the writes are serialized.
112+
const { localStore, releaseReads, read } = makeGatedLocalStore();
113+
114+
const writes = Promise.all(
115+
["A:G1", "B:G2", "C:G3"].map((assetCanonical) =>
116+
cacheAssetIcon({
117+
request: {
118+
assetCanonical,
119+
iconUrl: `https://example.com/${assetCanonical}.png`,
120+
} as CacheAssetIconMessage,
121+
localStore,
122+
}),
123+
),
124+
);
125+
releaseReads();
126+
await writes;
127+
128+
expect(read()[CACHED_ASSET_ICONS_ID]).toEqual({
129+
"A:G1": "https://example.com/A:G1.png",
130+
"B:G2": "https://example.com/B:G2.png",
131+
"C:G3": "https://example.com/C:G3.png",
132+
});
133+
});
134+
135+
it("does not let an overlapping write resurrect a cleared entry", async () => {
136+
const { localStore, releaseReads, read } = makeGatedLocalStore({
137+
[CANONICAL]: ICON_URL,
138+
});
139+
140+
const writes = Promise.all([
141+
cacheAssetIcon({
142+
request: {
143+
assetCanonical: CANONICAL,
144+
iconUrl: null,
145+
} as CacheAssetIconMessage,
146+
localStore,
147+
}),
148+
cacheAssetIcon({
149+
request: {
150+
assetCanonical: OTHER_CANONICAL,
151+
iconUrl: OTHER_ICON_URL,
152+
} as CacheAssetIconMessage,
153+
localStore,
154+
}),
155+
]);
156+
releaseReads();
157+
await writes;
158+
159+
expect(read()[CACHED_ASSET_ICONS_ID]).toEqual({
160+
[OTHER_CANONICAL]: OTHER_ICON_URL,
161+
});
162+
});
163+
});

extension/src/background/messageListener/handlers/cacheAssetIcon.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@ import { CacheAssetIconMessage } from "@shared/api/types/message-request";
22
import { DataStorageAccess } from "background/helpers/dataStorageAccess";
33
import { CACHED_ASSET_ICONS_ID } from "constants/localStorageTypes";
44

5+
/**
6+
* Serializes the icon cache's read-modify-write.
7+
*
8+
* The whole cache lives under one storage key, and every update reads it,
9+
* changes one entry, and writes it back. Icon resolution runs several lookups
10+
* concurrently, so these messages arrive overlapping — and storage hands each
11+
* handler its own deserialized copy, so without a queue two handlers read the
12+
* same map, add different assets, and the later write discards the earlier
13+
* one's icon (or undoes a clear). Chaining keeps each update working from the
14+
* result of the last.
15+
*
16+
* A failed update still lets the queue continue: the rejection is passed to
17+
* whoever asked for that write, not to the writes behind it.
18+
*/
19+
let pendingCacheWrite: Promise<void> = Promise.resolve();
20+
521
export const cacheAssetIcon = async ({
622
request,
723
localStore,
@@ -11,19 +27,25 @@ export const cacheAssetIcon = async ({
1127
}) => {
1228
const { assetCanonical, iconUrl } = request;
1329

14-
const assetIconCache =
15-
(await localStore.getItem(CACHED_ASSET_ICONS_ID)) || {};
30+
const applyUpdate = async () => {
31+
const assetIconCache =
32+
(await localStore.getItem(CACHED_ASSET_ICONS_ID)) || {};
33+
34+
if (iconUrl) {
35+
assetIconCache[assetCanonical] = iconUrl;
36+
} else {
37+
// A falsy iconUrl means "forget what we cached" (retryAssetIcon sends
38+
// null to drop a url that failed to load). Delete the entry rather than
39+
// storing the null: getAssetIcons treats a cached null as "already
40+
// tried, never look again", and this cache is persisted to disk — so a
41+
// single transient image failure would blacklist the asset's icon for
42+
// good.
43+
delete assetIconCache[assetCanonical];
44+
}
1645

17-
if (iconUrl) {
18-
assetIconCache[assetCanonical] = iconUrl;
19-
} else {
20-
// A falsy iconUrl means "forget what we cached" (retryAssetIcon sends null
21-
// to drop a url that failed to load). Delete the entry rather than storing
22-
// the null: getAssetIcons treats a cached null as "already tried, never
23-
// look again", and this cache is persisted to disk — so a single transient
24-
// image failure would blacklist the asset's icon for good.
25-
delete assetIconCache[assetCanonical];
26-
}
46+
await localStore.setItem(CACHED_ASSET_ICONS_ID, assetIconCache);
47+
};
2748

28-
await localStore.setItem(CACHED_ASSET_ICONS_ID, assetIconCache);
49+
pendingCacheWrite = pendingCacheWrite.then(applyUpdate, applyUpdate);
50+
return pendingCacheWrite;
2951
};

extension/src/popup/views/__tests__/Account.test.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,18 @@ jest.mock("popup/ducks/accountServices", () => {
285285
});
286286

287287
describe("Account view", () => {
288+
// Only the icon test stubs the probe. clearAllMocks wipes call history but
289+
// keeps the implementation, so without an explicit restore that stub would
290+
// outlive its test and quietly stand in for the real thing in every case
291+
// after it, making the suite order-dependent.
292+
let probeSpy: jest.SpyInstance | undefined;
293+
288294
afterAll(() => {
289295
jest.clearAllMocks();
290296
});
291297
afterEach(() => {
298+
probeSpy?.mockRestore();
299+
probeSpy = undefined;
292300
jest.useRealTimers();
293301
jest.clearAllMocks();
294302
});
@@ -504,7 +512,7 @@ describe("Account view", () => {
504512
// jsdom images never fire load or error, so stand in for the browser and
505513
// say the first candidate renders. This test is about which source an icon
506514
// comes from; iconProbe.test.ts covers the loading itself.
507-
jest
515+
probeSpy = jest
508516
.spyOn(IconProbe, "firstLoadableIconUrl")
509517
.mockImplementation(async (urls: string[]) => urls[0]);
510518
const getIconUrlFromIssuerSpy = jest

0 commit comments

Comments
 (0)