diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 000d8e0..543620c 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -107,7 +107,17 @@ export function AppProvider({ children }: { children: ReactNode }) { const refresh = useCallback(async () => { const fresh = await api.getState(); - setState(fresh); + // The 5s poll must be silent when nothing changed: a fresh object + // identity every tick would re-render every screen and refire effects + // keyed on state slices (e.g. Home's publications loader flickering + // between "Loading…" and done forever). + setState((prev) => { + try { + return JSON.stringify(prev) === JSON.stringify(fresh) ? prev : fresh; + } catch { + return fresh; + } + }); }, []); useEffect(() => { @@ -147,10 +157,10 @@ export function AppProvider({ children }: { children: ReactNode }) { // getState is a cheap local vault read; errors are ignored here since every // screen surfaces its own request failures. useEffect(() => { - const timer = window.setInterval(() => { + const timer = setInterval(() => { void refresh().catch(() => {}); }, 5000); - return () => window.clearInterval(timer); + return () => clearInterval(timer); }, [refresh]); const createProfile = useCallback( diff --git a/frontend/src/test/HomeScreen.test.tsx b/frontend/src/test/HomeScreen.test.tsx index 88e4e13..9ba0a1f 100644 --- a/frontend/src/test/HomeScreen.test.tsx +++ b/frontend/src/test/HomeScreen.test.tsx @@ -98,4 +98,31 @@ describe('HomeScreen', () => { const aliceRow = screen.getByText('Alice').closest('.home-profile-row') as HTMLElement; expect(within(aliceRow).getByRole('button', { name: 'Select' })).toBeInTheDocument(); }); + + it('does not refetch publications on every background state poll', async () => { + // Regression: the 5s AppProvider poll must not refire the publications + // loader (it flickered Home between "Loading…" and done forever). + // Fake timers from the start: the poll interval must be scheduled under + // fake time, and RTL async queries stall under fake timers, so drive + // everything with explicit timer advances instead. + vi.useFakeTimers(); + try { + const backend = createFakeBackend(); + renderHome(backend); + renderWithApp( + , + ); + + // Initial load completes. + await vi.advanceTimersByTimeAsync(500); + const initialFetches = backend.requests.filter((r) => r.method === 'feed_get').length; + expect(initialFetches).toBeGreaterThan(0); + + // Two full poll ticks with unchanged state must not refetch. + await vi.advanceTimersByTimeAsync(12000); + expect(backend.requests.filter((r) => r.method === 'feed_get').length).toBe(initialFetches); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 1cab35c..47e6954 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -193,7 +193,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend { switch (method) { case 'init': case 'get_state': - return state; + // Deep-copy like the real IPC boundary (fresh JSON per call), so + // tests observe new object identities exactly as production does. + return structuredClone(state); // NIP-46 client handshake surface used by SignerModeScreen. The fake // keeps a Nip46SignerStatus-shaped object so handshake-state tests