Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const FeedbackBarComponentPlain = ({ feedback, onClose }: Props) => {
return null;
}

const { message, displayType, variant } = feedback;
const { id, message, displayType, variant } = feedback;
const isAlertBarOpen = typeof message === 'string' && !displayType;
const isDialogOpen = typeof message === 'object' && displayType === 'dialog';
const alertVariant = getAlertVariant(variant);
Expand All @@ -33,7 +33,7 @@ const FeedbackBarComponentPlain = ({ feedback, onClose }: Props) => {
<>
<AlertStack>
{isAlertBarOpen && (
<AlertBar {...alertVariant} duration={5000}>
<AlertBar key={id} {...alertVariant} duration={5000} onHidden={onClose}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Potential race condition between onHidden and new feedback arriving

The new onHidden={onClose} on the AlertBar (FeedbackBar.component.tsx:36) dispatches CLOSE_FEEDBACK when the alert finishes hiding. If a new error arrives while an old AlertBar is mid-hide-animation (timer expired but onHidden not yet called), the key change causes React to unmount the old AlertBar and mount a new one. If the old AlertBar's onHidden fires during or after unmount, it would dispatch CLOSE_FEEDBACK and shift the NEW error off the state at feedback.reducerDescriptionGetter.ts:61-64, causing the new error to disappear immediately. This depends on whether dhis2/ui's AlertBar properly cancels its hide callback on unmount. Standard React cleanup patterns should prevent this, but it's worth verifying with the AlertBar implementation.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified against @dhis2-ui/alert's AlertBar: its useEffect returns clearAllTimeouts as cleanup, so a key change unmounts the old bar and synchronously clears the pending hideTimeout before remove() (which calls onHidden) can fire. The race isn't reachable — remove() is purely synchronous and JS is single-threaded, so onHidden cannot fire after unmount.

{message}
</AlertBar>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ReactNode } from 'react';

export type Feedback = {
id: string;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
message: string | { title: string; content: string };
action?: ReactNode;
displayType?: 'alert' | 'dialog';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import log from 'loglevel';
import i18n from '@dhis2/d2-i18n';
import isString from 'd2-utilizr/lib/isString';
import isObject from 'd2-utilizr/lib/isObject';
import uuid from 'd2-utilizr/lib/uuid';
import { errorCreator } from 'capture-core-utils';
import { createReducerDescription } from '../../trackerRedux/trackerReducer';
import { actionTypes as feedbackActionTypes } from '../../components/FeedbackBar/actions/feedback.actions';
Expand Down Expand Up @@ -41,31 +42,19 @@ type ErrorFeedbackInput = {
action?: ReactNode,
};

function addErrorFeedback(
state: any,
{ message, variant = alertVariants.critical as keyof typeof alertVariants, action }: ErrorFeedbackInput,
) {
return [
...state,
{
message,
action,
feedbackType: 'ERROR',
variant,
},
];
}
const getErrorFeedback = ({
message,
variant = 'critical',
action,
}: ErrorFeedbackInput) => ({
id: uuid(),
message,
action,
feedbackType: 'ERROR' as const,
variant,
});

function getErrorFeedback(
{ message, variant = alertVariants.critical as keyof typeof alertVariants, action }: ErrorFeedbackInput,
) {
return {
message,
action,
feedbackType: 'ERROR',
variant,
};
}
const addErrorFeedback = (input: ErrorFeedbackInput) => [getErrorFeedback(input)];
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot May 28, 2026

Choose a reason for hiding this comment

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

📝 Info: Behavioral change: feedback queue replaced with single-item replacement

The old addErrorFeedback accumulated items via [...state, newItem], forming a FIFO queue where CLOSE_FEEDBACK would shift items to reveal the next. The new version (feedback.reducerDescriptionGetter.ts:57) always returns [singleItem], discarding any existing feedback. This is actually a sensible fix because the old queue was never properly drained — AlertBar-type feedbacks had no mechanism to dispatch CLOSE_FEEDBACK (no onHidden was wired up), so the queue grew indefinitely while only feedbacks[0] was ever displayed. The new approach pairs correctly with the onHidden={onClose} addition.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

@henrikmv henrikmv May 29, 2026

Choose a reason for hiding this comment

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

The replacement behaviour is intentional.

The 5000ms auto-dismiss only applies to non-critical AlertBars- DHIS2 UI's AlertBar requires the user to manually close critical alerts. With the old queue, when multiple errors fired, users had to dismiss each stale message one by one.

Replacing instead of appending means the user sees the most recent, most actionable error - not a backlog of errors.


export const getFeedbackDesc = (appUpdaters: Updaters) => createReducerDescription({
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
...appUpdaters,
Expand All @@ -74,80 +63,61 @@ export const getFeedbackDesc = (appUpdaters: Updaters) => createReducerDescripti
newState.shift();
return newState;
},
[dataEntryActionTypes.COMPLETE_EVENT_ERROR]: (state, action) =>
addErrorFeedback(state, {
[dataEntryActionTypes.COMPLETE_EVENT_ERROR]: (_state, action) =>
addErrorFeedback({
message: action.payload.error,
action: action.payload.action,
}),
[enrollmentActionTypes.ENROLLMENT_LOAD_FAILED]: (state, action) =>
addErrorFeedback(state, action.payload),
[workingListsCommonActionTypes.LIST_VIEW_INIT_ERROR]: (state, action) =>
addErrorFeedback(state, action.payload.errorMessage),
[newEventDataEntryActionTypes.SAVE_FAILED_FOR_NEW_EVENT_AFTER_RETURNED_TO_MAIN_PAGE]: (state, action) => {
[enrollmentActionTypes.ENROLLMENT_LOAD_FAILED]: (_state, action) =>
addErrorFeedback(action.payload),
[workingListsCommonActionTypes.LIST_VIEW_INIT_ERROR]: (_state, action) =>
addErrorFeedback({ message: action.payload.errorMessage }),
[newEventDataEntryActionTypes.SAVE_FAILED_FOR_NEW_EVENT_AFTER_RETURNED_TO_MAIN_PAGE]: (_state, action) => {
const error = action.payload;
const errorMessage = isString(error) ? error : error.message;
const errorObject = isObject(error) ? error : null;
log.error(errorCreator(errorMessage || 'Error saving event')(errorObject));
const newState = [
...state,
getErrorFeedback({ message: i18n.t('Could not save event') }),
];
return newState;
return addErrorFeedback({ message: i18n.t('Could not save event') });
},
[workingListsCommonActionTypes.LIST_UPDATE_ERROR]: (state, action) => [
...state,
getErrorFeedback({ message: action.payload.errorMessage }),
],
[eventWorkingListsActionTypes.EVENT_DELETE_ERROR]: state => [
...state,
getErrorFeedback({ message: i18n.t('Could not delete event') }),
],

[workingListsCommonActionTypes.TEMPLATE_UPDATE_ERROR]: state => [
...state,
getErrorFeedback({ message: i18n.t('Could not save working list') }),
],
[workingListsCommonActionTypes.TEMPLATE_ADD_ERROR]: state => [
...state,
getErrorFeedback({ message: i18n.t('Could not add working list') }),
],
[workingListsCommonActionTypes.TEMPLATE_DELETE_ERROR]: state => [
...state,
getErrorFeedback({ message: i18n.t('Could not delete working list') }),
],
[asyncHandlerActionTypes.ASYNC_UPDATE_FIELD_FAILED]: (state, { payload }) =>
addErrorFeedback(state, { message: payload.message }),
[newEventDataEntryActionTypes.SAVE_FAILED_FOR_NEW_EVENT_ADD_ANOTHER]: (state, action) => {
[workingListsCommonActionTypes.LIST_UPDATE_ERROR]: (_state, action) =>
addErrorFeedback({ message: action.payload.errorMessage }),
[eventWorkingListsActionTypes.EVENT_DELETE_ERROR]: () =>
addErrorFeedback({ message: i18n.t('Could not delete event') }),
[workingListsCommonActionTypes.TEMPLATE_UPDATE_ERROR]: () =>
addErrorFeedback({ message: i18n.t('Could not save working list') }),
[workingListsCommonActionTypes.TEMPLATE_ADD_ERROR]: () =>
addErrorFeedback({ message: i18n.t('Could not add working list') }),
[workingListsCommonActionTypes.TEMPLATE_DELETE_ERROR]: () =>
addErrorFeedback({ message: i18n.t('Could not delete working list') }),
[asyncHandlerActionTypes.ASYNC_UPDATE_FIELD_FAILED]: (_state, { payload }) =>
addErrorFeedback({ message: payload.message }),
[newEventDataEntryActionTypes.SAVE_FAILED_FOR_NEW_EVENT_ADD_ANOTHER]: (_state, action) => {
const error = action.payload;
const errorMessage = isString(error) ? error : error.message;
const errorObject = isObject(error) ? error : null;
log.error(errorCreator(errorMessage || 'Error saving event')(errorObject));
const newState = [
...state,
getErrorFeedback({ message: i18n.t('Could not save event') }),
];
return newState;
return addErrorFeedback({ message: i18n.t('Could not save event') });
},
[dataEntryActionTypes.DATA_ENTRY_RELATIONSHIP_ALREADY_EXISTS]: (state, action) =>
addErrorFeedback(state, { message: action.payload.message }),
[viewEventNewRelationshipActionTypes.EVENT_RELATIONSHIP_ALREADY_EXISTS]: (state, action) =>
addErrorFeedback(state, { message: action.payload.message }),
[registrationSectionActionTypes.ORG_UNIT_SEARCH_FAILED]: state =>
addErrorFeedback(state, { message: i18n.t('Organisation unit search failed.') }),
[registrationFormActionTypes.NEW_TRACKED_ENTITY_INSTANCE_SAVE_FAILED]: state =>
addErrorFeedback(state, { message: i18n.t('Error saving tracked entity instance') }),
[registrationFormActionTypes.NEW_TRACKED_ENTITY_INSTANCE_WITH_ENROLLMENT_SAVE_FAILED]: state =>
addErrorFeedback(state, { message: i18n.t('Error saving enrollment') }),
[enrollmentSiteActionTypes.SAVE_FAILED]: state =>
addErrorFeedback(state, { message: i18n.t('Error saving the enrollment event') }),
[editEventActionTypes.DELETE_EVENT_DATA_ENTRY_FAILED]: state =>
addErrorFeedback(state, { message: i18n.t('Error deleting the enrollment event') }),
[editEventDataEntryAction.SAVE_EDIT_EVENT_DATA_ENTRY_FAILED]: state =>
addErrorFeedback(state, { message: i18n.t('Error editing the event, the changes made were not saved') }),
[enrollmentSiteActionTypes.ERROR_ENROLLMENT]: (state, action) =>
addErrorFeedback(state, { message: i18n.t(action.payload.message) }),
[viewEventActionTypes.ASSIGNEE_SAVE_FAILED]: state =>
addErrorFeedback(state, { message: i18n.t('Error updating the Assignee') }),
[enrollmentEditEventActionTypes.ASSIGNEE_SAVE_FAILED]: state =>
addErrorFeedback(state, { message: i18n.t('Error updating the Assignee') }),
[dataEntryActionTypes.DATA_ENTRY_RELATIONSHIP_ALREADY_EXISTS]: (_state, action) =>
addErrorFeedback({ message: action.payload.message }),
[viewEventNewRelationshipActionTypes.EVENT_RELATIONSHIP_ALREADY_EXISTS]: (_state, action) =>
addErrorFeedback({ message: action.payload.message }),
[registrationSectionActionTypes.ORG_UNIT_SEARCH_FAILED]: () =>
addErrorFeedback({ message: i18n.t('Organisation unit search failed.') }),
[registrationFormActionTypes.NEW_TRACKED_ENTITY_INSTANCE_SAVE_FAILED]: () =>
addErrorFeedback({ message: i18n.t('Error saving tracked entity instance') }),
[registrationFormActionTypes.NEW_TRACKED_ENTITY_INSTANCE_WITH_ENROLLMENT_SAVE_FAILED]: () =>
addErrorFeedback({ message: i18n.t('Error saving enrollment') }),
[enrollmentSiteActionTypes.SAVE_FAILED]: () =>
addErrorFeedback({ message: i18n.t('Error saving the enrollment event') }),
[editEventActionTypes.DELETE_EVENT_DATA_ENTRY_FAILED]: () =>
addErrorFeedback({ message: i18n.t('Error deleting the enrollment event') }),
[editEventDataEntryAction.SAVE_EDIT_EVENT_DATA_ENTRY_FAILED]: () =>
addErrorFeedback({ message: i18n.t('Error editing the event, the changes made were not saved') }),
[enrollmentSiteActionTypes.ERROR_ENROLLMENT]: (_state, action) =>
addErrorFeedback({ message: i18n.t(action.payload.message) }),
[viewEventActionTypes.ASSIGNEE_SAVE_FAILED]: () =>
addErrorFeedback({ message: i18n.t('Error updating the Assignee') }),
[enrollmentEditEventActionTypes.ASSIGNEE_SAVE_FAILED]: () =>
addErrorFeedback({ message: i18n.t('Error updating the Assignee') }),
}, 'feedbacks', []);
20 changes: 10 additions & 10 deletions src/reducers/descriptions/feedback.reducerDescription.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import i18n from '@dhis2/d2-i18n';
import uuid from 'd2-utilizr/lib/uuid';
import { getFeedbackDesc } from 'capture-core/reducers/descriptions/feedback.reducerDescriptionGetter';
import { getMainStorageController } from 'capture-core/storageControllers';
import { appStartActionTypes } from '../../components/AppStart';
Expand All @@ -7,17 +8,16 @@ export const feedbackDesc = getFeedbackDesc({
[appStartActionTypes.APP_LOAD_SUCESS]: (state) => {
const storageController = getMainStorageController();
if (storageController.Adapters[0] !== storageController.adapterType) {
return [
...state, {
message: {
title: i18n.t('Compatibility mode'),
// eslint-disable-next-line max-len
content: i18n.t('This app is currently running in compatibility mode due to browser restrictions. For better performance, use another browser or exit private mode if this is currently in use.'),
},
feedbackType: 'ERROR',
displayType: 'dialog',
return [{
id: uuid(),
message: {
title: i18n.t('Compatibility mode'),
// eslint-disable-next-line max-len
content: i18n.t('This app is currently running in compatibility mode due to browser restrictions. For better performance, use another browser or exit private mode if this is currently in use.'),
},
];
feedbackType: 'ERROR',
displayType: 'dialog',
}];
}
return state;
},
Expand Down
Loading