mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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 in08fdea1772/d6880d47dato fix #4783 but was unintentionally dropped in0e9218bd68, 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:
parent
f89fe1ebc3
commit
05cd875dd6
3 changed files with 75 additions and 8 deletions
|
|
@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue