fix(ui): silent background state poll - no refetch churn when vault unchanged

This commit is contained in:
Avi 2026-09-23 15:09:37 -05:00
commit 3d1c30662a
3 changed files with 43 additions and 4 deletions

View file

@ -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(

View file

@ -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(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
// 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();
}
});
});

View file

@ -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