fix: filemanager state handling (#232)

* fix: filemanager state handling
* refactor: fm provider and fm page
* fix: detect bee warmup and wait more for syncing
* refactor: optimize bee provider to avoid rerenders
---------

Co-authored-by: Roland Seres <roland.seres90@gmail.com>
This commit is contained in:
Bálint Ujvári
2026-03-19 16:03:25 +01:00
parent 55e7879849
commit 855a017033
5 changed files with 334 additions and 290 deletions
+112 -113
View File
@@ -29,7 +29,7 @@ import { useLatestBeeRelease } from '../hooks/apiHooks'
import { Context as SettingsContext } from './Settings'
const LAUNCH_GRACE_PERIOD = 15_000
const LAUNCH_GRACE_PERIOD = 35_000
const REFRESH_WHEN_OK = 30_000
const REFRESH_WHEN_ERROR = 5_000
const TIMEOUT = 3_000
@@ -116,15 +116,22 @@ interface Props {
children: ReactNode
}
function getStatus(
nodeInfo: NodeInfo | null,
apiHealth: boolean,
topology: Topology | null,
chequebookAddress: ChequebookAddressResponse | null,
chequebookBalance: ChequebookBalanceResponse | null,
error: Error | null,
startedAt: number,
): Status {
interface StatusProps {
nodeInfo: NodeInfo | null
apiHealth: boolean
topology: Topology | null
isWarmingUp: boolean
chequebookAddress: ChequebookAddressResponse | null
chequebookBalance: ChequebookBalanceResponse | null
error: Error | null
startedAt: number
}
function getStatus(props: StatusProps): Status {
const { nodeInfo, apiHealth, topology, isWarmingUp, chequebookAddress, chequebookBalance, error, startedAt } = {
...props,
}
const status: Status = { ...initialValues.status }
// API connection check
@@ -143,15 +150,17 @@ function getStatus(
if (chequebookAddress?.chequebookAddress && chequebookBalance !== null) {
status.chequebook.checkState = CheckState.OK
} else status.chequebook.checkState = CheckState.OK
} else {
status.chequebook.checkState = CheckState.WARNING
}
}
status.all = determineOverallStatus(status, startedAt)
status.all = determineOverallStatus(status, isWarmingUp, startedAt)
return status
}
function determineOverallStatus(status: Status, startedAt: number): CheckState {
function determineOverallStatus(status: Status, isWarmingUp: boolean, startedAt: number): CheckState {
const hasErrors = Object.values(status).some(
({ isEnabled, checkState }) => isEnabled && checkState === CheckState.ERROR,
)
@@ -160,15 +169,23 @@ function determineOverallStatus(status: Status, startedAt: number): CheckState {
)
const isInGracePeriod = Date.now() - startedAt < LAUNCH_GRACE_PERIOD
if (hasErrors && isInGracePeriod) {
if (isWarmingUp || isInGracePeriod) {
return CheckState.CONNECTING
} else if (hasErrors) {
return CheckState.ERROR
} else if (hasWarnings) {
return CheckState.WARNING
} else {
return CheckState.OK
}
if (hasErrors) {
return CheckState.ERROR
}
if (hasWarnings) {
return CheckState.WARNING
}
return CheckState.OK
}
function getFulfilledValue<T>(result: PromiseSettledResult<T>): T | null {
return result.status === 'fulfilled' ? result.value : null
}
// This does not need to be exposed and works much better as variable than state variable which may trigger some unnecessary re-renders
@@ -179,6 +196,7 @@ export function Provider({ children }: Props): ReactElement {
const [beeVersion, setBeeVersion] = useState<string | null>(null)
const [apiHealth, setApiHealth] = useState<boolean>(false)
const [isWarmingUp, setIsWarmingUp] = useState<boolean>(true)
const [nodeAddresses, setNodeAddresses] = useState<NodeAddresses | null>(null)
const [nodeInfo, setNodeInfo] = useState<NodeInfo | null>(null)
const [topology, setNodeTopology] = useState<Topology | null>(null)
@@ -191,7 +209,7 @@ export function Provider({ children }: Props): ReactElement {
const [settlements, setSettlements] = useState<AllSettlements | null>(null)
const [chainState, setChainState] = useState<ChainState | null>(null)
const [walletBalance, setWalletBalance] = useState<WalletBalance | null>(null)
const [startedAt] = useState(() => Date.now())
const [startedAt, setStartedAt] = useState(() => Date.now())
const { latestBeeRelease } = useLatestBeeRelease()
@@ -202,6 +220,15 @@ export function Provider({ children }: Props): ReactElement {
const frequencyRef = useRef<number | null>(frequency)
useEffect(() => {
if (isWarmingUp) return
setStartedAt(Date.now())
const timer = setTimeout(() => setStartedAt(0), LAUNCH_GRACE_PERIOD)
return () => clearTimeout(timer)
}, [isWarmingUp])
const refresh = useCallback(async () => {
// Don't want to refresh when already refreshing
if (isRefreshing) {
@@ -215,101 +242,63 @@ export function Provider({ children }: Props): ReactElement {
return
}
try {
isRefreshing = true
setError(null)
isRefreshing = true
const promises = [
// API health
beeApi
.getHealth({ timeout: TIMEOUT })
.then(response => setBeeVersion(response.version))
.then(() => setApiHealth(true))
.catch(() => {
setBeeVersion(null)
setApiHealth(false)
}),
const [
healthResult,
statusResult,
nodeAddressesResult,
nodeInfoResult,
topologyResult,
peersResult,
chequebookAddressResult,
peerChequesResult,
chainStateResult,
walletResult,
chequebookBalanceResult,
stakeResult,
peerBalancesResult,
settlementsResult,
] = await Promise.allSettled([
beeApi.getHealth({ timeout: TIMEOUT }),
beeApi.getStatus({ timeout: TIMEOUT }),
beeApi.getNodeAddresses({ timeout: TIMEOUT }),
beeApi.getNodeInfo({ timeout: TIMEOUT }),
beeApi.getTopology({ timeout: TIMEOUT }),
beeApi.getPeers({ timeout: TIMEOUT }),
beeApi.getChequebookAddress({ timeout: TIMEOUT }),
beeApi.getLastCheques({ timeout: TIMEOUT }),
beeApi.getChainState({ timeout: TIMEOUT }),
beeApi.getWalletBalance({ timeout: TIMEOUT }),
beeApi.getChequebookBalance({ timeout: TIMEOUT }),
beeApi.getStake({ timeout: TIMEOUT }),
beeApi.getAllBalances({ timeout: TIMEOUT }),
beeApi.getAllSettlements(),
])
// Node Addresses
beeApi
.getNodeAddresses({ timeout: TIMEOUT })
.then(setNodeAddresses)
.catch(() => setNodeAddresses(null)),
// NodeInfo
beeApi
.getNodeInfo({ timeout: TIMEOUT })
.then(setNodeInfo)
.catch(() => setNodeInfo(null)),
// Network Topology
beeApi
.getTopology({ timeout: TIMEOUT })
.then(setNodeTopology)
.catch(() => setNodeTopology(null)),
// Peers
beeApi
.getPeers({ timeout: TIMEOUT })
.then(setPeers)
.catch(() => setPeers(null)),
// Chequebook address
beeApi
.getChequebookAddress({ timeout: TIMEOUT })
.then(setChequebookAddress)
.catch(() => setChequebookAddress(null)),
// Cheques
beeApi
.getLastCheques({ timeout: TIMEOUT })
.then(setPeerCheques)
.catch(() => setPeerCheques(null)),
// Chain state
beeApi
.getChainState({ timeout: TIMEOUT })
.then(setChainState)
.catch(() => setChainState(null)),
// Wallet
beeApi
.getWalletBalance({ timeout: TIMEOUT })
.then(setWalletBalance)
.catch(() => setWalletBalance(null)),
// Chequebook balance
beeApi
.getChequebookBalance({ timeout: TIMEOUT })
.then(setChequebookBalance)
.catch(() => setChequebookBalance(null)),
beeApi
.getStake({ timeout: TIMEOUT })
.then(stake => setStake(stake))
.catch(() => setStake(null)),
// Peer balances
beeApi
.getAllBalances({ timeout: TIMEOUT })
.then(x => setPeerBalances(x.balances))
.catch(() => setPeerBalances(null)),
// Settlements
beeApi
.getAllSettlements()
.then(setSettlements)
.catch(() => setSettlements(null)),
]
await Promise.allSettled(promises)
} catch (e) {
setError(e as Error)
}
// All setters called synchronously — React 18 batches them into one render.
const health = getFulfilledValue(healthResult)
setBeeVersion(health?.version ?? null)
setApiHealth(Boolean(health))
setIsWarmingUp(getFulfilledValue(statusResult)?.isWarmingUp ?? false)
setNodeAddresses(getFulfilledValue(nodeAddressesResult))
setNodeInfo(getFulfilledValue(nodeInfoResult))
setNodeTopology(getFulfilledValue(topologyResult))
setPeers(getFulfilledValue(peersResult))
setChequebookAddress(getFulfilledValue(chequebookAddressResult))
setPeerCheques(getFulfilledValue(peerChequesResult))
setChainState(getFulfilledValue(chainStateResult))
setWalletBalance(getFulfilledValue(walletResult))
setChequebookBalance(getFulfilledValue(chequebookBalanceResult))
setStake(getFulfilledValue(stakeResult))
setPeerBalances(getFulfilledValue(peerBalancesResult)?.balances ?? null)
setSettlements(getFulfilledValue(settlementsResult))
setError(null)
setIsLoading(false)
isRefreshing = false
setLastUpdate(Date.now())
isRefreshing = false
}, [beeApi])
const start = useCallback(
@@ -322,8 +311,18 @@ export function Provider({ children }: Props): ReactElement {
const stop = useCallback(() => setFrequency(null), [])
const status = useMemo(
() => getStatus(nodeInfo, apiHealth, topology, chequebookAddress, chequebookBalance, error, startedAt),
[nodeInfo, apiHealth, topology, chequebookAddress, chequebookBalance, error, startedAt],
() =>
getStatus({
nodeInfo,
apiHealth,
topology,
isWarmingUp,
chequebookAddress,
chequebookBalance,
error,
startedAt,
}),
[nodeInfo, apiHealth, topology, chequebookAddress, chequebookBalance, error, startedAt, isWarmingUp],
)
useEffect(() => {
+147 -84
View File
@@ -7,10 +7,12 @@ import { FILE_MANAGER_EVENTS } from '../modules/filemanager/constants/common'
import { getUsableStamps } from '../modules/filemanager/utils/bee'
import { getSignerPk } from '../modules/filemanager/utils/common'
import { CheckState, Context as BeeContext } from './Bee'
import { Context as SettingsContext } from './Settings'
interface ContextInterface {
fm: FileManagerBase | null
initDone: boolean
files: FileInfo[]
currentDrive?: DriveInfo
currentStamp?: PostageBatch
@@ -24,6 +26,7 @@ interface ContextInterface {
setCurrentStamp: (s: PostageBatch | undefined) => void
resync: () => Promise<void>
init: () => Promise<FileManagerBase | null>
notifyPkSaved: () => void
setShowError: (show: boolean) => void
syncDrives: () => Promise<void>
refreshStamp: (batchId: string) => Promise<PostageBatch | undefined>
@@ -31,6 +34,7 @@ interface ContextInterface {
const initialValues: ContextInterface = {
fm: null,
initDone: false,
files: [],
currentDrive: undefined,
currentStamp: undefined,
@@ -45,6 +49,7 @@ const initialValues: ContextInterface = {
resync: async () => {},
// eslint-disable-next-line require-await
init: async () => null,
notifyPkSaved: () => {},
setShowError: () => {},
syncDrives: async () => {},
// eslint-disable-next-line require-await
@@ -85,12 +90,18 @@ const findDrives = (
}
export function Provider({ children }: Props) {
const initInProgressRef = useRef(false)
const beeInstanceRef = useRef<Bee | null>(null)
const initInProgressRef = useRef<boolean>(false)
const isBeeApiInitialized = useRef<boolean>(false)
const { status } = useContext(BeeContext)
const { apiUrl } = useContext(SettingsContext)
const apiUrlRef = useRef<string>(apiUrl)
const [pkSaved, setPkSaved] = useState<boolean>(false)
const [beeInstance, setBeeInstance] = useState<Bee | null>(null)
const [fm, setFm] = useState<FileManagerBase | null>(null)
const [initDone, setInitDone] = useState<boolean>(false)
const [shallReset, setShallReset] = useState<boolean>(false)
const [files, setFiles] = useState<FileInfo[]>([])
const [drives, setDrives] = useState<DriveInfo[]>([])
@@ -102,6 +113,8 @@ export function Provider({ children }: Props) {
const [initializationError, setInitializationError] = useState<boolean>(false)
const [showError, setShowError] = useState<boolean>(false)
const notifyPkSaved = useCallback(() => setPkSaved(v => !v), [])
const syncFiles = useCallback((manager: FileManagerBase, fi?: FileInfo, remove?: boolean): void => {
if (fi) {
if (remove) {
@@ -129,70 +142,73 @@ export function Provider({ children }: Props) {
setFiles([...manager.fileInfoList])
}, [])
const syncDrives = useCallback(async (manager: FileManagerBase, di?: DriveInfo, remove?: boolean): Promise<void> => {
if (!beeInstanceRef.current) {
return
}
const syncDrives = useCallback(
async (manager: FileManagerBase, di?: DriveInfo, remove?: boolean): Promise<void> => {
if (!beeInstance) {
return
}
const usableStamps = await getUsableStamps(beeInstanceRef.current)
const usableStamps = await getUsableStamps(beeInstance)
if (di) {
const isNotExpired = usableStamps.some(s => s.batchID.toString() === di.batchId.toString())
if (di) {
const isNotExpired = usableStamps.some(s => s.batchID.toString() === di.batchId.toString())
if (isNotExpired) {
if (remove) {
setDrives(prev => prev.filter(d => d.id.toString() !== di.id.toString()))
if (isNotExpired) {
if (remove) {
setDrives(prev => prev.filter(d => d.id.toString() !== di.id.toString()))
return
}
if (di.isAdmin) {
setAdminDrive(di)
return
}
setDrives(prev => {
const existingIndex = prev.findIndex(d => d.id.toString() === di.id.toString())
if (existingIndex >= 0) {
const updated = [...prev]
updated[existingIndex] = di
return updated
return
}
return [...prev, di]
})
if (di.isAdmin) {
setAdminDrive(di)
return
}
setDrives(prev => {
const existingIndex = prev.findIndex(d => d.id.toString() === di.id.toString())
if (existingIndex >= 0) {
const updated = [...prev]
updated[existingIndex] = di
return updated
}
return [...prev, di]
})
return
}
if (remove) {
setExpiredDrives(prev => prev.filter(d => d.id.toString() !== di.id.toString()))
return
}
if (!di.isAdmin) {
setExpiredDrives(prev => {
const exists = prev.some(d => d.id.toString() === di.id.toString())
return exists ? prev : [...prev, di]
})
return
}
// TODO: handle admin drive expiration!
return
}
if (remove) {
setExpiredDrives(prev => prev.filter(d => d.id.toString() !== di.id.toString()))
return
}
if (!di.isAdmin) {
setExpiredDrives(prev => {
const exists = prev.some(d => d.id.toString() === di.id.toString())
return exists ? prev : [...prev, di]
})
return
}
// TODO: handle admin drive expiration!
return
}
const { adminDrive: tmpAdminDrive, userDrives, expiredDrives } = findDrives(manager.driveList, usableStamps)
setAdminDrive(tmpAdminDrive)
setDrives(userDrives)
setExpiredDrives(expiredDrives)
}, [])
const { adminDrive: tmpAdminDrive, userDrives, expiredDrives } = findDrives(manager.driveList, usableStamps)
setAdminDrive(tmpAdminDrive)
setDrives(userDrives)
setExpiredDrives(expiredDrives)
},
[beeInstance],
)
const syncDrivesPublic = useCallback(async () => {
if (fm) {
@@ -200,55 +216,56 @@ export function Provider({ children }: Props) {
}
}, [fm, syncDrives])
const refreshStamp = useCallback(async (batchId: string): Promise<PostageBatch | undefined> => {
if (!beeInstanceRef.current) {
return
}
const usableStamps = await getUsableStamps(beeInstanceRef.current)
const refreshedStamp = usableStamps.find(s => s.batchID.toString() === batchId)
setCurrentStamp(prev => {
if (prev && prev.batchID.toString() === batchId && refreshedStamp) {
return refreshedStamp
const refreshStamp = useCallback(
async (batchId: string): Promise<PostageBatch | undefined> => {
if (!beeInstance) {
return
}
return prev
})
const usableStamps = await getUsableStamps(beeInstance)
const refreshedStamp = usableStamps.find(s => s.batchID.toString() === batchId)
return refreshedStamp
}, [])
setCurrentStamp(prev => {
if (prev && prev.batchID.toString() === batchId && refreshedStamp) {
return refreshedStamp
}
return prev
})
return refreshedStamp
},
[beeInstance],
)
const init = useCallback(async (): Promise<FileManagerBase | null> => {
const pk = getSignerPk()
if (!apiUrl || !pk || initInProgressRef.current) return null
if (!beeInstance || !pk || initInProgressRef.current) return null
initInProgressRef.current = true
setFm(null)
setInitDone(false)
setFiles([])
setDrives([])
setAdminDrive(null)
setInitializationError(false)
setCurrentDrive(undefined)
setCurrentStamp(undefined)
setShallReset(false)
if (!beeInstanceRef.current) {
beeInstanceRef.current = new Bee(apiUrl, { signer: pk })
}
const manager = new FileManagerBase(beeInstanceRef.current)
const manager = new FileManagerBase(beeInstance)
const handleInitialized = (success: boolean) => {
setInitializationError(!success)
setInitDone(true)
if (success) {
if (manager.adminStamp && !manager.adminStamp.usable) {
// eslint-disable-next-line no-console
console.warn('Admin stamp exists but is not usable')
setShallReset(true)
setInitializationError(true)
return
}
@@ -310,11 +327,13 @@ export function Provider({ children }: Props) {
return manager
} catch {
setInitDone(true)
return null
} finally {
initInProgressRef.current = false
}
}, [apiUrl, syncDrives, syncFiles])
}, [beeInstance, syncDrives, syncFiles])
const resync = useCallback(async (): Promise<void> => {
const prevDriveId = currentDrive?.id.toString()
@@ -322,19 +341,59 @@ export function Provider({ children }: Props) {
const manager = await init()
if (prevDriveId && manager && beeInstanceRef.current) {
if (prevDriveId && manager && beeInstance) {
const refreshedDrive = manager.driveList.find(d => d.id.toString() === prevDriveId)
setCurrentDrive(refreshedDrive)
const uStamps: PostageBatch[] = await getUsableStamps(beeInstanceRef.current)
const uStamps: PostageBatch[] = await getUsableStamps(beeInstance)
const isValidCurrentStamp = uStamps.find(s => s.batchID.toString() === prevStamp?.batchID.toString())
setCurrentStamp(isValidCurrentStamp)
}
}, [currentDrive?.id, currentStamp, init])
}, [beeInstance, currentDrive?.id, currentStamp, init])
useEffect(() => {
if (!apiUrl || initInProgressRef.current) {
apiUrlRef.current = apiUrl
}, [apiUrl])
useEffect(() => {
const isConnecting = status.all === CheckState.CONNECTING
const isApiOk = status.apiConnection.isEnabled && status.apiConnection.checkState === CheckState.OK
const currentApiUrl = apiUrlRef.current
const pk = getSignerPk()
if (!currentApiUrl || !pk) {
isBeeApiInitialized.current = false
setBeeInstance(null)
return
}
if (isConnecting) {
return
}
if (isBeeApiInitialized.current) {
return
}
if (!isApiOk) {
return
}
isBeeApiInitialized.current = true
setBeeInstance(new Bee(currentApiUrl, { signer: pk }))
}, [status.all, status.apiConnection, pkSaved])
useEffect(() => {
isBeeApiInitialized.current = false
setBeeInstance(null)
setInitDone(false)
initInProgressRef.current = false
}, [apiUrl])
useEffect(() => {
if (!beeInstance || initInProgressRef.current) {
return
}
@@ -343,7 +402,7 @@ export function Provider({ children }: Props) {
}
initFromLocalState()
}, [apiUrl, init])
}, [beeInstance, init])
useEffect(() => {
if (fm && drives.length === 0 && !adminDrive) {
@@ -354,6 +413,7 @@ export function Provider({ children }: Props) {
const contextValue = useMemo(
() => ({
fm,
initDone,
files,
currentDrive,
currentStamp,
@@ -367,12 +427,14 @@ export function Provider({ children }: Props) {
setCurrentStamp,
resync,
init,
notifyPkSaved,
setShowError,
syncDrives: syncDrivesPublic,
refreshStamp,
}),
[
fm,
initDone,
files,
currentDrive,
currentStamp,
@@ -386,6 +448,7 @@ export function Provider({ children }: Props) {
setCurrentStamp,
resync,
init,
notifyPkSaved,
setShowError,
syncDrivesPublic,
refreshStamp,