fix: refresh the widgets when the app goes to the background #52

Leaving the app scheduled the widget refresh with a zero-delay timer.
React Native pauses JS timers while the activity is in the background
(timers in the bridgeless runtime have no immediate path for a zero
delay), so the refresh only ran once the app came back or a widget task
happened to start. Switching accounts and pressing Home left the
widgets on the previous account's mail. The refresh now starts directly
from the app state change.
This commit is contained in:
Linus Rath
2026-10-02 11:42:50 +02:00
parent 7fe4a1ba27
commit 6c797f6e2c
2 changed files with 45 additions and 3 deletions
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const refreshSnapshot = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock('../build', () => ({ refreshSnapshot, readRegistry: vi.fn() }));
vi.mock('../render', () => ({ hasPlacedWidgets: vi.fn(async () => true), redrawAll: vi.fn(async () => undefined) }));
vi.mock('../state', () => ({ replaceAll: vi.fn() }));
const { handleAppState } = await import('../sync');
afterEach(() => {
vi.useRealTimers();
refreshSnapshot.mockClear();
});
describe('widget sync', () => {
it('refreshes when the app goes to the background without waiting for a timer', async () => {
// React Native pauses JS timers while the activity is in the background,
// so not even a zero-delay timer may stand between leaving the app and
// the refresh. Fake timers that never advance stand in for that.
vi.useFakeTimers();
handleAppState('background');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshSnapshot).toHaveBeenCalledTimes(1);
});
it('leaves the app coming back to the scheduled refreshes', async () => {
vi.useFakeTimers();
handleAppState('active');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshSnapshot).not.toHaveBeenCalled();
});
});
+13 -3
View File
@@ -35,6 +35,18 @@ function schedule(delayMs: number, urgent = false): void {
timer = setTimeout(() => { void run(); }, wait);
}
/**
* Leaving the app is the most likely moment to glance at a widget, so the
* widgets refresh right then. Not through a timer: React Native pauses JS
* timers while the app is in the background, so even a zero-delay timer
* would only fire when the app comes back.
*/
export function handleAppState(next: string): void {
if (next !== 'background') return;
if (timer) clearTimeout(timer);
void run();
}
/** Start following app state; returns the cleanup. No-op off Android. */
export function startWidgetSync(): () => void {
if (Platform.OS !== 'android') return () => {};
@@ -61,9 +73,7 @@ export function startWidgetSync(): () => void {
}
}),
];
const appState = AppState.addEventListener('change', (next) => {
if (next === 'background') schedule(0, true);
});
const appState = AppState.addEventListener('change', handleAppState);
return () => {
for (const unsubscribe of unsubscribers) unsubscribe();
appState.remove();