Skip to content

Commit 3c8ba3d

Browse files
authored
feat: detect and allow refreshing of stale mutable entities (#73)
* feat: detect and allow refreshing of stale mutable entities
1 parent 931c888 commit 3c8ba3d

File tree

13 files changed

+361
-68
lines changed

13 files changed

+361
-68
lines changed

src/features/accounts/components/account-info.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
accountRekeyedToLabel,
1919
} from './labels'
2020

21-
export function AccountInfo({ account }: { account: Account }) {
21+
type Props = {
22+
account: Account
23+
}
24+
25+
export function AccountInfo({ account }: Props) {
2226
const totalAssetsHeld = account.assetsHeld.length
2327
const totalAssetsCreated = account.assetsCreated.length
2428
const totalAssetsOptedIn = account.assetsHeld.length + account.assetsOpted.length
Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,37 @@
1-
import { atom, useAtomValue } from 'jotai'
1+
import { atom, useAtomValue, useSetAtom } from 'jotai'
22
import { Address } from './types'
3-
import { loadable } from 'jotai/utils'
3+
import { atomWithRefresh, loadable } from 'jotai/utils'
44
import { useMemo } from 'react'
55
import { asAccount } from '../mappers'
6-
import { getAccountResultAtom } from './account-result'
6+
import { accountResultsAtom, getAccountResultAtom } from './account-result'
77
import { assetSummaryResolver } from '@/features/assets/data'
8+
import { atomEffect } from 'jotai-effect'
89

9-
const createAccountAtom = (address: Address) => {
10-
return atom(async (get) => {
11-
const accountResult = await get(getAccountResultAtom(address))
12-
return asAccount(accountResult, assetSummaryResolver)
10+
const createAccountAtoms = (address: Address) => {
11+
const isStaleAtom = atom(false)
12+
const detectIsStaleEffect = atomEffect((get, set) => {
13+
const accountResults = get(accountResultsAtom)
14+
const isStale = accountResults.get(address) === undefined ? true : false
15+
set(isStaleAtom, isStale)
1316
})
17+
18+
return [
19+
atomWithRefresh(async (get) => {
20+
const accountResult = await get(getAccountResultAtom(address))
21+
get(detectIsStaleEffect)
22+
return asAccount(accountResult, assetSummaryResolver)
23+
}),
24+
isStaleAtom,
25+
] as const
1426
}
1527

16-
const useAccountAtom = (address: Address) => {
28+
const useAccountAtoms = (address: Address) => {
1729
return useMemo(() => {
18-
return createAccountAtom(address)
30+
return createAccountAtoms(address)
1931
}, [address])
2032
}
2133

22-
export const useLoadableAccountAtom = (address: Address) => {
23-
return useAtomValue(loadable(useAccountAtom(address)))
34+
export const useLoadableAccount = (address: Address) => {
35+
const [accountAtom, isStaleAtom] = useAccountAtoms(address)
36+
return [useAtomValue(loadable(accountAtom)), useSetAtom(accountAtom), useAtomValue(isStaleAtom)] as const
2437
}

src/features/accounts/pages/account-page.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from '../components/labels'
2424
import { assetResultsAtom } from '@/features/assets/data'
2525
import { assetResultMother } from '@/tests/object-mother/asset-result'
26+
import { refreshButtonLabel } from '@/features/common/components/refresh-button'
2627

2728
describe('account-page', () => {
2829
describe('when rendering an account using a invalid address', () => {
@@ -140,6 +141,7 @@ describe('account-page', () => {
140141
)
141142
})
142143
})
144+
143145
describe('when rendering an account with rekey', () => {
144146
const accountResult = accountResultMother['mainnet-DGOANM6JL4VNSBJW737T24V4WVQINFWELRE3OKHQQFZ2JFMVKUF52D4AY4']().build()
145147

@@ -176,4 +178,51 @@ describe('account-page', () => {
176178
)
177179
})
178180
})
181+
182+
describe('when rendering an account that becomes stale', () => {
183+
const accountResult = accountResultMother['mainnet-BIQXAK67KSCKN3EJXT4S3RVXUBFOLZ45IQOBTSOQWOSR4LLULBTD54S5IA']().build()
184+
const assetResults = new Map([
185+
[924268058, atom(assetResultMother['mainnet-924268058']().build())],
186+
[1010208883, atom(assetResultMother['mainnet-1010208883']().build())],
187+
[1096015467, atom(assetResultMother['mainnet-1096015467']().build())],
188+
])
189+
190+
it('should be rendered with the refresh button', () => {
191+
const myStore = createStore()
192+
myStore.set(accountResultsAtom, new Map([[accountResult.address, atom(accountResult)]]))
193+
myStore.set(assetResultsAtom, assetResults)
194+
195+
vi.mocked(useParams).mockImplementation(() => ({ address: accountResult.address }))
196+
197+
return executeComponentTest(
198+
() => render(<AccountPage />, undefined, myStore),
199+
async (component) => {
200+
await waitFor(() => {
201+
const informationCard = component.getByLabelText(accountInformationLabel)
202+
descriptionListAssertion({
203+
container: informationCard,
204+
items: [{ term: accountAddressLabel, description: 'BIQXAK67KSCKN3EJXT4S3RVXUBFOLZ45IQOBTSOQWOSR4LLULBTD54S5IA' }],
205+
})
206+
207+
const refreshButton = component.queryByLabelText(refreshButtonLabel)
208+
expect(refreshButton).toBeFalsy()
209+
})
210+
211+
// Simulate the account being evicted from the store, due to staleness
212+
myStore.set(accountResultsAtom, new Map())
213+
214+
await waitFor(() => {
215+
const informationCard = component.getByLabelText(accountInformationLabel)
216+
descriptionListAssertion({
217+
container: informationCard,
218+
items: [{ term: accountAddressLabel, description: 'BIQXAK67KSCKN3EJXT4S3RVXUBFOLZ45IQOBTSOQWOSR4LLULBTD54S5IA' }],
219+
})
220+
221+
const refreshButton = component.getByLabelText(refreshButtonLabel)
222+
expect(refreshButton).toBeTruthy()
223+
})
224+
}
225+
)
226+
})
227+
})
179228
})

src/features/accounts/pages/account-page.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { isAddress } from '@/utils/is-address'
66
import { is404 } from '@/utils/error'
77
import { RenderLoadable } from '@/features/common/components/render-loadable'
88
import { Account } from '../models'
9-
import { useLoadableAccountAtom } from '../data'
9+
import { useLoadableAccount } from '../data'
1010
import { AccountDetails } from '../components/account-details'
11+
import { useCallback } from 'react'
12+
import { RefreshButton } from '@/features/common/components/refresh-button'
1113

1214
export const accountPageTitle = 'Account'
1315
export const accountInvalidAddressMessage = 'Address is invalid'
@@ -26,11 +28,18 @@ const transformError = (e: Error) => {
2628
export function AccountPage() {
2729
const { address } = useRequiredParam(UrlParams.Address)
2830
invariant(isAddress(address), accountInvalidAddressMessage)
29-
const loadableAccount = useLoadableAccountAtom(address)
31+
const [loadableAccount, refreshAccount, isStale] = useLoadableAccount(address)
32+
33+
const refresh = useCallback(() => {
34+
refreshAccount()
35+
}, [refreshAccount])
3036

3137
return (
3238
<div>
33-
<h1 className={cn('text-2xl text-primary font-bold')}>{accountPageTitle}</h1>
39+
<div className="flex">
40+
<h1 className={cn('text-2xl text-primary font-bold')}>{accountPageTitle}</h1>
41+
{isStale && <RefreshButton onClick={refresh} />}
42+
</div>
3443
<RenderLoadable loadable={loadableAccount} transformError={transformError}>
3544
{(account: Account) => <AccountDetails account={account} />}
3645
</RenderLoadable>

src/features/applications/components/application-details.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { OverflowAutoTabsContent, Tabs, TabsList, TabsTrigger } from '@/features
3434
import { ApplicationLiveTransactions } from './application-live-transactions'
3535
import { ApplicationTransactionHistory } from './application-transaction-history'
3636
import { JsonView } from '@/features/common/components/json-view'
37+
import { AccountLink } from '@/features/accounts/components/account-link'
3738

3839
type Props = {
3940
application: Application
@@ -54,11 +55,11 @@ export function ApplicationDetails({ application }: Props) {
5455
: undefined,
5556
{
5657
dt: applicationCreatorAccountLabel,
57-
dd: application.creator,
58+
dd: <AccountLink address={application.creator}></AccountLink>,
5859
},
5960
{
6061
dt: applicationAccountLabel,
61-
dd: application.account,
62+
dd: <AccountLink address={application.account}></AccountLink>,
6263
},
6364
application.globalStateSchema
6465
? {
@@ -99,7 +100,6 @@ export function ApplicationDetails({ application }: Props) {
99100
<div className={cn('space-y-6 pt-7')}>
100101
<Card aria-label={applicationDetailsLabel} className={cn('p-4')}>
101102
<CardContent className={cn('text-sm space-y-2')}>
102-
<h1 className={cn('text-2xl text-primary font-bold')}>{applicationDetailsLabel}</h1>
103103
<DescriptionList items={applicationItems} />
104104
</CardContent>
105105
</Card>
Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,38 @@
1-
import { atom, useAtomValue } from 'jotai'
1+
import { atom, useAtomValue, useSetAtom } from 'jotai'
22
import { asApplication } from '../mappers'
33
import { useMemo } from 'react'
4-
import { loadable } from 'jotai/utils'
4+
import { atomWithRefresh, loadable } from 'jotai/utils'
55
import { ApplicationId } from './types'
6-
import { getApplicationResultAtom } from './application-result'
6+
import { applicationResultsAtom, getApplicationResultAtom } from './application-result'
77
import { getApplicationMetadataResultAtom } from './application-metadata'
8+
import { atomEffect } from 'jotai-effect'
89

9-
export const createApplicationAtom = (applicationId: ApplicationId) => {
10-
return atom(async (get) => {
11-
const applicationResult = await get(getApplicationResultAtom(applicationId))
12-
const applicationMetadata = await get(getApplicationMetadataResultAtom(applicationResult))
13-
return asApplication(applicationResult, applicationMetadata)
10+
const createApplicationAtoms = (applicationId: ApplicationId) => {
11+
const isStaleAtom = atom(false)
12+
const detectIsStaleEffect = atomEffect((get, set) => {
13+
const applicationResults = get(applicationResultsAtom)
14+
const isStale = applicationResults.get(applicationId) === undefined ? true : false
15+
set(isStaleAtom, isStale)
1416
})
17+
18+
return [
19+
atomWithRefresh(async (get) => {
20+
const applicationResult = await get(getApplicationResultAtom(applicationId))
21+
const applicationMetadata = await get(getApplicationMetadataResultAtom(applicationResult))
22+
get(detectIsStaleEffect)
23+
return asApplication(applicationResult, applicationMetadata)
24+
}),
25+
isStaleAtom,
26+
] as const
1527
}
1628

17-
const useApplicationAtom = (applicationId: ApplicationId) => {
29+
const useApplicationAtoms = (applicationId: ApplicationId) => {
1830
return useMemo(() => {
19-
return createApplicationAtom(applicationId)
31+
return createApplicationAtoms(applicationId)
2032
}, [applicationId])
2133
}
2234

2335
export const useLoadableApplication = (applicationId: ApplicationId) => {
24-
return useAtomValue(loadable(useApplicationAtom(applicationId)))
36+
const [applicationAtom, isStaleAtom] = useApplicationAtoms(applicationId)
37+
return [useAtomValue(loadable(applicationAtom)), useSetAtom(applicationAtom), useAtomValue(isStaleAtom)] as const
2538
}

src/features/applications/pages/application-page.test.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { descriptionListAssertion } from '@/tests/assertions/description-list-as
3030
import { tableAssertion } from '@/tests/assertions/table-assertion'
3131
import { modelsv2, indexerModels } from 'algosdk'
3232
import { transactionResultMother } from '@/tests/object-mother/transaction-result'
33+
import { refreshButtonLabel } from '@/features/common/components/refresh-button'
3334

3435
describe('application-page', () => {
3536
describe('when rendering an application using an invalid application Id', () => {
@@ -218,4 +219,90 @@ describe('application-page', () => {
218219
)
219220
})
220221
})
222+
223+
describe('when rendering an application that becomes stale', () => {
224+
const applicationResult = applicationResultMother['mainnet-80441968']().build()
225+
226+
it('should be rendered with the refresh button', () => {
227+
const myStore = createStore()
228+
myStore.set(applicationResultsAtom, new Map([[applicationResult.id, atom(applicationResult)]]))
229+
230+
vi.mocked(useParams).mockImplementation(() => ({ applicationId: applicationResult.id.toString() }))
231+
vi.mocked(indexer.searchForApplicationBoxes(0).nextToken('').limit(10).do).mockImplementation(() =>
232+
Promise.resolve(
233+
new indexerModels.BoxesResponse({
234+
applicationId: 80441968,
235+
boxes: [
236+
new modelsv2.BoxDescriptor({
237+
name: 'AAAAAAAAAAAAAAAAABhjNpJEU5krRanhldfCDWa2Rs8=',
238+
}),
239+
new modelsv2.BoxDescriptor({
240+
name: 'AAAAAAAAAAAAAAAAAB3fFPhSWjPaBhjzsx3NbXvlBK4=',
241+
}),
242+
new modelsv2.BoxDescriptor({
243+
name: 'AAAAAAAAAAAAAAAAACctz98iaZ1MeSEbj+XCnD5CCwQ=',
244+
}),
245+
new modelsv2.BoxDescriptor({
246+
name: 'AAAAAAAAAAAAAAAAACh7tCy49kQrUL7ykRWDmayeLKk=',
247+
}),
248+
new modelsv2.BoxDescriptor({
249+
name: 'AAAAAAAAAAAAAAAAAECfyDmi7C5tEjBUI9N80BEnnAk=',
250+
}),
251+
new modelsv2.BoxDescriptor({
252+
name: 'AAAAAAAAAAAAAAAAAEKTl0iZ2Q9UxPJphTgwplTfk6U=',
253+
}),
254+
new modelsv2.BoxDescriptor({
255+
name: 'AAAAAAAAAAAAAAAAAEO4cIhnhmQ0qdQDLoXi7q0+G7o=',
256+
}),
257+
new modelsv2.BoxDescriptor({
258+
name: 'AAAAAAAAAAAAAAAAAEVLZkp/l5eUQJZ/QEYYy9yNtuc=',
259+
}),
260+
new modelsv2.BoxDescriptor({
261+
name: 'AAAAAAAAAAAAAAAAAEkbM2/K1+8IrJ/jdkgEoF/O5k0=',
262+
}),
263+
new modelsv2.BoxDescriptor({
264+
name: 'AAAAAAAAAAAAAAAAAFwILIUnvVR4R/Xe9jTEV2SzTck=',
265+
}),
266+
],
267+
nextToken: 'b64:AAAAAAAAAAAAAAAAAFwILIUnvVR4R/Xe9jTEV2SzTck=',
268+
})
269+
)
270+
)
271+
vi.mocked(indexer.searchForTransactions().applicationID(applicationResult.id).limit(3).do).mockImplementation(() =>
272+
Promise.resolve({ currentRound: 123, transactions: [], nextToken: '' })
273+
)
274+
275+
return executeComponentTest(
276+
() => {
277+
return render(<ApplicationPage />, undefined, myStore)
278+
},
279+
async (component) => {
280+
await waitFor(async () => {
281+
const detailsCard = component.getByLabelText(applicationDetailsLabel)
282+
descriptionListAssertion({
283+
container: detailsCard,
284+
items: [{ term: applicationIdLabel, description: '80441968' }],
285+
})
286+
287+
const refreshButton = component.queryByLabelText(refreshButtonLabel)
288+
expect(refreshButton).toBeFalsy()
289+
})
290+
291+
// Simulate the application being evicted from the store, due to staleness
292+
myStore.set(applicationResultsAtom, new Map())
293+
294+
await waitFor(async () => {
295+
const detailsCard = component.getByLabelText(applicationDetailsLabel)
296+
descriptionListAssertion({
297+
container: detailsCard,
298+
items: [{ term: applicationIdLabel, description: '80441968' }],
299+
})
300+
301+
const refreshButton = component.getByLabelText(refreshButtonLabel)
302+
expect(refreshButton).toBeTruthy()
303+
})
304+
}
305+
)
306+
})
307+
})
221308
})

src/features/applications/pages/application-page.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { useLoadableApplication } from '../data'
77
import { RenderLoadable } from '@/features/common/components/render-loadable'
88
import { ApplicationDetails } from '../components/application-details'
99
import { is404 } from '@/utils/error'
10+
import { RefreshButton } from '@/features/common/components/refresh-button'
11+
import { useCallback } from 'react'
1012

1113
const transformError = (e: Error) => {
1214
if (is404(e)) {
@@ -28,11 +30,18 @@ export function ApplicationPage() {
2830
invariant(isInteger(_applicationId), applicationInvalidIdMessage)
2931

3032
const applicationId = parseInt(_applicationId, 10)
31-
const loadableApplication = useLoadableApplication(applicationId)
33+
const [loadableApplication, refreshApplication, isStale] = useLoadableApplication(applicationId)
34+
35+
const refresh = useCallback(() => {
36+
refreshApplication()
37+
}, [refreshApplication])
3238

3339
return (
3440
<div>
35-
<h1 className={cn('text-2xl text-primary font-bold')}>{applicationPageTitle}</h1>
41+
<div className="flex">
42+
<h1 className={cn('text-2xl text-primary font-bold')}>{applicationPageTitle}</h1>
43+
{isStale && <RefreshButton onClick={refresh} />}
44+
</div>
3645
<RenderLoadable loadable={loadableApplication} transformError={transformError}>
3746
{(application) => <ApplicationDetails application={application} />}
3847
</RenderLoadable>

0 commit comments

Comments
 (0)