Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/assets-controllers/src/TokenListController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1414,6 +1414,7 @@ describe('TokenListController', () => {
chainId: ChainId.mainnet,
messenger: restrictedMessenger,
});
await controller1.initialize();

nock(tokenService.TOKEN_END_POINT_API)
.get(getTokensPath(ChainId.mainnet))
Expand Down Expand Up @@ -1451,6 +1452,7 @@ describe('TokenListController', () => {
chainId: ChainId.mainnet,
messenger: restrictedMessenger,
});
await controller.initialize();

await controller.fetchTokenList(ChainId.mainnet);

Expand All @@ -1473,6 +1475,102 @@ describe('TokenListController', () => {
controller.destroy();
});

it('should not save to StorageService before initialization', async () => {
const messenger = getMessenger();
const restrictedMessenger = getRestrictedMessenger(messenger);

// Create controller and fetch tokens
const controller1 = new TokenListController({
chainId: ChainId.mainnet,
messenger: restrictedMessenger,
});
// skip initialization

nock(tokenService.TOKEN_END_POINT_API)
.get(getTokensPath(ChainId.mainnet))
.reply(200, sampleMainnetTokenList);

await controller1.fetchTokenList(ChainId.mainnet);
expect(
controller1.state.tokensChainsCache[ChainId.mainnet],
).toBeDefined();

// Wait for debounced persistence to complete (500ms + buffer)
await new Promise((resolve) => setTimeout(resolve, 600));

controller1.destroy();

// Verify data is in StorageService (per-chain file)
const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`;
const { result } = await messenger.call(
'StorageService:getItem',
'TokenListController',
chainStorageKey,
);

expect(result).toBeUndefined();
});

it('should save data updated before initialization to StorageService', async () => {
// Setup stale mainnet data in storage
const validChainData: DataCache = {
data: sampleMainnetTokensChainsCache,
timestamp: 1,
};
mockStorage.set(
`TokenListController:tokensChainsCache:${ChainId.mainnet}`,
validChainData,
);
const messenger = getMessenger();
const restrictedMessenger = getRestrictedMessenger(messenger);

// Create controller with delayed initialization, and fetch tokens
const controller = new TokenListController({
chainId: ChainId.mainnet,
messenger: restrictedMessenger,
});

nock(tokenService.TOKEN_END_POINT_API)
.get(getTokensPath(ChainId.mainnet))
.reply(200, sampleMainnetTokenList);
nock(tokenService.TOKEN_END_POINT_API)
.get(getTokensPath(toHex(56)))
.reply(200, sampleBinanceTokenList);

await controller.fetchTokenList(ChainId.mainnet);
await controller.fetchTokenList(toHex(56));
const savedCache = controller.state.tokensChainsCache;
expect(savedCache[ChainId.mainnet]).toBeDefined();
expect(savedCache[toHex(56)]).toBeDefined();
await controller.initialize();

// Wait for debounced persistence to complete (500ms + buffer)
await new Promise((resolve) => setTimeout(resolve, 600));

controller.destroy();

// Verify data is in StorageService (per-chain file)
const mainnetStorageKey = `tokensChainsCache:${ChainId.mainnet}`;
const { result: mainnetResult } = await messenger.call(
'StorageService:getItem',
'TokenListController',
mainnetStorageKey,
);
const binanceStorageKey = `tokensChainsCache:${toHex(56)}`;
const { result: binanceResult } = await messenger.call(
'StorageService:getItem',
'TokenListController',
binanceStorageKey,
);

// Confirm fresh results overwrite stale
expect(mainnetResult).toBeDefined();
expect(mainnetResult).toStrictEqual(savedCache[ChainId.mainnet]);
// Confirm results not in storage previously are persisted
expect(binanceResult).toBeDefined();
expect(binanceResult).toStrictEqual(savedCache[toHex(56)]);
});

it('should handle errors when loading individual chain cache files', async () => {
// Pre-populate storage with two chains
const validChainData: DataCache = {
Expand Down
56 changes: 14 additions & 42 deletions packages/assets-controllers/src/TokenListController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,6 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
*/
readonly #changedChainsToPersist: Set<Hex> = new Set();

/**
* Tracks chains that were just loaded from storage and should skip
* the next persistence cycle. This prevents redundant writes where
* data loaded from storage would be immediately written back.
* Chains are removed from this set after being skipped once.
*/
readonly #chainsLoadedFromStorage: Set<Hex> = new Set();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for removing this! That was a workaround to avoid doing redundant writes 🙏


/**
* Previous tokensChainsCache for detecting which chains changed.
*/
Expand Down Expand Up @@ -211,13 +203,6 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
this.#chainId = chainId;
this.#abortController = new AbortController();

// Subscribe to state changes to automatically persist tokensChainsCache
this.messenger.subscribe(
'TokenListController:stateChange',
(newCache: TokensChainsCache) => this.#onCacheChanged(newCache),
(controllerState) => controllerState.tokensChainsCache,
);

if (onNetworkStateChange) {
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
Expand All @@ -244,6 +229,13 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
*/
async initialize(): Promise<void> {
await this.#synchronizeCacheWithStorage();

// Subscribe to state changes to automatically persist tokensChainsCache
this.messenger.subscribe(
'TokenListController:stateChange',
(newCache: TokensChainsCache) => this.#onCacheChanged(newCache),
(controllerState) => controllerState.tokensChainsCache,
);
}

/**
Expand All @@ -260,13 +252,7 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi

// Chain is new or timestamp changed (indicating data update)
if (!prevData || prevData.timestamp !== newData.timestamp) {
// Skip persistence for chains that were just loaded from storage
// (they don't need to be written back immediately)
if (this.#chainsLoadedFromStorage.has(chainId)) {
this.#chainsLoadedFromStorage.delete(chainId); // Clean up - future updates should persist
} else {
this.#changedChainsToPersist.add(chainId);
}
this.#changedChainsToPersist.add(chainId);
}
}

Expand Down Expand Up @@ -388,17 +374,14 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
}
});

// Chains in state _before loading persisted state_, from a recent update
const chainsInState = new Set(
Object.keys(this.state.tokensChainsCache) as Hex[],
);

// Merge loaded cache with existing state, preferring existing data
// (which may be fresher if fetched during initialization)
if (Object.keys(loadedCache).length > 0) {
// Track which chains we're actually loading from storage
// These will be skipped in the next #onCacheChanged to avoid redundant writes
for (const chainId of Object.keys(loadedCache) as Hex[]) {
if (!this.state.tokensChainsCache[chainId]) {
this.#chainsLoadedFromStorage.add(chainId);
}
}

this.update((state) => {
// Only load chains that don't already exist in state
// This prevents overwriting fresh API data with stale cached data
Expand All @@ -408,24 +391,14 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
}
}
});

// Note: The update() call above only triggers #onCacheChanged if chains
// were actually added to state. If initial state already contains chains
// from storage, the update() is a no-op and #onCacheChanged is never called.
}

// Persist chains that exist in state but were not loaded from storage.
// This handles the case where initial state contains chains that don't exist
// in storage yet (e.g., fresh data from API). Without this, those chains
// would be lost on the next app restart.
const loadedChainIds = new Set(Object.keys(loadedCache) as Hex[]);
const chainsInState = new Set(
Object.keys(this.state.tokensChainsCache) as Hex[],
);
for (const chainId of chainsInState) {
if (!loadedChainIds.has(chainId)) {
this.#changedChainsToPersist.add(chainId);
}
this.#changedChainsToPersist.add(chainId);
}

// Persist any chains that need to be saved
Expand Down Expand Up @@ -548,7 +521,6 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
this.#persistDebounceTimer = undefined;
}
this.#changedChainsToPersist.clear();
this.#chainsLoadedFromStorage.clear();
}

/**
Expand Down