Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/components/NanoContract/NanoContractDetails.js
Original file line number Diff line number Diff line change
Expand Up @@ -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={<LoadMoreButton lastTx={txHistory.slice(-1,).pop()} />}
ListFooterComponent={(
<LoadMoreButton ncId={nc.ncId} lastTx={txHistory.slice(-1,).pop()} />
)}
extraData={[isLoading, error]}
/>
)}
Expand All @@ -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';

Expand All @@ -178,10 +181,10 @@ const LoadMoreButton = ({ lastTx }) => {
*/
const handleLoadMore = useCallback(() => {
dispatch(nanoContractHistoryRequest({
ncId: lastTx.ncId,
ncId,
Comment on lines -181 to +184
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The history of a nano returns also txs of other contracts that execute methods of this contract, so lastTx.ncId was a different of ncId in some of the txs.

after: lastTx.txId,
}));
}, [dispatch, lastTx]);
}, [dispatch, ncId, lastTx.txId]);

return !isInitializeTx && (
<NewHathorButton
Expand Down
115 changes: 88 additions & 27 deletions src/sagas/nanoContract.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,44 @@ import { isNanoContractsEnabled, getResultHelper } from '../utils';

const log = logger('nano-contract-saga');

/**
* Synchronous lock mechanism to prevent race conditions in concurrent history requests.
* Maps ncId -> Set of currently loading request types ('initial', 'before', 'after').
*
* 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<string, Set<'initial' | 'before' | 'after'>>}
*/
const loadingByNcId = new Map();

/**
* Gets or creates the Set of loading request types for a given ncId.
* @param {string} ncId Nano Contract ID
* @returns {Set<string>}
*/
function getLoadingSet(ncId) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): Rename to getLoadingLockSet and the variables that fetch from here to loadingLock.

It makes it easier to understand, since most other places with loading in this codebase refer to a component state related to a spinner for user feedback.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much better, thanks! c99b622

if (!loadingByNcId.has(ncId)) {
loadingByNcId.set(ncId, new Set());
}
return loadingByNcId.get(ncId);
}

/**
* Removes a request type from the loading set and cleans up if empty.
* @param {string} ncId Nano Contract ID
* @param {string} requestType The request type to remove
*/
function cleanupLoading(ncId, requestType) {
const loading = loadingByNcId.get(ncId);
if (loading) {
loading.delete(requestType);
if (loading.size === 0) {
loadingByNcId.delete(ncId);
}
}
}

export const failureMessage = {
alreadyRegistered: t`Nano Contract already registered.`,
walletNotReadyError: t`Wallet is not ready yet to register a Nano Contract.`,
Expand Down Expand Up @@ -334,39 +372,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;
// Determine request type: 'before' (newer txs), 'after' (older txs), or 'initial' (full reload)
let requestType = 'initial';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): Create a constant or enum for this hardcoded string

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much better, thanks! c99b622

if (before != null) {
requestType = 'before';
} else if (after != null) {
requestType = 'after';
}
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 }));
const loading = getLoadingSet(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 = loading.has(requestType)
|| loading.has('initial')
|| (requestType === 'initial' && loading.size > 0);

if (shouldBlock) {
log.debug(`Halting: conflicting history load for ncId=${ncId}, type=${requestType}, current=[${[...loading]}]`);
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
loading.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,
Expand Down Expand Up @@ -396,6 +454,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
cleanupLoading(ncId, requestType);
}
}

Expand Down