I’ve encountered an issue where the “Unsaved Changes” confirmation dialog appears incorrectly when quickly navigating between different pages.
The Symptom
Sometimes, if I navigate to an Overview page and immediately switch to a different page before the first one finishes loading (e.g., correcting a mis-click), I am interrupted by this popup:
Title: Unsaved Changes
Body: There are some unsaved changes in the editor. They will be lost. What do you want to do?
Investigation
I reproduced this in the isolated tutorial app to rule out any custom code in my project. Using Redux Dev Tools, I observed that a standard filter action triggers this sequence:
OverviewEngine/Event/onFilterChanged
OverviewEngine/COMMAND/setQueryParameters
Activity/LOCK
Activity/LOAD_DATA … (Data processing) …
Activity/UNLOCK
The Root Cause
The issue is a race condition. If the user navigates away while the state is between Activity/LOCK and Activity/UNLOCK, the application interprets the “Locked” state as a “Dirty” state.
This triggers handleDirtyState in dirtyHandling.js:
export function* handleDirtyState({ payload: { activityIds, replacementActivity } }) {
// ... validation logic
for (const activityId of orderedActivityIdsToCancel) {
const activity = yield* select(ActivitySelectors.activityById(activityId));
// ... existence check
// PROBLEM: This selector grabs Locked activities as well as Dirty ones
const dirtySubTree = yield* select(ActivitySelectors.allDirtyOrLockedActivities(activity));
if (dirtySubTree.length > 0) {
// This dispatches the confirmation popup because the activity is Locked
yield* put(ActivityActions.setCancelConfirmationRequired({
activityId: activity.id,
cancelConfirmationRequired: true
}));
// ...
Because the activity is technically “Locked” during the load, dirtySubTree returns a value >0, forcing the “Unsaved Changes” popup even though the user hasn’t actually edited anything.
Hi @i.ursu,
It’s occurs because of the ActivitySelectors.allDirtyOrLockedActivities conflates Locked (loading) and Dirty (unsaved changes) states as you already see it, which triggers the confirmation dialog incorrectly.
As a temporary workaround, you can distinguish between Locked and Dirty states before showing the confirmation dialog.
Instead of relying on allDirtyOrLockedActivities(), check each activity’s dirty state individually using ActivitySelectors.dirty(id)
function* discardChangesAndNavigateToNextActivity(action: Action<ClickMenuActionPayload>): SagaIterator {
const activityMap = yield* select(ActivitySelectors.activities());
const allActivities = ActivityMap.toList(activityMap);
const activityIds = getCancelledActivityList(allActivities);
// Check INDIVIDUALLY if activities have actual unsaved changes
// This separates Dirty (unsaved) from Locked (loading) states
let hasUnsavedChanges = false;
for (const id of activityIds) {
const isDirty = yield* select(ActivitySelectors.dirty(id));
if (isDirty) {
hasUnsavedChanges = true;
break;
}
}
if (hasUnsavedChanges) {
// Only show confirmation dialog if there are ACTUAL unsaved changes
yield* put(ActivityActions.cancelRequested({ activityIds }));
const cancelled: boolean = yield* call(ActivitySagas.waitForResponseCancelRequested);
if (cancelled) {
yield* put(ActivityActions.create({
activityDescriptor: action.payload.nextActivityDescriptor
}));
}
} else {
// Direct cancel for locked/loading activities - NO dialog
for (const activityId of activityIds) {
yield* put(ActivityActions.cancel({ activityId }));
}
yield* put(ActivityActions.create({
activityDescriptor: action.payload.nextActivityDescriptor
}));
}
}
Hope it helps in your case, and we will have a ticket to modify it in the future.
Thank you.
For completeness, the A12 bug ticket for this issue is A12-18127. We will update this thread with more information regarding the ticket once it becomes available.