Skip to content

Commit 9332b6e

Browse files
committed
test: add test to ensure cache is loaded only once during multiple fetchTokenList calls
1 parent eec8869 commit 9332b6e

File tree

2 files changed

+151
-21
lines changed

2 files changed

+151
-21
lines changed

packages/assets-controllers/src/TokenListController.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,6 +2069,107 @@ describe('TokenListController', () => {
20692069
consoleErrorSpy.mockRestore();
20702070
controller.destroy();
20712071
});
2072+
2073+
it('should only load cache from storage once even when fetchTokenList is called multiple times', async () => {
2074+
// Pre-populate storage with cached data
2075+
const chainData: DataCache = {
2076+
data: sampleMainnetTokensChainsCache,
2077+
timestamp: Date.now(),
2078+
};
2079+
mockStorage.set(
2080+
`TokenListController:tokensChainsCache:${ChainId.mainnet}`,
2081+
chainData,
2082+
);
2083+
2084+
// Track how many times getItem is called
2085+
let getItemCallCount = 0;
2086+
let getAllKeysCallCount = 0;
2087+
2088+
const trackingMessenger = new Messenger({
2089+
namespace: MOCK_ANY_NAMESPACE,
2090+
});
2091+
2092+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2093+
(trackingMessenger as any).registerActionHandler(
2094+
'StorageService:getItem',
2095+
(controllerNamespace: string, key: string) => {
2096+
getItemCallCount += 1;
2097+
const storageKey = `${controllerNamespace}:${key}`;
2098+
const value = mockStorage.get(storageKey);
2099+
return value ? { result: value } : {};
2100+
},
2101+
);
2102+
2103+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2104+
(trackingMessenger as any).registerActionHandler(
2105+
'StorageService:setItem',
2106+
(controllerNamespace: string, key: string, value: unknown) => {
2107+
const storageKey = `${controllerNamespace}:${key}`;
2108+
mockStorage.set(storageKey, value);
2109+
},
2110+
);
2111+
2112+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2113+
(trackingMessenger as any).registerActionHandler(
2114+
'StorageService:removeItem',
2115+
(controllerNamespace: string, key: string) => {
2116+
const storageKey = `${controllerNamespace}:${key}`;
2117+
mockStorage.delete(storageKey);
2118+
},
2119+
);
2120+
2121+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2122+
(trackingMessenger as any).registerActionHandler(
2123+
'StorageService:getAllKeys',
2124+
(controllerNamespace: string) => {
2125+
getAllKeysCallCount += 1;
2126+
const keys: string[] = [];
2127+
const prefix = `${controllerNamespace}:`;
2128+
mockStorage.forEach((_value, key) => {
2129+
if (key.startsWith(prefix)) {
2130+
const keyWithoutNamespace = key.substring(prefix.length);
2131+
keys.push(keyWithoutNamespace);
2132+
}
2133+
});
2134+
return keys;
2135+
},
2136+
);
2137+
2138+
const restrictedMessenger = getRestrictedMessenger(trackingMessenger);
2139+
2140+
const controller = new TokenListController({
2141+
chainId: ChainId.mainnet,
2142+
messenger: restrictedMessenger,
2143+
});
2144+
2145+
// Wait for initialization to complete
2146+
await new Promise((resolve) => setTimeout(resolve, 100));
2147+
2148+
// Record call counts after initialization
2149+
const getItemCallsAfterInit = getItemCallCount;
2150+
const getAllKeysCallsAfterInit = getAllKeysCallCount;
2151+
2152+
// getAllKeys should be called twice during init (once for load, once for migration check)
2153+
expect(getAllKeysCallsAfterInit).toBe(2);
2154+
// getItem should be called once for the cached chain during load
2155+
expect(getItemCallsAfterInit).toBe(1);
2156+
2157+
// Now call fetchTokenList multiple times
2158+
nock(tokenService.TOKEN_END_POINT_API)
2159+
.get(getTokensPath(ChainId.mainnet))
2160+
.reply(200, sampleMainnetTokenList)
2161+
.persist();
2162+
2163+
await controller.fetchTokenList(ChainId.mainnet);
2164+
await controller.fetchTokenList(ChainId.mainnet);
2165+
await controller.fetchTokenList(ChainId.mainnet);
2166+
2167+
// Verify getAllKeys was NOT called again after initialization
2168+
// (getItem may be called for other reasons, but getAllKeys is only used in load/migrate)
2169+
expect(getAllKeysCallCount).toBe(getAllKeysCallsAfterInit);
2170+
2171+
controller.destroy();
2172+
});
20722173
});
20732174

