fix: keep sync on when it is turned back on during a turn-off #34

Turning device sync off uploads what the device changed and then deletes
This commit is contained in:
Linus Rath
2026-09-27 07:19:26 +02:00
parent c777dc4089
commit 2b9d19521f
2 changed files with 53 additions and 3 deletions
@@ -260,6 +260,26 @@ describe('disable', () => {
expect(stored.reminderOwner).toBeNull();
});
it('lets a turn-on wait for a turn-off that is still tearing down, instead of being undone by it', async () => {
await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
let finishTeardown: () => void = () => undefined;
teardown.mockImplementationOnce(async (_registryId, name, authority, options) => {
h.state.log.push(`teardown ${name} ${authority} ${JSON.stringify(options ?? {})}`);
await new Promise<void>((resolve) => { finishTeardown = resolve; });
return { pending: 0 };
});
const turningOff = disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
await vi.waitFor(() => expect(h.state.log).toContain(`teardown alice ${CONTACTS_AUTHORITY} {"uploadFirst":true,"timeoutMs":${TEARDOWN_TIMEOUT_MS}}`));
// The user opens the settings again and turns contacts sync back on.
const turningOn = enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
finishTeardown();
expect(await turningOff).toEqual({ done: true, pending: 0 });
expect(await turningOn).toMatchObject({ kind: 'enabled' });
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY]?.automatic).toBe(true);
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(true);
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
});
it('removes the Android account once both authorities are off', async () => {
await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
+33 -3
View File
@@ -162,6 +162,24 @@ async function refreshPushState(registryId: string): Promise<void> {
await refreshPushSubscriptionTypes(registryId).catch(noop);
}
// ─── One step at a time ────────────────────────────────
// Turning sync on and off for one app account runs one step after the other:
// a turn-on that starts while a turn-off still tears down (the user left the
// settings and came back) would otherwise be undone by it, down to the
// Android account being removed right after it was turned on again.
const accountSteps = new Map<string, Promise<unknown>>();
function oneAtATime<T>(registryId: string, step: () => Promise<T>): Promise<T> {
const next = (accountSteps.get(registryId) ?? Promise.resolve()).catch(noop).then(step);
const settled = next.catch(noop);
accountSteps.set(registryId, settled);
void settled.then(() => {
if (accountSteps.get(registryId) === settled) accountSteps.delete(registryId);
});
return next;
}
// ─── Enable ────────────────────────────────────────────
export type EnableOutcome =
@@ -174,7 +192,11 @@ export type EnableOutcome =
* Turns device sync on for one authority of an app account. The caller has
* asked for the permissions (and, for calendars, who reminds the user).
*/
export async function enableDeviceSync(registryId: string, authority: Authority): Promise<EnableOutcome> {
export function enableDeviceSync(registryId: string, authority: Authority): Promise<EnableOutcome> {
return oneAtATime(registryId, () => enable(registryId, authority));
}
async function enable(registryId: string, authority: Authority): Promise<EnableOutcome> {
if (!deviceSyncAvailable()) return { kind: 'failed', message: 'Device sync is not available' };
if (!(await hasSyncPermissions(authority))) return { kind: 'permission' };
await waitForDeviceSyncHydration();
@@ -207,7 +229,11 @@ export async function enableDeviceSync(registryId: string, authority: Authority)
}
/** Automatic sync back on after the user kept syncing instead of losing changes. */
export async function resumeDeviceSync(registryId: string, authority: Authority): Promise<void> {
export function resumeDeviceSync(registryId: string, authority: Authority): Promise<void> {
return oneAtATime(registryId, () => resume(registryId, authority));
}
async function resume(registryId: string, authority: Authority): Promise<void> {
if (!deviceSyncAvailable()) return;
await waitForDeviceSyncHydration();
const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name;
@@ -285,11 +311,15 @@ function withoutAuthorityStates(entry: AccountDeviceSync, authority: Authority):
* how many changes are waiting: call again with `force` once the user agreed
* to lose them, or `resumeDeviceSync` to keep syncing.
*/
export async function disableDeviceSync(
export function disableDeviceSync(
registryId: string,
authority: Authority,
options: { force?: boolean } = {},
): Promise<TurnOffResult> {
return oneAtATime(registryId, () => disable(registryId, authority, options));
}
async function disable(registryId: string, authority: Authority, options: { force?: boolean }): Promise<TurnOffResult> {
if (!deviceSyncAvailable()) return { done: true, pending: 0 };
await waitForDeviceSyncHydration();
const store = useDeviceSyncStore.getState();