feat: cache merged home page data for instant startup display#10070
feat: cache merged home page data for instant startup display#10070huhuanming wants to merge 3 commits intoxfrom
Conversation
Cache the final merged token list and balance on every successful load, so the next cold start can show data immediately without skeleton/loading state. Uses a new SimpleDB entity (homePageData) for single-read access instead of N per-network reads + merge.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
Pull request was converted to draft
| void (async () => { | ||
| const cache = await backgroundApiProxy.serviceToken.getHomePageCache({ | ||
| accountId: account.id, | ||
| networkId: network.id, | ||
| }); | ||
| if (cache) { | ||
| updateAccountWorth({ | ||
| accountId: cache.accountWorth.accountId, | ||
| worth: cache.accountWorth.worth, | ||
| createAtNetworkWorth: cache.accountWorth.createAtNetworkWorth, | ||
| initialized: true, | ||
| }); | ||
| } else { | ||
| updateAccountWorth({ | ||
| accountId: account.id, | ||
| worth: {}, | ||
| initialized: false, | ||
| }); | ||
| } | ||
| })(); |
There was a problem hiding this comment.
🟡 Race condition: unmanaged async cache fetch can apply stale account data after account switch
The useEffect at lines 98-117 fires an async IIFE (void (async () => {...})()) to fetch the home page cache, but provides no cleanup or cancellation mechanism. When the user switches accounts, the previous async getHomePageCache call may resolve after the new effect has already set state for the new account.
Root Cause & Scenario
- User is on Account A. Effect fires, starts
getHomePageCache({accountId: A, ...}). - User switches to Account B. Effect fires again, starts
getHomePageCache({accountId: B, ...}). - If B has no cache → synchronous path immediately sets
updateAccountWorth({accountId: B, worth: {}, initialized: false}). - A's cache resolves after B's → sets
updateAccountWorth({accountId: A, worth: {A's data}, initialized: true}). - Now the active account is B, but
accountWorthholds A's data withinitialized: true.
The display code at HomeOverviewContainer.tsx:206-208 checks account.id === accountWorth.accountId, so A's balance won't render for B. However, accountWorth.initialized is now true, which may prevent the skeleton from appearing — the user sees $0 or empty state for Account B until real data arrives, exactly the "$0 flash" this PR aimed to prevent.
A standard fix is to use a boolean ref (let cancelled = false) checked before calling updateAccountWorth, with the cleanup function setting cancelled = true.
Impact: On fast account switching, the cached worth from the previous account can corrupt the initialization state of the new account, causing a brief incorrect display.
| void (async () => { | |
| const cache = await backgroundApiProxy.serviceToken.getHomePageCache({ | |
| accountId: account.id, | |
| networkId: network.id, | |
| }); | |
| if (cache) { | |
| updateAccountWorth({ | |
| accountId: cache.accountWorth.accountId, | |
| worth: cache.accountWorth.worth, | |
| createAtNetworkWorth: cache.accountWorth.createAtNetworkWorth, | |
| initialized: true, | |
| }); | |
| } else { | |
| updateAccountWorth({ | |
| accountId: account.id, | |
| worth: {}, | |
| initialized: false, | |
| }); | |
| } | |
| })(); | |
| void (async () => { | |
| let cancelled = false; | |
| cleanupRef.current = () => { cancelled = true; }; | |
| const cache = await backgroundApiProxy.serviceToken.getHomePageCache({ | |
| accountId: account.id, | |
| networkId: network.id, | |
| }); | |
| if (cancelled) return; | |
| if (cache) { | |
| updateAccountWorth({ | |
| accountId: cache.accountWorth.accountId, | |
| worth: cache.accountWorth.worth, | |
| createAtNetworkWorth: cache.accountWorth.createAtNetworkWorth, | |
| initialized: true, | |
| }); | |
| } else { | |
| updateAccountWorth({ | |
| accountId: account.id, | |
| worth: {}, | |
| initialized: false, | |
| }); | |
| } | |
| })(); | |
Was this helpful? React with 👍 or 👎 to provide feedback.
| _saveHomePageCacheDebounced = debounce( | ||
| async ({ | ||
| accountId, | ||
| networkId, | ||
| data, | ||
| }: { | ||
| accountId: string; | ||
| networkId: string; | ||
| data: IHomePageCacheItem; | ||
| }) => { | ||
| await this.backgroundApi.simpleDb.homePageData.setCache({ | ||
| accountId, | ||
| networkId, | ||
| data, | ||
| }); | ||
| }, | ||
| 3000, | ||
| { | ||
| leading: false, | ||
| trailing: true, | ||
| }, | ||
| ); |
There was a problem hiding this comment.
🟡 Shared debounce drops cache saves for rapidly-switched accounts
The _saveHomePageCacheDebounced function in ServiceToken.ts is a single lodash debounce instance shared across all accounts. When saveHomePageCache is called for Account A, and then called again for Account B within the 3-second debounce window, lodash replaces the pending arguments entirely — only Account B's data is persisted, and Account A's cache update is silently dropped.
Root Cause & Scenario
Lodash debounce with trailing: true keeps only the most recent invocation's arguments. The flow:
handleAllNetworkCacheDatafires for Account A → callssaveHomePageCache({accountId: A, networkId: 'all', data: dataA})- User switches to Account B within 3 seconds
handleAllNetworkCacheDatafires for Account B → callssaveHomePageCache({accountId: B, networkId: 'all', data: dataB})- After 3s, only
{accountId: B, ...}fires on the trailing edge - Account A's cache is never written
On next cold start for Account A, either stale or no cached data is displayed, forcing the user through the loading skeleton — defeating the purpose of this caching feature for that account.
Impact: Accounts that are quickly switched away from never get their home page cache updated. The instant startup optimization is unreliable for non-primary accounts.
Prompt for agents
Replace the single shared debounce with a per-key debounce approach. Instead of one _saveHomePageCacheDebounced function, maintain a Map<string, DebouncedFunction> keyed by accountId_networkId. In saveHomePageCache, look up or create a debounced function for the specific key, ensuring each account+network pair gets its own independent debounce timer. This ensures that switching accounts does not drop the pending save for the previous account. Remember to clean up old debounced functions if the map grows too large (e.g., evict entries that haven't been used recently).
Was this helpful? React with 👍 or 👎 to provide feedback.
- Create homeHeader context (atoms + actions) for pre-computed balance display - Add homeTokenListDataAtom to tokenList context for flat data mapping - Add buildHomePageData/saveHomePageCacheV2 to ServiceToken - Create useHomePageDataController hook for cache orchestration - Remove all V1 cache code (IHomePageCacheItem, getCache, setCache, saveHomePageCache) - Fix balance showing '0' by showing skeleton until stores are initialized - HomeOverviewContainer reads from homeHeaderDataAtom with legacy fallback
Summary
SimpleDbEntityHomePageDataentity to cache the final merged home page state (balance + token list) per account+networkChanges
packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityHomePageData.ts— SimpleDB entity for home page cacheSimpleDb.ts/SimpleDbProxy.ts— Register new entityServiceToken.ts— AddgetHomePageCache/saveHomePageCachemethodsTokenListBlock.tsx— Load cache on init to skip skeleton; save cache after data updatesHomeOverviewContainer.tsx— Load cached accountWorth to avoid $0 flashTest plan