fix(effects): add skipDuringSync to task-ui effects and document all usages

Add skipDuringSync() to two selector-based effects in task-ui.effects.ts:
- timeEstimateExceeded$: Prevent spurious notifications during sync
- timeEstimateExceededDismissBanner$: Prevent unexpected banner dismissal

Add explanatory comments to all skipDuringSync() usages explaining why
each is needed:
- focus-mode.effects.ts: 3 effects (autoShowOverlay$, syncTrackingStartToSession$,
  syncTrackingStopToSession$)
- work-context.effects.ts: 1 effect (switchToTodayContextOnSpecialRoutes$)
- task-ui.effects.ts: 2 effects (new additions)

These are defensive measures to prevent selector-based effects from
triggering side effects during sync/hydration when store state changes
rapidly from remote operations.
This commit is contained in:
Johannes Millan 2025-12-28 17:45:21 +01:00
parent 8905701fa5
commit 140c46d40f
3 changed files with 16 additions and 0 deletions

View file

@ -61,6 +61,8 @@ export class FocusModeEffects {
this.store.select(selectFocusModeConfig),
this.store.select(selectIsFocusModeEnabled),
]).pipe(
// Prevent auto-showing overlay during sync - config/task state changes from
// remote ops would otherwise trigger the overlay unexpectedly
skipDuringSync(),
switchMap(([cfg, isFocusModeEnabled]) =>
isFocusModeEnabled && cfg?.isSyncSessionWithTracking && !cfg?.isStartInBackground
@ -81,6 +83,8 @@ export class FocusModeEffects {
this.store.select(selectFocusModeConfig),
this.store.select(selectIsFocusModeEnabled),
]).pipe(
// Prevent auto-starting/unpausing focus session during sync - currentTaskId
// changes from remote ops would otherwise start sessions unexpectedly
skipDuringSync(),
switchMap(([cfg, isFocusModeEnabled]) =>
isFocusModeEnabled && cfg?.isSyncSessionWithTracking
@ -116,6 +120,9 @@ export class FocusModeEffects {
// Only triggers when focus mode feature is enabled
syncTrackingStopToSession$ = createEffect(() =>
this.taskService.currentTaskId$.pipe(
// CRITICAL: Prevent cascading dispatches during sync that cause app freeze.
// Without this, rapid currentTaskId changes from remote ops trigger pairwise()
// which dispatches pauseFocusSession repeatedly, overwhelming the store.
skipDuringSync(),
pairwise(),
withLatestFrom(

View file

@ -30,6 +30,7 @@ import { GlobalConfigService } from '../../config/global-config.service';
import { playDoneSound } from '../util/play-done-sound';
import { Task } from '../task.model';
import { EMPTY } from 'rxjs';
import { skipDuringSync } from '../../../util/skip-during-sync.operator';
import { selectProjectById } from '../../project/store/project.selectors';
import { Router } from '@angular/router';
import { NavigateToTaskService } from '../../../core-ui/navigate-to-task/navigate-to-task.service';
@ -121,6 +122,9 @@ export class TaskUiEffects {
timeEstimateExceeded$ = createEffect(
() =>
this._store$.pipe(select(selectConfigFeatureState)).pipe(
// Prevent spurious notifications during sync - currentTask state changes
// from remote ops would otherwise trigger "time estimate exceeded" alerts
skipDuringSync(),
switchMap((globalCfg) =>
globalCfg && globalCfg.timeTracking.isNotifyWhenTimeEstimateExceeded
? // reset whenever the current taskId changes (but no the task data, which is polled afterwards)
@ -152,6 +156,9 @@ export class TaskUiEffects {
timeEstimateExceededDismissBanner$ = createEffect(
() =>
this._store$.pipe(select(selectConfigFeatureState)).pipe(
// Prevent unexpected banner dismissal during sync - currentTaskId changes
// from remote ops would otherwise dismiss the time estimate exceeded banner
skipDuringSync(),
switchMap((globalCfg) =>
globalCfg && globalCfg.timeTracking.isNotifyWhenTimeEstimateExceeded
? this._bannerService.activeBanner$.pipe(

View file

@ -47,6 +47,8 @@ export class WorkContextEffects {
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event) => event.urlAfterRedirects),
startWith(this._router.url), // Handle initial page load
// Prevent dispatching setActiveWorkContext during sync - startWith() emits
// immediately on subscription which could interfere with sync state hydration
skipDuringSync(),
filter(
(url) =>