diff --git a/src/components/NanoContract/NanoContractDetails.js b/src/components/NanoContract/NanoContractDetails.js index 3e492d4bc..6ea752c55 100644 --- a/src/components/NanoContract/NanoContractDetails.js +++ b/src/components/NanoContract/NanoContractDetails.js @@ -150,7 +150,9 @@ export const NanoContractDetails = ({ nc }) => { onRefresh={handleNewerTransactions} // Enables a button to load more of older transactions until the end // By reaching the end, the button ceases to render - ListFooterComponent={} + ListFooterComponent={( + + )} extraData={[isLoading, error]} /> )} @@ -163,12 +165,13 @@ export const NanoContractDetails = ({ nc }) => { * It hides the button when the last transaction is the initialize. * * @param {Object} prop Properties object + * @param {string} prop.ncId Nano Contract ID (from the registered contract) * @param {{ - * ncId: string; * txId: string; + * ncMethod: string; * }} prop.lastTx A transaction item from transaction history */ -const LoadMoreButton = ({ lastTx }) => { +const LoadMoreButton = ({ ncId, lastTx }) => { const dispatch = useDispatch(); const isInitializeTx = lastTx.ncMethod === 'initialize'; @@ -178,10 +181,10 @@ const LoadMoreButton = ({ lastTx }) => { */ const handleLoadMore = useCallback(() => { dispatch(nanoContractHistoryRequest({ - ncId: lastTx.ncId, + ncId, after: lastTx.txId, })); - }, [dispatch, lastTx]); + }, [dispatch, ncId, lastTx.txId]); return !isInitializeTx && ( Set of currently loading request types. + * + * This is necessary because the Redux state check (historyMeta.isLoading) has a race window + * between `yield select()` and `yield put()` where multiple sagas can pass the guard. + * + * @type {Map>} + */ +const loadingLockByNcId = new Map(); + +/** + * Gets or creates the Set of loading request types for a given ncId. + * @param {string} ncId Nano Contract ID + * @returns {Set} + */ +function getLoadingLockSet(ncId) { + if (!loadingLockByNcId.has(ncId)) { + loadingLockByNcId.set(ncId, new Set()); + } + return loadingLockByNcId.get(ncId); +} + +/** + * Removes a request type from the loading lock set and cleans up if empty. + * @param {string} ncId Nano Contract ID + * @param {string} requestType The request type to remove + */ +function cleanupLoadingLock(ncId, requestType) { + const loadingLock = loadingLockByNcId.get(ncId); + if (loadingLock) { + loadingLock.delete(requestType); + if (loadingLock.size === 0) { + loadingLockByNcId.delete(ncId); + } + } +} + export const failureMessage = { alreadyRegistered: t`Nano Contract already registered.`, walletNotReadyError: t`Wallet is not ready yet to register a Nano Contract.`, @@ -334,39 +381,59 @@ export function* requestHistoryNanoContract({ payload }) { const { ncId, before, after } = payload; log.debug('Start processing request for nano contract transaction history...'); - const historyMeta = yield select((state) => state.nanoContract.historyMeta); - if (historyMeta[ncId] && historyMeta[ncId].isLoading) { - // Do nothing if nano contract already loading... - log.debug('Halting processing for nano contract transaction history request while it is loading...'); - return; - } - yield put(nanoContractHistoryLoading({ ncId })); - - const wallet = yield select((state) => state.wallet); - if (!wallet.isReady()) { - log.debug('Fail fetching Nano Contract history because wallet is not ready.'); - yield put(nanoContractHistoryFailure({ ncId, error: failureMessage.walletNotReadyError })); - // This will show user an error modal with the option to send the error to sentry. - yield put(onExceptionCaptured(new Error(failureMessage.walletNotReadyError), false)); - return; + // Determine request type: 'before' (newer txs), 'after' (older txs), or 'initial' (full reload) + let requestType = REQUEST_TYPE.INITIAL; + if (before != null) { + requestType = REQUEST_TYPE.BEFORE; + } else if (after != null) { + requestType = REQUEST_TYPE.AFTER; } - - const fn = wallet.storage.isNanoContractRegistered.bind(wallet.storage); - const isNcRegistered = yield call(fn, ncId); - if (!isNcRegistered) { - log.debug('Fail fetching Nano Contract history because Nano Contract is not registered yet.'); - yield put(nanoContractHistoryFailure({ ncId, error: failureMessage.notRegistered })); + const loadingLock = getLoadingLockSet(ncId); + + // Synchronous lock check - prevents race condition between yield select() and yield put() + // Block conditions: + // 1. Same request type already in-flight (duplicate request) + // 2. Initial is loading (it will replace everything, so other requests should wait) + // 3. This is initial but before/after are in-flight (their results would be lost) + const shouldBlock = loadingLock.has(requestType) + || loadingLock.has(REQUEST_TYPE.INITIAL) + || (requestType === REQUEST_TYPE.INITIAL && loadingLock.size > 0); + + if (shouldBlock) { + log.debug(`Halting: conflicting history load for ncId=${ncId}, type=${requestType}, current=[${[...loadingLock]}]`); return; } - if (before == null && after == null) { - // it clean the history when starting load from the beginning - log.debug('Cleaning previous history to start over.'); - yield put(nanoContractHistoryClean({ ncId })); - } + // Acquire lock synchronously before any yield + loadingLock.add(requestType); - const useWalletService = yield select((state) => state.useWalletService); try { + yield put(nanoContractHistoryLoading({ ncId })); + + const wallet = yield select((state) => state.wallet); + if (!wallet.isReady()) { + log.debug('Fail fetching Nano Contract history because wallet is not ready.'); + yield put(nanoContractHistoryFailure({ ncId, error: failureMessage.walletNotReadyError })); + // This will show user an error modal with the option to send the error to sentry. + yield put(onExceptionCaptured(new Error(failureMessage.walletNotReadyError), false)); + return; + } + + const fn = wallet.storage.isNanoContractRegistered.bind(wallet.storage); + const isNcRegistered = yield call(fn, ncId); + if (!isNcRegistered) { + log.debug('Fail fetching Nano Contract history because Nano Contract is not registered yet.'); + yield put(nanoContractHistoryFailure({ ncId, error: failureMessage.notRegistered })); + return; + } + + if (before == null && after == null) { + // it clean the history when starting load from the beginning + log.debug('Cleaning previous history to start over.'); + yield put(nanoContractHistoryClean({ ncId })); + } + + const useWalletService = yield select((state) => state.useWalletService); const req = { wallet, useWalletService, @@ -396,6 +463,9 @@ export function* requestHistoryNanoContract({ payload }) { ); // give opportunity for users to send the error to our team yield put(onExceptionCaptured(error, false)); + } finally { + // Always release the lock, even on error or early return + cleanupLoadingLock(ncId, requestType); } }