feat: opt-in desktop setting to keep replication active in the background

Replication is suspended when the Obsidian window becomes hidden (document.hidden),
so LiveSync and Periodic stop syncing while minimised until the window is focused.

Add keepReplicationActiveInBackground (default off, desktop only). When enabled, the
window-visibility handler no longer suspends on hide, so replication keeps running while
minimised. Becoming visible forces a teardown before reopen (LiveSync only) so a stalled,
half-open channel is always replaced.

Includes the setting definition (src/lib submodule), a desktop-only toggle in the Sync
pane shown for LiveSync and Periodic, a docs/settings.md entry, and unit tests for the
visibility handler.
This commit is contained in:
Miguel Ferreira
2026-06-05 00:06:26 +01:00
parent 31050c9cb8
commit c78e583399
5 changed files with 220 additions and 4 deletions
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Unit tests stub out `obsidian`, so deps.ts (which re-exports from it) can't be loaded for real.
// ModuleObsidianEvents only needs `Platform` from deps at runtime; provide a mutable stub so each
// test can choose desktop vs mobile.
vi.mock("../../deps.ts", () => ({ Platform: { isDesktopApp: true } }));
import { Platform } from "../../deps.ts";
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@lib/common/types";
type SetupOptions = {
settings?: Partial<typeof DEFAULT_SETTINGS>;
hidden: boolean;
isLastHidden?: boolean;
hasFocus?: boolean;
isSuspended?: boolean;
};
function setup(opts: SetupOptions) {
const appLifecycle = {
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => opts.isSuspended ?? false),
onSuspending: vi.fn(async () => true),
onResuming: vi.fn(async () => true),
onResumed: vi.fn(async () => true),
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const core = {
_services: {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
},
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
isConfigured: true,
...opts.settings,
},
} as any;
Object.defineProperty(core, "services", { get: () => core._services });
const module = new ModuleObsidianEvents({} as any, core);
module.isLastHidden = opts.isLastHidden ?? false;
module.hasFocus = opts.hasFocus ?? true;
// The handler reads `activeWindow.document.hidden`.
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
return { module, appLifecycle, fileProcessing };
}
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
beforeEach(() => {
(Platform as any).isDesktopApp = true;
});
afterEach(() => {
// The handler reads a global `activeWindow`; the Platform mock is module-scoped. Both would
// otherwise leak into sibling spec files running in the same worker, so reset them here.
delete (globalThis as any).activeWindow;
(Platform as any).isDesktopApp = true;
});
it("does NOT suspend on hide when enabled in LiveSync mode on the desktop app", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
});
it("suspends on hide by default (setting off)", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: true },
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
it("forces onSuspending before the resume on becoming visible when enabled (LiveSync teardown)", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: false,
isLastHidden: true, // hidden -> visible transition
});
await module.watchWindowVisibilityAsync();
// Decision-logic only: on visible + enabled + LiveSync the handler calls onSuspending (the
// forced teardown) before onResuming. The actual stalled-channel replacement is exercised by
// the manual integration test, not here.
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
expect(appLifecycle.onSuspending.mock.invocationCallOrder[0]).toBeLessThan(
appLifecycle.onResuming.mock.invocationCallOrder[0]
);
});
it("does not force a teardown on becoming visible by default (setting off)", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: true },
hidden: false,
isLastHidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("does not apply in On-Events mode even if the flag is set (no scope leak)", async () => {
const { module, appLifecycle } = setup({
settings: {
keepReplicationActiveInBackground: true,
liveSync: false,
periodicReplication: false,
},
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
it("does NOT suspend on hide when enabled in Periodic mode (the periodic timer also stalls otherwise)", async () => {
const { module, appLifecycle } = setup({
settings: {
keepReplicationActiveInBackground: true,
liveSync: false,
periodicReplication: true,
},
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
});
it("does NOT force a teardown on becoming visible in Periodic mode (only the continuous channel can stall)", async () => {
const { module, appLifecycle } = setup({
settings: {
keepReplicationActiveInBackground: true,
liveSync: false,
periodicReplication: true,
},
hidden: false,
isLastHidden: true,
});
await module.watchWindowVisibilityAsync();
// The teardown is gated on liveSync: a periodic timer doesn't go half-open, so bouncing it
// on every restore would be needless churn. Resume still runs normally.
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("does not apply on a non-desktop app even if the flag is set", async () => {
(Platform as any).isDesktopApp = false;
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
});