fix(ui): silent background state poll - no refetch churn when vault unchanged
This commit is contained in:
parent
5714349ea3
commit
3d1c30662a
3 changed files with 43 additions and 4 deletions
|
|
@ -107,7 +107,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const fresh = await api.getState();
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -147,10 +157,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
// getState is a cheap local vault read; errors are ignored here since every
|
// getState is a cheap local vault read; errors are ignored here since every
|
||||||
// screen surfaces its own request failures.
|
// screen surfaces its own request failures.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
void refresh().catch(() => {});
|
void refresh().catch(() => {});
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return () => window.clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const createProfile = useCallback(
|
const createProfile = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -98,4 +98,31 @@ describe('HomeScreen', () => {
|
||||||
const aliceRow = screen.getByText('Alice').closest('.home-profile-row') as HTMLElement;
|
const aliceRow = screen.getByText('Alice').closest('.home-profile-row') as HTMLElement;
|
||||||
expect(within(aliceRow).getByRole('button', { name: 'Select' })).toBeInTheDocument();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'init':
|
case 'init':
|
||||||
case 'get_state':
|
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
|
// NIP-46 client handshake surface used by SignerModeScreen. The fake
|
||||||
// keeps a Nip46SignerStatus-shaped object so handshake-state tests
|
// keeps a Nip46SignerStatus-shaped object so handshake-state tests
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue