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();