20742175
describe('deprecated methods', () => {

packages/assets-controllers/src/TokenListController.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
123123
> {
124124
readonly #mutex = new Mutex();
125125

126+
/**
127+
* Promise that resolves when initialization (loading cache from storage) is complete.
128+
* Methods that access the cache should await this before proceeding.
129+
*/
130+
readonly #initializationPromise: Promise<void>;
131+
126132
// Storage key prefix for per-chain files
127133
static readonly #storageKeyPrefix = 'tokensChainsCache';
128134

@@ -191,18 +197,9 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
191197
this.updatePreventPollingOnNetworkRestart(preventPollingOnNetworkRestart);
192198
this.#abortController = new AbortController();
193199

194-
// Load cache from StorageService on initialization and handle migration
195-
this.#loadCacheFromStorage()
196-
.then(() => {
197-
// Migrate existing cache from state to StorageService if needed
198-
return this.#migrateStateToStorage();
199-
})
200-
.catch((error) => {
201-
console.error(
202-
'TokenListController: Failed to load cache from storage:',
203-
error,
204-
);
205-
});
200+
// Load cache from StorageService on initialization and handle migration.
201+
// Store the promise so other methods can await it to avoid race conditions.
202+
this.#initializationPromise = this.#initializeFromStorage();
206203

207204
if (onNetworkStateChange) {
208205
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
@@ -222,11 +219,36 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
222219
}
223220
}
224221

222+
/**
223+
* Initialize the controller by loading cache from storage and running migration.
224+
* This method acquires the mutex to prevent race conditions with fetchTokenList.
225+
*
226+
* @returns A promise that resolves when initialization is complete.
227+
*/
228+
async #initializeFromStorage(): Promise<void> {
229+
const releaseLock = await this.#mutex.acquire();
230+
try {
231+
await this.#loadCacheFromStorage();
232+
await this.#migrateStateToStorage();
233+
} catch (error) {
234+
console.error(
235+
'TokenListController: Failed to initialize from storage:',
236+
error,
237+
);
238+
} finally {
239+
releaseLock();
240+
}
241+
}
242+
225243
/**
226244
* Load tokensChainsCache from StorageService into state.
227245
* Loads all cached chains from separate per-chain files in parallel.
228246
* Called during initialization to restore cached data.
229247
*
248+
* Note: This method merges loaded data with existing state to avoid
249+
* overwriting any fresh data that may have been fetched concurrently.
250+
* Caller must hold the mutex.
251+
*
230252
* @returns A promise that resolves when loading is complete.
231253
*/
232254
async #loadCacheFromStorage(): Promise<void> {
@@ -278,10 +300,17 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
278300
}
279301
});
280302

281-
// Load into state (all chains available for TokenDetectionController)
303+
// Merge loaded cache with existing state, preferring existing data
304+
// (which may be fresher if fetched during initialization)
282305
if (Object.keys(loadedCache).length > 0) {
283306
this.update((state) => {
284-
state.tokensChainsCache = loadedCache;
307+
// Only load chains that don't already exist in state
308+
// This prevents overwriting fresh API data with stale cached data
309+
for (const [chainId, cacheData] of Object.entries(loadedCache)) {
310+
if (!state.tokensChainsCache[chainId as Hex]) {
311+
state.tokensChainsCache[chainId as Hex] = cacheData;
312+
}
313+
}
285314
});
286315
}
287316
} catch (error) {
@@ -497,6 +526,10 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
497526
* @param chainId - The chainId of the current chain triggering the fetch.
498527
*/
499528
async fetchTokenList(chainId: Hex): Promise<void> {
529+
// Wait for initialization to complete before fetching
530+
// This ensures we have loaded any cached data from storage first
531+
await this.#initializationPromise;
532+
500533
const releaseLock = await this.#mutex.acquire();
501534
try {
502535
if (this.isCacheValid(chainId)) {
@@ -571,6 +604,9 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
571604
* This clears both state and all per-chain files in StorageService.
572605
*/
573606
async clearingTokenListData(): Promise<void> {
607+
// Wait for initialization to complete before clearing
608+
await this.#initializationPromise;
609+
574610
// Clear state
575611
this.update((state) => {
576612
state.tokensChainsCache = {};
@@ -593,13 +629,6 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
593629
this.messenger.call('StorageService:removeItem', name, key),
594630
),
595631
);
596-
597-
// Also remove old single-file storage if it exists (cleanup)
598-
await this.messenger.call(
599-
'StorageService:removeItem',
600-
name,
601-
TokenListController.#storageKeyPrefix,
602-
);
603632
} catch (error) {
604633
console.error(
605634
'TokenListController: Failed to clear cache from storage:',

0 commit comments

Comments
 (0)