@pancakeswap/token-lists 0.0.17 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +13 -13
- package/CHANGELOG.md +19 -0
- package/dist/{chunk-DC45HQOL.mjs → chunk-UUVLERWJ.mjs} +1 -60
- package/dist/{chunk-YOAEDKQH.js → chunk-Y2NTTLTJ.js} +0 -62
- package/dist/index.d.ts +35 -3
- package/dist/index.js +74 -15
- package/dist/index.mjs +72 -1
- package/dist/react.d.ts +51 -10
- package/dist/react.js +325 -92
- package/dist/react.mjs +311 -85
- package/dist/{types-92a8b029.d.ts → types-dcc7ec4a.d.ts} +1 -0
- package/package.json +4 -3
- package/react/actions.ts +11 -0
- package/react/geoBlock.test.ts +68 -0
- package/react/getTokenList.test.ts +54 -0
- package/react/getTokenList.ts +2 -3
- package/react/index.test.ts +6 -0
- package/react/lists.ts +307 -27
- package/react/reducer.ts +34 -53
- package/react/reducerHelpers.ts +111 -0
- package/react/useFetchListCallback.ts +1 -1
- package/schema/pancakeswap.json +18 -9
- package/src/index.test.ts +1 -0
- package/src/index.ts +1 -0
- package/src/scaledUIAmount.ts +41 -0
- package/src/types.ts +1 -0
- package/src/wrappedTokenInfo.ts +8 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { getTokenList } from './getTokenList'
|
|
3
|
+
|
|
4
|
+
const validList = {
|
|
5
|
+
name: 'Test List',
|
|
6
|
+
timestamp: '2026-04-29T00:00:00.000Z',
|
|
7
|
+
version: {
|
|
8
|
+
major: 1,
|
|
9
|
+
minor: 0,
|
|
10
|
+
patch: 0,
|
|
11
|
+
},
|
|
12
|
+
tokens: [
|
|
13
|
+
{
|
|
14
|
+
chainId: 56,
|
|
15
|
+
address: '0x0000000000000000000000000000000000000001',
|
|
16
|
+
decimals: 18,
|
|
17
|
+
name: 'Valid Token',
|
|
18
|
+
symbol: 'VALID',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('getTokenList', () => {
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('filters invalid tokens without warning when the list is recoverable', async () => {
|
|
29
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
30
|
+
ok: true,
|
|
31
|
+
json: async () => ({
|
|
32
|
+
...validList,
|
|
33
|
+
tokens: [
|
|
34
|
+
...validList.tokens,
|
|
35
|
+
{
|
|
36
|
+
chainId: 56,
|
|
37
|
+
address: '0x0000000000000000000000000000000000000002',
|
|
38
|
+
decimals: 18,
|
|
39
|
+
name: 'Broken Token',
|
|
40
|
+
symbol: 'BROKEN🚨',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
}),
|
|
44
|
+
} as Response)
|
|
45
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
46
|
+
|
|
47
|
+
const list = await getTokenList('https://tokens.example.com/list.json')
|
|
48
|
+
|
|
49
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
50
|
+
expect(list?.tokens).toHaveLength(1)
|
|
51
|
+
expect(list?.tokens[0]?.symbol).toBe('VALID')
|
|
52
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
})
|
package/react/getTokenList.ts
CHANGED
|
@@ -17,19 +17,18 @@ export async function getTokenList(listUrl: string): Promise<TokenList | undefin
|
|
|
17
17
|
try {
|
|
18
18
|
const json = await fetchJson(url)
|
|
19
19
|
if (!validator(json)) {
|
|
20
|
-
const preFilterErrors = validator.errors
|
|
21
20
|
json.tokens = json.tokens.filter((token: any) => validator({ ...json, tokens: [token] }))
|
|
22
21
|
if (!validator(json)) {
|
|
23
|
-
const errors = validator
|
|
22
|
+
const { errors } = validator
|
|
24
23
|
throw new Error(`Validation failed after filtering: ${JSON.stringify(errors)}`)
|
|
25
24
|
}
|
|
26
|
-
console.warn(`Pre-filter validation errors: ${JSON.stringify(preFilterErrors)}`)
|
|
27
25
|
}
|
|
28
26
|
return json as TokenList
|
|
29
27
|
} catch (error) {
|
|
30
28
|
// if (i === urls.length - 1) {
|
|
31
29
|
// throw new Error(`Failed to download list ${listUrl}`)
|
|
32
30
|
// }
|
|
31
|
+
console.warn(`Failed to download or validate list from ${url}:`, error)
|
|
33
32
|
|
|
34
33
|
return undefined
|
|
35
34
|
}
|
package/react/index.test.ts
CHANGED
|
@@ -12,7 +12,13 @@ test('exports', () => {
|
|
|
12
12
|
"acceptListUpdate",
|
|
13
13
|
"rejectVersionUpdate",
|
|
14
14
|
"updateListVersion",
|
|
15
|
+
"batchFetchTokenListPending",
|
|
16
|
+
"batchFetchTokenListFulfilled",
|
|
17
|
+
"batchFetchTokenListRejected",
|
|
15
18
|
"getTokenList",
|
|
19
|
+
"getGeoBlockTokenKey",
|
|
20
|
+
"getGeoBlockedTokenKeys",
|
|
21
|
+
"isGeoBlockedToken",
|
|
16
22
|
"findTokenByAddress",
|
|
17
23
|
"findTokenBySymbol",
|
|
18
24
|
"createListsAtom",
|
package/react/lists.ts
CHANGED
|
@@ -3,10 +3,85 @@ import { atom, useAtom, useAtomValue } from 'jotai'
|
|
|
3
3
|
import { atomFamily, atomWithStorage, loadable } from 'jotai/utils'
|
|
4
4
|
import { type AsyncStorage } from 'jotai/vanilla/utils/atomWithStorage'
|
|
5
5
|
import localForage from 'localforage'
|
|
6
|
-
import
|
|
6
|
+
import debounce from 'lodash/debounce'
|
|
7
|
+
import {
|
|
8
|
+
fetchTokenList,
|
|
9
|
+
batchFetchTokenListPending,
|
|
10
|
+
batchFetchTokenListFulfilled,
|
|
11
|
+
batchFetchTokenListRejected,
|
|
12
|
+
} from './actions'
|
|
7
13
|
import { getTokenList } from './getTokenList'
|
|
8
14
|
import { ListsState } from './reducer'
|
|
9
15
|
|
|
16
|
+
export type GeoBlockList = {
|
|
17
|
+
rules?: Record<string, string[]>
|
|
18
|
+
tokens?: Record<string, string[]>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CreateListsAtomOptions = {
|
|
22
|
+
geoBlockListUrl?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Persisted payload shape. `activeListUrls` was added later - existing IndexedDB
|
|
26
|
+
// entries only have the flat `{ [url]: TokenList }` shape and must still be read.
|
|
27
|
+
type StoredListsPayload = {
|
|
28
|
+
byUrl: Record<string, any>
|
|
29
|
+
activeListUrls?: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type TokenKeyLike = {
|
|
33
|
+
chainId: number
|
|
34
|
+
address: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeTokenAddress(address: string) {
|
|
38
|
+
return address.startsWith('0x') ? address.toLowerCase() : address
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getGeoBlockTokenKey(token: TokenKeyLike) {
|
|
42
|
+
return `${token.chainId}:${normalizeTokenAddress(token.address)}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeTokenKey(key: string) {
|
|
46
|
+
const separatorIndex = key.indexOf(':')
|
|
47
|
+
if (separatorIndex < 0) return key
|
|
48
|
+
const chainId = key.slice(0, separatorIndex)
|
|
49
|
+
const address = key.slice(separatorIndex + 1)
|
|
50
|
+
return `${chainId}:${normalizeTokenAddress(address)}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getGeoBlockedTokenKeys(geoBlockList?: GeoBlockList, countryCode?: string) {
|
|
54
|
+
const blockedTokenKeys = new Set<string>()
|
|
55
|
+
|
|
56
|
+
if (!countryCode || !geoBlockList?.rules || !geoBlockList.tokens) {
|
|
57
|
+
return blockedTokenKeys
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const country = countryCode.toUpperCase()
|
|
61
|
+
const blockedRuleIds = new Set(
|
|
62
|
+
Object.entries(geoBlockList.rules)
|
|
63
|
+
.filter(([, countries]) => countries.includes(country))
|
|
64
|
+
.map(([ruleId]) => ruleId),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
Object.entries(geoBlockList.tokens).forEach(([tokenKey, ruleIds]) => {
|
|
68
|
+
if (ruleIds.some((ruleId) => blockedRuleIds.has(ruleId))) {
|
|
69
|
+
blockedTokenKeys.add(normalizeTokenKey(tokenKey))
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return blockedTokenKeys
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isGeoBlockedToken(token: TokenKeyLike, geoBlockList?: GeoBlockList, countryCode?: string) {
|
|
77
|
+
return getGeoBlockedTokenKeys(geoBlockList, countryCode).has(getGeoBlockTokenKey(token))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function fetchJson(url: string): Promise<any> {
|
|
81
|
+
const res = await fetch(url)
|
|
82
|
+
if (!res.ok) throw new Error(`Failed to fetch: ${url}`)
|
|
83
|
+
return res.json()
|
|
84
|
+
}
|
|
10
85
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
11
86
|
function noop() {}
|
|
12
87
|
|
|
@@ -39,9 +114,14 @@ export function findTokenBySymbol(state: ListsState, chainId: number, symbol: st
|
|
|
39
114
|
return undefined
|
|
40
115
|
}
|
|
41
116
|
|
|
42
|
-
export const createListsAtom = (
|
|
117
|
+
export const createListsAtom = (
|
|
118
|
+
storeName: string,
|
|
119
|
+
reducer: any,
|
|
120
|
+
initialState: any,
|
|
121
|
+
options: CreateListsAtomOptions = {},
|
|
122
|
+
) => {
|
|
43
123
|
/**
|
|
44
|
-
* Persist
|
|
124
|
+
* Persist only token lists using IndexedDB - optimized storage format
|
|
45
125
|
* @param {string} dbName - IndexedDB database name
|
|
46
126
|
*/
|
|
47
127
|
function IndexedDBStorage<Value>(dbName: string): AsyncStorage<Value> {
|
|
@@ -50,17 +130,27 @@ export const createListsAtom = (storeName: string, reducer: any, initialState: a
|
|
|
50
130
|
name: dbName,
|
|
51
131
|
storeName,
|
|
52
132
|
})
|
|
133
|
+
const mem = new Map<string, any>()
|
|
134
|
+
|
|
135
|
+
const debouncedSetItem = debounce(async (k: string, v: any) => {
|
|
136
|
+
db.setItem(k, v)
|
|
137
|
+
}, 300)
|
|
138
|
+
|
|
53
139
|
return {
|
|
54
140
|
getItem: async (key: string) => {
|
|
141
|
+
if (mem.has(key)) {
|
|
142
|
+
return mem.get(key)
|
|
143
|
+
}
|
|
55
144
|
const value = await db.getItem(key)
|
|
56
145
|
if (value) {
|
|
57
146
|
return value
|
|
58
147
|
}
|
|
59
|
-
return
|
|
148
|
+
return undefined as any
|
|
60
149
|
},
|
|
61
150
|
setItem: async (k: string, v: any) => {
|
|
62
151
|
if (v === EMPTY) return
|
|
63
|
-
|
|
152
|
+
mem.set(k, v)
|
|
153
|
+
debouncedSetItem(k, v)
|
|
64
154
|
},
|
|
65
155
|
removeItem: db.removeItem,
|
|
66
156
|
}
|
|
@@ -68,63 +158,253 @@ export const createListsAtom = (storeName: string, reducer: any, initialState: a
|
|
|
68
158
|
return noopStorage
|
|
69
159
|
}
|
|
70
160
|
|
|
71
|
-
|
|
161
|
+
// Storage for token lists and the user's enabled/disabled list selection.
|
|
162
|
+
// `getOnInit: true` starts the IndexedDB read as soon as this atom is created (module load)
|
|
163
|
+
// instead of waiting for the first component subscribe - by the time a consumer like the
|
|
164
|
+
// Manage Tokens modal actually mounts, the read has usually already resolved, avoiding a
|
|
165
|
+
// visible flash of the (all-enabled) default state before the persisted selection applies.
|
|
166
|
+
const tokenListsStorageAtom = atomWithStorage<StoredListsPayload | typeof EMPTY>(
|
|
167
|
+
'tokenLists',
|
|
168
|
+
EMPTY,
|
|
169
|
+
IndexedDBStorage('tokenLists'),
|
|
170
|
+
{ getOnInit: true },
|
|
171
|
+
)
|
|
72
172
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return value.data
|
|
78
|
-
}
|
|
79
|
-
return initialState
|
|
80
|
-
},
|
|
81
|
-
async (get, set, action) => {
|
|
82
|
-
set(listsStorageAtom, reducer(await get(defaultStateAtom), action))
|
|
83
|
-
},
|
|
173
|
+
const geoBlockListStorageAtom = atomWithStorage<GeoBlockList | typeof EMPTY>(
|
|
174
|
+
'geoBlockList',
|
|
175
|
+
EMPTY,
|
|
176
|
+
IndexedDBStorage('geoBlockList'),
|
|
84
177
|
)
|
|
85
178
|
|
|
86
|
-
const
|
|
179
|
+
const geoBlockListAtom = atom<GeoBlockList | undefined>((get) => {
|
|
180
|
+
const value = get(loadable(geoBlockListStorageAtom))
|
|
181
|
+
if (value.state === 'hasData' && value.data && value.data !== EMPTY) {
|
|
182
|
+
return value.data as GeoBlockList
|
|
183
|
+
}
|
|
184
|
+
return undefined
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const fetchGeoBlockListAtom = atom<null, [], Promise<void>>(null, async (_get, set) => {
|
|
188
|
+
if (!options.geoBlockListUrl) {
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
set(geoBlockListStorageAtom, await fetchJson(options.geoBlockListUrl))
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.warn(`Failed to download geo block list from ${options.geoBlockListUrl}:`, error)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// Memory state atom that holds the full ListsState
|
|
200
|
+
const memoryStateAtom = atom<ListsState>(initialState as ListsState)
|
|
201
|
+
|
|
202
|
+
// Merges a resolved storage payload's token list content and active-list selection onto a
|
|
203
|
+
// base ListsState. Shared by `listStateAtom` (read-time overlay, for display before the first
|
|
204
|
+
// dispatch has hydrated memory) and `updateListStateAtom` (one-time hydration into memory, so
|
|
205
|
+
// reducer calls - e.g. "this list's content was already fetched" - see accurate `byUrl.current`
|
|
206
|
+
// and don't treat an already-cached-but-disabled list as newly discovered and re-activate it).
|
|
207
|
+
const mergeStoredData = (baseState: ListsState, storedData: StoredListsPayload | Record<string, any>) => {
|
|
208
|
+
// Backward compat: pre-existing IndexedDB entries are a flat { [url]: TokenList } map with
|
|
209
|
+
// no `byUrl`/`activeListUrls` wrapper.
|
|
210
|
+
const storedTokenLists: Record<string, any> = storedData.byUrl ?? storedData
|
|
211
|
+
const storedActiveListUrls = Array.isArray((storedData as StoredListsPayload).activeListUrls)
|
|
212
|
+
? (storedData as StoredListsPayload).activeListUrls
|
|
213
|
+
: undefined
|
|
214
|
+
|
|
215
|
+
const mergedState = { ...baseState }
|
|
216
|
+
|
|
217
|
+
const updatedByUrl = { ...mergedState.byUrl }
|
|
218
|
+
Object.keys(storedTokenLists).forEach((url) => {
|
|
219
|
+
if (storedTokenLists[url]) {
|
|
220
|
+
updatedByUrl[url] = {
|
|
221
|
+
...updatedByUrl[url],
|
|
222
|
+
current: storedTokenLists[url],
|
|
223
|
+
// Keep existing memory state for loading, error, pendingUpdate
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
mergedState.byUrl = updatedByUrl
|
|
228
|
+
|
|
229
|
+
if (storedActiveListUrls) {
|
|
230
|
+
mergedState.activeListUrls = storedActiveListUrls
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return mergedState
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const listStateAtom = atom<ListsState>((get) => {
|
|
237
|
+
// Separate this(mem/storage) is a prepare for refactor of the list part
|
|
238
|
+
const memoryState = get(memoryStateAtom)
|
|
239
|
+
const value = get(loadable(tokenListsStorageAtom))
|
|
240
|
+
|
|
241
|
+
if (value.state === 'hasData' && value.data && value.data !== EMPTY) {
|
|
242
|
+
return mergeStoredData(memoryState, value.data as StoredListsPayload | Record<string, any>)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return memoryState
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// Set once the persisted storage read has resolved (whether it held data or was genuinely
|
|
249
|
+
// empty) and been merged into `memoryStateAtom` (both `byUrl.current` and `activeListUrls`).
|
|
250
|
+
// Two things depend on this:
|
|
251
|
+
// 1. Every reducer call after hydration bases itself on `memoryStateAtom` alone - a single,
|
|
252
|
+
// synchronously-consistent source of truth - instead of re-reading the async, loadable
|
|
253
|
+
// wrapped storage atom on every dispatch, which could otherwise let one action's write race
|
|
254
|
+
// another action's read of a not-yet-updated storage snapshot.
|
|
255
|
+
// 2. Until hydration completes, writes are NOT persisted (see below) - otherwise the very first
|
|
256
|
+
// action dispatched on boot (typically `updateListVersion`, fired as soon as the app
|
|
257
|
+
// considers storage "ready") can itself run before the storage read has resolved, compute a
|
|
258
|
+
// default-only state, and persist it - permanently clobbering the real persisted value the
|
|
259
|
+
// read just hadn't returned yet. Hydrating `byUrl.current` too (not just `activeListUrls`)
|
|
260
|
+
// matters because `setFulfilledTokenList` treats a null `current` as "list never loaded
|
|
261
|
+
// before" and auto re-activates it if it's in the default set - without this, refetching an
|
|
262
|
+
// already-cached-but-user-disabled list on boot would silently re-enable it.
|
|
263
|
+
let hasHydratedActiveListUrls = false
|
|
264
|
+
|
|
265
|
+
const updateListStateAtom = atom<null, any, void>(null, async (get, set, action) => {
|
|
266
|
+
const baseState = get(memoryStateAtom)
|
|
267
|
+
|
|
268
|
+
if (!hasHydratedActiveListUrls) {
|
|
269
|
+
const storageValue = get(loadable(tokenListsStorageAtom))
|
|
270
|
+
|
|
271
|
+
if (storageValue.state !== 'hasData') {
|
|
272
|
+
// Storage hasn't resolved yet - update memory optimistically so the UI stays responsive,
|
|
273
|
+
// but skip persisting until we know what (if anything) was already stored.
|
|
274
|
+
const optimisticState = reducer(baseState, action)
|
|
275
|
+
set(memoryStateAtom, { ...optimisticState })
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (storageValue.data && storageValue.data !== EMPTY) {
|
|
280
|
+
set(memoryStateAtom, mergeStoredData(baseState, storageValue.data as StoredListsPayload | Record<string, any>))
|
|
281
|
+
}
|
|
282
|
+
hasHydratedActiveListUrls = true
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const currentState = get(memoryStateAtom)
|
|
286
|
+
const newState = reducer(currentState, action)
|
|
287
|
+
|
|
288
|
+
// Update memory state
|
|
289
|
+
set(memoryStateAtom, { ...newState })
|
|
290
|
+
|
|
291
|
+
// Extract current token lists and the active list selection for storage
|
|
292
|
+
const tokenListsToStore: Record<string, any> = {}
|
|
293
|
+
Object.keys(newState.byUrl).forEach((url) => {
|
|
294
|
+
if (newState.byUrl[url]?.current) {
|
|
295
|
+
tokenListsToStore[url] = newState.byUrl[url].current
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
set(tokenListsStorageAtom, { byUrl: tokenListsToStore, activeListUrls: newState.activeListUrls })
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const isReadyAtom = loadable(tokenListsStorageAtom)
|
|
87
303
|
|
|
88
304
|
const tokenAtom = atomFamily((key: { chainId: number; address: string }) =>
|
|
89
|
-
atom((get) => findTokenByAddress(get(
|
|
305
|
+
atom((get) => findTokenByAddress(get(listStateAtom), key.chainId, key.address)),
|
|
90
306
|
)
|
|
91
307
|
|
|
92
308
|
const tokenBySymbolAtom = atomFamily((key: { chainId: number; symbol: string }) =>
|
|
93
|
-
atom((get) => findTokenBySymbol(get(
|
|
309
|
+
atom((get) => findTokenBySymbol(get(listStateAtom), key.chainId, key.symbol)),
|
|
94
310
|
)
|
|
95
311
|
|
|
96
312
|
const fetchListAtom = atom<null, [string], Promise<void>>(null, async (get, set, url) => {
|
|
97
|
-
const state = get(
|
|
313
|
+
const state = get(listStateAtom)
|
|
98
314
|
const listState = state.byUrl[url]
|
|
99
315
|
if (listState?.current || listState?.loadingRequestId) {
|
|
100
316
|
return
|
|
101
317
|
}
|
|
102
318
|
|
|
103
319
|
const requestId = nanoid()
|
|
104
|
-
set(
|
|
320
|
+
set(updateListStateAtom, fetchTokenList.pending({ url, requestId }))
|
|
105
321
|
|
|
106
322
|
try {
|
|
107
323
|
const tokenList = await getTokenList(url)
|
|
108
|
-
set(
|
|
324
|
+
set(updateListStateAtom, fetchTokenList.fulfilled({ url, tokenList: tokenList!, requestId }))
|
|
325
|
+
} catch (error: any) {
|
|
326
|
+
set(updateListStateAtom, fetchTokenList.rejected({ url, requestId, errorMessage: error.message }))
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const fetchListBatchAtom = atom<null, [string[]], Promise<void>>(null, async (get, set, urls) => {
|
|
331
|
+
const state = get(listStateAtom)
|
|
332
|
+
|
|
333
|
+
// Filter out URLs that are already loaded or loading
|
|
334
|
+
const urlsToFetch = urls.filter((url) => {
|
|
335
|
+
const listState = state.byUrl[url]
|
|
336
|
+
return !listState?.current && !listState?.loadingRequestId
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
if (urlsToFetch.length === 0) {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const requestId = nanoid()
|
|
344
|
+
set(updateListStateAtom, batchFetchTokenListPending({ urls: urlsToFetch, requestId }))
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
// Fetch all token lists in parallel
|
|
348
|
+
const results = await Promise.allSettled(
|
|
349
|
+
urlsToFetch.map(async (url) => {
|
|
350
|
+
const tokenList = await getTokenList(url)
|
|
351
|
+
return { url, tokenList: tokenList!, requestId }
|
|
352
|
+
}),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
// Separate successful and failed results
|
|
356
|
+
const fulfilled: Array<{ url: string; tokenList: any; requestId: string }> = []
|
|
357
|
+
const rejected: Array<{ url: string; errorMessage: string; requestId: string }> = []
|
|
358
|
+
|
|
359
|
+
results.forEach((result, index) => {
|
|
360
|
+
const url = urlsToFetch[index]
|
|
361
|
+
if (result.status === 'fulfilled') {
|
|
362
|
+
fulfilled.push(result.value)
|
|
363
|
+
} else {
|
|
364
|
+
rejected.push({
|
|
365
|
+
url,
|
|
366
|
+
errorMessage: result.reason?.message || 'Unknown error',
|
|
367
|
+
requestId,
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// Dispatch batch actions
|
|
373
|
+
if (fulfilled.length > 0) {
|
|
374
|
+
set(updateListStateAtom, batchFetchTokenListFulfilled({ results: fulfilled }))
|
|
375
|
+
}
|
|
376
|
+
if (rejected.length > 0) {
|
|
377
|
+
set(updateListStateAtom, batchFetchTokenListRejected({ errors: rejected }))
|
|
378
|
+
}
|
|
109
379
|
} catch (error: any) {
|
|
110
|
-
|
|
380
|
+
// Fallback to individual rejections if batch processing fails
|
|
381
|
+
const errors = urlsToFetch.map((url) => ({
|
|
382
|
+
url,
|
|
383
|
+
errorMessage: error.message,
|
|
384
|
+
requestId,
|
|
385
|
+
}))
|
|
386
|
+
set(updateListStateAtom, batchFetchTokenListRejected({ errors }))
|
|
111
387
|
}
|
|
112
388
|
})
|
|
113
389
|
|
|
114
390
|
function useListState() {
|
|
115
|
-
return useAtom(
|
|
391
|
+
return useAtom(listStateAtom)
|
|
116
392
|
}
|
|
117
393
|
|
|
118
394
|
function useListStateReady() {
|
|
119
395
|
const value = useAtomValue(isReadyAtom)
|
|
120
|
-
return value.state === 'hasData'
|
|
396
|
+
return value.state === 'hasData'
|
|
121
397
|
}
|
|
122
398
|
|
|
123
399
|
return {
|
|
124
|
-
listsAtom:
|
|
400
|
+
listsAtom: listStateAtom,
|
|
401
|
+
updateListStateAtom,
|
|
125
402
|
tokenAtom,
|
|
126
403
|
tokenBySymbolAtom,
|
|
404
|
+
geoBlockListAtom,
|
|
405
|
+
fetchGeoBlockListAtom,
|
|
127
406
|
fetchListAtom,
|
|
407
|
+
fetchListBatchAtom,
|
|
128
408
|
useListStateReady,
|
|
129
409
|
useListState,
|
|
130
410
|
}
|
package/react/reducer.ts
CHANGED
|
@@ -8,8 +8,12 @@ import {
|
|
|
8
8
|
enableList,
|
|
9
9
|
disableList,
|
|
10
10
|
updateListVersion,
|
|
11
|
+
batchFetchTokenListPending,
|
|
12
|
+
batchFetchTokenListFulfilled,
|
|
13
|
+
batchFetchTokenListRejected,
|
|
11
14
|
} from './actions'
|
|
12
|
-
import {
|
|
15
|
+
import { TokenList } from '../src/types'
|
|
16
|
+
import { setPendingTokenList, setFulfilledTokenList, setRejectedTokenList, batchActivateUrls } from './reducerHelpers'
|
|
13
17
|
|
|
14
18
|
export interface ListsState {
|
|
15
19
|
readonly byUrl: {
|
|
@@ -44,62 +48,13 @@ export const createTokenListReducer = (
|
|
|
44
48
|
createReducer(initialState, (builder) =>
|
|
45
49
|
builder
|
|
46
50
|
.addCase(fetchTokenList.pending, (state, { payload: { requestId, url } }) => {
|
|
47
|
-
|
|
48
|
-
const pendingUpdate = state.byUrl[url]?.pendingUpdate ?? null
|
|
49
|
-
|
|
50
|
-
state.byUrl[url] = {
|
|
51
|
-
current,
|
|
52
|
-
pendingUpdate,
|
|
53
|
-
loadingRequestId: requestId,
|
|
54
|
-
error: null,
|
|
55
|
-
}
|
|
51
|
+
setPendingTokenList(state, url, requestId)
|
|
56
52
|
})
|
|
57
53
|
.addCase(fetchTokenList.fulfilled, (state, { payload: { requestId, tokenList, url } }) => {
|
|
58
|
-
|
|
59
|
-
const loadingRequestId = state.byUrl[url]?.loadingRequestId
|
|
60
|
-
|
|
61
|
-
// no-op if update does nothing
|
|
62
|
-
if (current) {
|
|
63
|
-
const upgradeType = getVersionUpgrade(current.version, tokenList.version)
|
|
64
|
-
|
|
65
|
-
if (upgradeType === VersionUpgrade.NONE) return
|
|
66
|
-
if (loadingRequestId === null || loadingRequestId === requestId) {
|
|
67
|
-
state.byUrl[url] = {
|
|
68
|
-
...state.byUrl[url],
|
|
69
|
-
loadingRequestId: null,
|
|
70
|
-
error: null,
|
|
71
|
-
current,
|
|
72
|
-
pendingUpdate: tokenList,
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
} else {
|
|
76
|
-
// activate if on default active
|
|
77
|
-
if (DEFAULT_ACTIVE_LIST_URLS.includes(url) && state.activeListUrls && !state.activeListUrls.includes(url)) {
|
|
78
|
-
state.activeListUrls.push(url)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
state.byUrl[url] = {
|
|
82
|
-
...state.byUrl[url],
|
|
83
|
-
loadingRequestId: null,
|
|
84
|
-
error: null,
|
|
85
|
-
current: tokenList,
|
|
86
|
-
pendingUpdate: null,
|
|
87
|
-
}
|
|
88
|
-
}
|
|
54
|
+
setFulfilledTokenList(state, url, tokenList, requestId, DEFAULT_ACTIVE_LIST_URLS)
|
|
89
55
|
})
|
|
90
56
|
.addCase(fetchTokenList.rejected, (state, { payload: { url, requestId, errorMessage } }) => {
|
|
91
|
-
|
|
92
|
-
// no-op since it's not the latest request
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
state.byUrl[url] = {
|
|
97
|
-
...state.byUrl[url],
|
|
98
|
-
loadingRequestId: null,
|
|
99
|
-
error: errorMessage,
|
|
100
|
-
current: null,
|
|
101
|
-
pendingUpdate: null,
|
|
102
|
-
}
|
|
57
|
+
setRejectedTokenList(state, url, requestId, errorMessage)
|
|
103
58
|
})
|
|
104
59
|
.addCase(addList, (state, { payload: url }) => {
|
|
105
60
|
if (!state.byUrl[url]) {
|
|
@@ -182,5 +137,31 @@ export const createTokenListReducer = (
|
|
|
182
137
|
return true
|
|
183
138
|
})
|
|
184
139
|
}
|
|
140
|
+
})
|
|
141
|
+
.addCase(batchFetchTokenListPending, (state, { payload: { urls, requestId } }) => {
|
|
142
|
+
// Batch update multiple lists to pending state in a single update cycle
|
|
143
|
+
urls.forEach((url) => {
|
|
144
|
+
setPendingTokenList(state, url, requestId)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
.addCase(batchFetchTokenListFulfilled, (state, { payload: { results } }) => {
|
|
148
|
+
// Batch update multiple lists to fulfilled state in a single update cycle
|
|
149
|
+
const urlsToActivate: string[] = []
|
|
150
|
+
|
|
151
|
+
results.forEach(({ url, tokenList, requestId }) => {
|
|
152
|
+
const isNewList = setFulfilledTokenList(state, url, tokenList, requestId, DEFAULT_ACTIVE_LIST_URLS)
|
|
153
|
+
if (isNewList && DEFAULT_ACTIVE_LIST_URLS.includes(url)) {
|
|
154
|
+
urlsToActivate.push(url)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Batch activate URLs if needed
|
|
159
|
+
batchActivateUrls(state, urlsToActivate)
|
|
160
|
+
})
|
|
161
|
+
.addCase(batchFetchTokenListRejected, (state, { payload: { errors } }) => {
|
|
162
|
+
// Batch update multiple lists to rejected state in a single update cycle
|
|
163
|
+
errors.forEach(({ url, requestId, errorMessage }) => {
|
|
164
|
+
setRejectedTokenList(state, url, requestId, errorMessage)
|
|
165
|
+
})
|
|
185
166
|
}),
|
|
186
167
|
)
|