fix(sync): restore periodic timer for file-based providers and add visibility-hidden trigger

Two related changes that close the data-loss window for Dropbox/WebDAV/LocalFile sync:

1. Restore the periodic interval timer (`timer(syncInterval, syncInterval)`) for
   file-based providers. The timer was added in 08fdea1772 / d6880d47da to fix
   #4783 but was unintentionally dropped in 0e9218bd68, a large 87-file commit
   whose stated purpose was data-validation and client-ID work. Without the
   timer, file-based sync only fires on user activity (focus/idle/online) — so
   changes made just before closing the app may never reach the remote.

2. Add an unthrottled `visibilitychange → hidden` trigger for non-Electron
   platforms. Previously this event was either absent (desktop browser) or
   throttled at 15 minutes alongside touch activity (iOS PWA), so backgrounding
   the app within the throttle window did not trigger a sync. Electron remains
   on its existing IPC-blocking close path (execBeforeCloseService).

Refs #7144, #4783
This commit is contained in:
Johannes Millan 2026-04-16 19:07:58 +02:00
parent f89fe1ebc3
commit 05cd875dd6
3 changed files with 75 additions and 8 deletions

View file

@ -288,4 +288,47 @@ describe('SyncTriggerService', () => {
expect(secondVal).toBe(true);
}));
});
describe('getSyncTrigger$', () => {
// syncInterval=10000 stays above SYNC_MIN_INTERVAL=5000 so the auditTime
// path doesn't fire faster than the periodic timer.
const SYNC_INTERVAL = 10000;
const DEBOUNCE = 100;
it('should fire periodically when useIntervalTimer=true (file-based providers)', fakeAsync(() => {
const emissions: unknown[] = [];
const sub = service
.getSyncTrigger$(SYNC_INTERVAL, true)
.subscribe((v) => emissions.push(v));
// Periodic timer fires at SYNC_INTERVAL; debounceTime tail adds DEBOUNCE
tick(SYNC_INTERVAL + DEBOUNCE + 50);
const afterFirstInterval = emissions.length;
expect(afterFirstInterval).toBeGreaterThan(0);
// Second periodic emission after another SYNC_INTERVAL
tick(SYNC_INTERVAL);
expect(emissions.length).toBeGreaterThan(afterFirstInterval);
sub.unsubscribe();
}));
it('should NOT fire periodically when useIntervalTimer=false (SuperSync)', fakeAsync(() => {
const emissions: unknown[] = [];
const sub = service
.getSyncTrigger$(SYNC_INTERVAL, false)
.subscribe((v) => emissions.push(v));
// After one syncInterval, the audit-time path may emit once.
tick(SYNC_INTERVAL + DEBOUNCE + 50);
const afterFirstInterval = emissions.length;
// After another full syncInterval, no further emissions
// (auditTime's of(null) source has completed; no periodic timer registered)
tick(SYNC_INTERVAL);
expect(emissions.length).toBe(afterFirstInterval);
sub.unsubscribe();
}));
});
});

View file

@ -87,12 +87,11 @@ export class SyncTriggerService {
)
: // FALLBACK we check if there was any kind of user interaction
// (otherwise sync might never be checked if there are no local data changes)
// NOTE: visibilitychange is handled separately by _visibilityHiddenTrigger$ —
// not throttled, so backgrounding always attempts a sync.
IS_TOUCH_PRIMARY
? merge(
fromEvent(window, 'touchstart'),
fromEvent(document, 'visibilitychange'),
).pipe(
mapTo('I_MOUSE_TOUCH_MOVE_OR_VISIBILITYCHANGE'),
? fromEvent(window, 'touchstart').pipe(
mapTo('I_TOUCH_ACTIVITY'),
throttleTime(USER_ACTIVITY_SYNC_THROTTLE_TIME),
)
: fromEvent(window, 'focus').pipe(
@ -133,6 +132,16 @@ export class SyncTriggerService {
mapTo('I_IS_ONLINE'),
);
// Fires when the page becomes hidden (tab switch, app backgrounding, close).
// Not throttled — a "last chance before close" trigger should always attempt a sync.
// Electron uses execBeforeCloseService (IPC-level blocking close) instead.
private _visibilityHiddenTrigger$: Observable<string | never> = !IS_ELECTRON
? fromEvent(document, 'visibilitychange').pipe(
filter(() => document.visibilityState === 'hidden'),
mapTo('I_VISIBILITY_HIDDEN'),
)
: EMPTY;
// OTHER INITIAL SYNC STUFF
// ------------------------
private _isInitialSyncEnabled$: Observable<boolean> =
@ -206,7 +215,10 @@ export class SyncTriggerService {
shareReplay(1),
);
getSyncTrigger$(syncInterval: number = SYNC_DEFAULT_AUDIT_TIME): Observable<unknown> {
getSyncTrigger$(
syncInterval: number = SYNC_DEFAULT_AUDIT_TIME,
useIntervalTimer = false,
): Observable<unknown> {
const _immediateSyncTrigger$: Observable<string> = IS_ANDROID_WEB_VIEW
? // ANDROID ONLY
merge(
@ -231,6 +243,14 @@ export class SyncTriggerService {
this._isOnlineTrigger$,
this._onIdleTrigger$,
this._onElectronResumeTrigger$,
this._visibilityHiddenTrigger$,
// Periodic interval timer: fires every syncInterval ms to detect external file
// changes (e.g. Syncthing on Linux) even without user activity.
// Only for file-based providers — SuperSync uses WebSocket push and doesn't need polling.
// Fixes: Linux auto-sync ignoring interval (#4783), Dropbox sync gaps (#7144)
...(useIntervalTimer
? [timer(syncInterval, syncInterval).pipe(mapTo('I_INTERVAL_TIMER'))]
: []),
);
return merge(
// once immediately

View file

@ -135,10 +135,14 @@ export class SyncEffects {
combineLatest([
this._syncWrapperService.isEnabledAndReady$,
this._syncWrapperService.syncInterval$,
this._syncWrapperService.syncProviderId$,
]).pipe(
switchMap(([isEnabledAndReady, syncInterval]) =>
switchMap(([isEnabledAndReady, syncInterval, providerId]) =>
isEnabledAndReady && syncInterval
? this._syncTriggerService.getSyncTrigger$(syncInterval)
? this._syncTriggerService.getSyncTrigger$(
syncInterval,
providerId !== SyncProviderId.SuperSync,
)
: EMPTY,
),
),