From 6c797f6e2cc733537c83d4c01ca4bef480aaa777 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:42:50 +0200 Subject: [PATCH] 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. --- src/widgets/__tests__/sync.test.ts | 32 ++++++++++++++++++++++++++++++ src/widgets/sync.ts | 16 ++++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/widgets/__tests__/sync.test.ts diff --git a/src/widgets/__tests__/sync.test.ts b/src/widgets/__tests__/sync.test.ts new file mode 100644 index 00000000..83b0049a --- /dev/null +++ b/src/widgets/__tests__/sync.test.ts @@ -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(); + }); +}); diff --git a/src/widgets/sync.ts b/src/widgets/sync.ts index d26b648e..f72c36bc 100644 --- a/src/widgets/sync.ts +++ b/src/widgets/sync.ts @@ -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();