fix(focus-mode): add skipDuringSync to prevent freeze on task done

The syncTrackingStopToSession$ effect was missing skipDuringSync(),
causing it to fire during sync operations. When sync was active and
a user marked a task as done, this created cascading action dispatches
that overwhelmed the store and froze the UI permanently.

Added skipDuringSync() to match the sibling effect syncTrackingStartToSession$.

Added 2 unit tests to verify the fix:
- Effect skips during sync (prevents freeze)
- Effect works normally when sync is not active
This commit is contained in:
Johannes Millan 2025-12-28 16:16:22 +01:00
parent 4d5c79f129
commit 80b925ef2f
2 changed files with 71 additions and 0 deletions

View file

@ -22,6 +22,7 @@ import {
} from '../../config/store/global-config.reducer';
import { updateGlobalConfigSection } from '../../config/store/global-config.actions';
import { take, toArray } from 'rxjs/operators';
import { HydrationStateService } from '../../../op-log/apply/hydration-state.service';
describe('FocusModeEffects', () => {
let actions$: Observable<any>;
@ -31,6 +32,7 @@ describe('FocusModeEffects', () => {
let taskServiceMock: any;
let globalConfigServiceMock: any;
let metricServiceMock: any;
let hydrationStateServiceMock: any;
let currentTaskId$: BehaviorSubject<string | null>;
const createMockTimer = (overrides: Partial<TimerState> = {}): TimerState => ({
@ -68,6 +70,12 @@ describe('FocusModeEffects', () => {
logFocusSession: jasmine.createSpy('logFocusSession'),
};
hydrationStateServiceMock = {
isApplyingRemoteOps: jasmine
.createSpy('isApplyingRemoteOps')
.and.returnValue(false),
};
TestBed.configureTestingModule({
providers: [
FocusModeEffects,
@ -106,6 +114,7 @@ describe('FocusModeEffects', () => {
provide: FocusModeStorageService,
useValue: { setLastCountdownDuration: jasmine.createSpy() },
},
{ provide: HydrationStateService, useValue: hydrationStateServiceMock },
],
});
@ -973,6 +982,67 @@ describe('FocusModeEffects', () => {
done();
}, 50);
});
it('should NOT dispatch when sync is applying remote operations (skipDuringSync)', (done) => {
// This test verifies the fix for the app freeze bug:
// When sync is active, the effect should be skipped to prevent
// cascading action dispatches that overwhelm the store.
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
});
store.overrideSelector(
selectors.selectTimer,
createMockTimer({ isRunning: true, purpose: 'work' }),
);
store.refreshState();
// Simulate sync being active
hydrationStateServiceMock.isApplyingRemoteOps.and.returnValue(true);
effects = TestBed.inject(FocusModeEffects);
currentTaskId$.next('task-123');
setTimeout(() => {
currentTaskId$.next(null);
}, 10);
setTimeout(() => {
// Effect should NOT fire during sync - this prevents the freeze bug
done();
}, 50);
});
it('should dispatch normally when sync completes (skipDuringSync allows)', (done) => {
// Verify the effect works normally when not during sync
store.overrideSelector(selectFocusModeConfig, {
isSyncSessionWithTracking: true,
isSkipPreparation: false,
});
store.overrideSelector(
selectors.selectTimer,
createMockTimer({ isRunning: true, purpose: 'work' }),
);
store.refreshState();
// Sync is NOT active
hydrationStateServiceMock.isApplyingRemoteOps.and.returnValue(false);
effects = TestBed.inject(FocusModeEffects);
currentTaskId$.next('task-123');
effects.syncTrackingStopToSession$.pipe(take(1)).subscribe((action) => {
expect(action.type).toEqual(actions.pauseFocusSession.type);
expect((action as any).pausedTaskId).toBe('task-123');
done();
});
setTimeout(() => {
currentTaskId$.next(null);
}, 10);
});
});
describe('syncSessionPauseToTracking$', () => {

View file

@ -116,6 +116,7 @@ export class FocusModeEffects {
// Only triggers when focus mode feature is enabled
syncTrackingStopToSession$ = createEffect(() =>
this.taskService.currentTaskId$.pipe(
skipDuringSync(),
pairwise(),
withLatestFrom(
this.store.select(selectFocusModeConfig),