False positive "Unsaved Changes" warning when navigating during data load

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:

  1. OverviewEngine/Event/onFilterChanged

  2. OverviewEngine/COMMAND/setQueryParameters

  3. Activity/LOCK

  4. Activity/LOAD_DATA … (Data processing) …

  5. 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.

P.S. The problem was more obvious in my application because I had some pre-set filters for overview models.

@denise-narrow-token we observed this behavior in our PoC, didn’t we? Do you remember what our solution was?

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.

Best regards,
Tin.

Thank you, @tin-rugged-rock, for sharing the PoC solution!

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.