From a6aa295d447a0da9c00e6241cc6884324f197fcd Mon Sep 17 00:00:00 2001 From: mskdylan Date: Thu, 22 Oct 2026 19:18:49 +0000 Subject: [PATCH] test: cover SSE reconnect cleanup, settings fallback, streaming pause, and malformed events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add deterministic regression tests for four hook-level issues: - #1783: unmounting useStreamEvents during a pending reconnect clears the backoff timer, closes the EventSource, and never reconnects; repeated error bursts keep exactly one timer and honor the attempt cap. - #1784: invalid decimal settings (missing, empty, negative, out-of-range, malformed, fractional) fall back per the parseInt contract without throwing, and valid values still apply after invalid ones. - #1781: useStreamingAmount freezes on pause, resumes without duplicate frame loops, excludes paused time across repeated cycles, resyncs after clock drift, and cleans up when unmounted while streaming or paused. - #1782: malformed JSON, unknown event types, and partial frames are ignored without corrupting subsequent valid event processing. All tests use fake timers and controlled clocks; no production code changed. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../src/__tests__/useStreamEvents.test.tsx | 272 ++++++++++++++- frontend/src/hooks/useSettings.test.ts | 133 ++++++++ .../src/hooks/useStreamingAmount.test.tsx | 313 ++++++++++++++++++ 3 files changed, 717 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/useStreamEvents.test.tsx b/frontend/src/__tests__/useStreamEvents.test.tsx index 06a6dcc8..0e0112c5 100644 --- a/frontend/src/__tests__/useStreamEvents.test.tsx +++ b/frontend/src/__tests__/useStreamEvents.test.tsx @@ -16,6 +16,8 @@ class MockEventSource { onmessage: EventHandler | null = null; onerror: ErrorHandler | null = null; readyState = 0; + /** Mirrors the real EventSource: no events are delivered once closed. */ + closed = false; private handlers: Map = new Map(); @@ -37,7 +39,17 @@ class MockEventSource { /** Fire a named event as if it arrived from the server. */ emit(type: string, data: unknown) { - const event = { data: JSON.stringify(data) } as MessageEvent; + this.deliver(type, JSON.stringify(data)); + } + + /** Deliver a raw payload string (e.g. truncated or malformed JSON). */ + emitRaw(type: string, raw: string) { + this.deliver(type, raw); + } + + private deliver(type: string, raw: string) { + if (this.closed) return; + const event = { data: raw } as MessageEvent; for (const handler of this.handlers.get(type) ?? []) { handler(event); } @@ -57,6 +69,7 @@ class MockEventSource { close() { this.readyState = 2; + this.closed = true; } } @@ -332,4 +345,261 @@ describe('useStreamEvents', () => { // After the 20th reconnect attempt, no more timers should fire. expect(MockEventSource.instanceCount).toBeLessThanOrEqual(21); }); + + // ─── Reconnect timer cleanup (#1783) ────────────────────────────────────── + + describe('reconnect timer cleanup (#1783)', () => { + it('schedules no timers until a connection error occurs', () => { + renderHook(() => + useStreamEvents({ streamIds: ['1'], autoReconnect: true }), + ); + + expect(vi.getTimerCount()).toBe(0); + }); + + it('clears the pending reconnect timer when the hook unmounts', () => { + const { unmount } = renderHook(() => + useStreamEvents({ streamIds: ['1'], autoReconnect: true }), + ); + + act(() => { MockEventSource.instance?.triggerError(); }); + expect(vi.getTimerCount()).toBe(1); + + unmount(); + + expect(vi.getTimerCount()).toBe(0); + }); + + it('never reconnects after unmounting while a reconnect is pending', () => { + // Stable identity so re-renders never trigger a reconnect of their own. + const streamIds = ['1']; + const { unmount } = renderHook(() => + useStreamEvents({ streamIds, autoReconnect: true }), + ); + + const first = MockEventSource.instance; + act(() => { first?.triggerError(); }); + + unmount(); + act(() => { vi.advanceTimersByTime(10 * 60 * 1000); }); + + // The stale backoff timer must not have produced a new connection. + expect(MockEventSource.instanceCount).toBe(1); + expect(MockEventSource.instance).toBe(first); + expect(vi.getTimerCount()).toBe(0); + }); + + it('closes the active connection on unmount and stops delivering events', () => { + const { result, unmount } = renderHook(() => + useStreamEvents({ streamIds: ['1'], autoReconnect: false }), + ); + + const first = MockEventSource.instance; + act(() => { + first?.open(); + first?.emit('stream.created', { streamId: 1 }); + }); + expect(result.current.events).toHaveLength(1); + + unmount(); + + expect(first?.closed).toBe(true); + + // Events emitted on the stale source after unmount are dropped. + act(() => { first?.emit('stream.created', { streamId: 2 }); }); + expect(result.current.events).toHaveLength(1); + }); + + it('schedules exactly one reconnect timer across repeated error events', () => { + renderHook(() => + useStreamEvents({ streamIds: ['1'], autoReconnect: true }), + ); + + const first = MockEventSource.instance; + act(() => { + // Several failures in the same tick (e.g. a flapping network). + first?.triggerError(); + first?.triggerError(); + first?.triggerError(); + }); + + // Repeated failures must not stack timers on top of each other. + expect(vi.getTimerCount()).toBe(1); + + // Like consecutive failures, a burst of errors grows the backoff: + // 1000 → 2000 → 4000, and only the last delay is actually scheduled. + act(() => { vi.advanceTimersByTime(3999); }); + expect(MockEventSource.instanceCount).toBe(1); + + act(() => { vi.advanceTimersByTime(1); }); + + // Exactly one reconnect attempt was made for the burst of errors. + expect(MockEventSource.instanceCount).toBe(2); + expect(MockEventSource.instance).not.toBe(first); + }); + + it('does not reconnect when unmounting just before the retry delay elapses', () => { + const streamIds = ['1']; + const { unmount } = renderHook(() => + useStreamEvents({ streamIds, autoReconnect: true }), + ); + + act(() => { MockEventSource.instance?.triggerError(); }); + + // One tick before the 1000ms retry fires. + act(() => { vi.advanceTimersByTime(999); }); + expect(MockEventSource.instanceCount).toBe(1); + + unmount(); + act(() => { vi.advanceTimersByTime(60_000); }); + + expect(MockEventSource.instanceCount).toBe(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('reports reconnecting=true while a retry is pending and cleans up on unmount', () => { + const { result, unmount } = renderHook(() => + useStreamEvents({ streamIds: ['1'], autoReconnect: true }), + ); + + act(() => { MockEventSource.instance?.triggerError(); }); + + expect(result.current.connected).toBe(false); + expect(result.current.reconnecting).toBe(true); + expect(result.current.error).toBeInstanceOf(Error); + + unmount(); + + expect(vi.getTimerCount()).toBe(0); + }); + + it('leaves no timers behind once the reconnect-attempt cap is exhausted', () => { + renderHook(() => + useStreamEvents({ streamIds: ['1'], autoReconnect: true }), + ); + + for (let i = 0; i < 25; i++) { + act(() => { MockEventSource.instance?.triggerError(); }); + act(() => { vi.advanceTimersByTime(2000); }); + } + + // 1 initial connection + at most 20 reconnects; once the cap is hit, + // no further reconnect timers may remain scheduled. + expect(MockEventSource.instanceCount).toBeLessThanOrEqual(21); + expect(vi.getTimerCount()).toBe(0); + }); + }); + + // ─── Malformed SSE event recovery (#1782) ────────────────────────────────── + + describe('malformed SSE event recovery (#1782)', () => { + // The hook consumes complete EventSource messages and JSON.parses each + // payload. Frames the transport truncates arrive as incomplete text, so + // the contract is: unparsable payloads are silently ignored and must not + // affect the processing of subsequent valid events. + const renderSubscription = () => { + const streamIds = ['1']; + return renderHook(() => useStreamEvents({ streamIds, autoReconnect: false })); + }; + + it('ignores payloads that are not valid JSON without crashing', () => { + const { result } = renderSubscription(); + const source = MockEventSource.instance; + + act(() => { + source?.open(); + source?.emitRaw('stream.created', '{"streamId":'); // truncated JSON + source?.emitRaw('stream.topped_up', 'not-json at all'); // plain text + source?.emitRaw('stream.withdrawn', ''); // empty payload + }); + + expect(result.current.events).toHaveLength(0); + expect(result.current.error).toBeNull(); + expect(result.current.connected).toBe(true); + }); + + it('continues processing valid events delivered after malformed ones', () => { + const { result } = renderSubscription(); + const source = MockEventSource.instance; + + act(() => { + source?.open(); + source?.emitRaw('stream.created', '{"broken":'); + source?.emit('stream.created', { streamId: 42 }); + }); + + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0]?.type).toBe('created'); + expect(result.current.events[0]?.data).toEqual({ streamId: 42 }); + }); + + it('ignores event types the hook does not subscribe to', () => { + const { result } = renderSubscription(); + const source = MockEventSource.instance; + + act(() => { + source?.open(); + // The EventSource transport only delivers subscribed event types, so + // unknown server events never reach the hook's handlers at all. + source?.emit('stream.exploded', { streamId: 1 }); + source?.emit('message', { streamId: 1 }); + source?.emit('stream.created', { streamId: 7 }); + }); + + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0]?.data).toEqual({ streamId: 7 }); + }); + + it('treats partially delivered frames as malformed and recovers on the next event', () => { + const { result } = renderSubscription(); + const source = MockEventSource.instance; + + act(() => { + source?.open(); + // A frame cut off mid-payload (missing closing brace). + source?.emitRaw('stream.topped_up', '{"amount":"100"'); + source?.emit('stream.topped_up', { amount: '100' }); + }); + + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0]?.type).toBe('topped_up'); + expect(result.current.events[0]?.data).toEqual({ amount: '100' }); + }); + + it('does not corrupt the event list when malformed payloads interleave with valid ones', () => { + const { result } = renderSubscription(); + const source = MockEventSource.instance; + + act(() => { + source?.open(); + source?.emit('stream.created', { streamId: 1 }); + source?.emitRaw('stream.created', '%%garbage%%'); + source?.emit('stream.cancelled', { streamId: 2 }); + }); + + // Newest first: the malformed payload never entered the list. + expect(result.current.events).toHaveLength(2); + expect(result.current.events[0]?.type).toBe('cancelled'); + expect(result.current.events[1]?.type).toBe('created'); + }); + + it('keeps malformed payloads from triggering reconnect or error state', () => { + const { result } = renderSubscription(); + const source = MockEventSource.instance; + + act(() => { + source?.open(); + for (let i = 0; i < 5; i++) { + source?.emitRaw('stream.created', '{oops'); + } + }); + + // Malformed messages are not connection failures: no error state, no + // reconnect timers, and the connection stays reported as healthy. + expect(result.current.error).toBeNull(); + expect(result.current.connected).toBe(true); + expect(result.current.reconnecting).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + }); }); diff --git a/frontend/src/hooks/useSettings.test.ts b/frontend/src/hooks/useSettings.test.ts index 148fe7e2..ee3c8d66 100644 --- a/frontend/src/hooks/useSettings.test.ts +++ b/frontend/src/hooks/useSettings.test.ts @@ -4,6 +4,9 @@ import { useSettings, STORAGE_KEYS, formatAmountWithPreference, + getDecimalPlaces, + getAmountFormat, + getDisplayCurrency, DEFAULT_SETTINGS, _resetSharedSettings } from './useSettings'; @@ -186,4 +189,134 @@ describe('useSettings and formatAmountWithPreference', () => { expect(formatAmountWithPreference('1001234000')).toBe('100.1234'); }); }); + + // ─── Invalid decimal settings fallback (#1784) ───────────────────────────── + // + // Documented behavior: a missing/empty stored value falls back to the + // default of 7. Any other stored string is parsed with + // `parseInt(value, 10)`, which truncates to the leading integer (or yields + // NaN for values with no leading integer). `formatAmountWithPreference` + // then formats with however many decimal places that produces, clamped by + // the amount of fractional digits that actually exist — it must never + // throw, whatever was stored. + + describe('invalid decimal settings fallback (#1784)', () => { + it('uses the documented default of 7 when the stored value is missing or empty', () => { + expect(getDecimalPlaces()).toBe(7); + + localStorage.setItem(STORAGE_KEYS.decimalPlaces, ''); + expect(getDecimalPlaces()).toBe(7); + expect(formatAmountWithPreference(15678901n)).toBe('1.5678901'); + }); + + it('accepts every supported decimal value (2, 4 and 7)', () => { + for (const value of [2, 4, 7] as const) { + localStorage.setItem(STORAGE_KEYS.decimalPlaces, String(value)); + expect(getDecimalPlaces()).toBe(value); + } + + // Boundary values format to exactly the requested precision. + localStorage.setItem(STORAGE_KEYS.decimalPlaces, '2'); + expect(formatAmountWithPreference(15678901n)).toBe('1.56'); + localStorage.setItem(STORAGE_KEYS.decimalPlaces, '7'); + expect(formatAmountWithPreference(15678901n)).toBe('1.5678901'); + }); + + it('truncates fractional stored values to their integer prefix', () => { + localStorage.setItem(STORAGE_KEYS.decimalPlaces, '4.9'); + expect(getDecimalPlaces()).toBe(4); + + localStorage.setItem(STORAGE_KEYS.decimalPlaces, '2.7'); + expect(getDecimalPlaces()).toBe(2); + }); + + it('ignores leading zeros and surrounding whitespace', () => { + localStorage.setItem(STORAGE_KEYS.decimalPlaces, '004'); + expect(getDecimalPlaces()).toBe(4); + + localStorage.setItem(STORAGE_KEYS.decimalPlaces, ' 7 '); + expect(getDecimalPlaces()).toBe(7); + }); + + it('keeps out-of-range values parseable but never formats beyond the token precision', () => { + localStorage.setItem(STORAGE_KEYS.decimalPlaces, '12'); + expect(getDecimalPlaces()).toBe(12); + // Only 7 fractional digits exist on-chain, so extra digits are + // unreachable and formatting matches the maximum supported value. + expect(formatAmountWithPreference(15678901n)).toBe('1.5678901'); + + localStorage.setItem(STORAGE_KEYS.decimalPlaces, '100'); + expect(getDecimalPlaces()).toBe(100); + expect(formatAmountWithPreference(15678901n)).toBe('1.5678901'); + }); + + it('does not throw for negative values (they trim fractional digits from the right)', () => { + localStorage.setItem(STORAGE_KEYS.decimalPlaces, '-2'); + expect(getDecimalPlaces()).toBe(-2); + // slice(0, -2) on '5678901' keeps the first five digits. + expect(formatAmountWithPreference(15678901n)).toBe('1.56789'); + }); + + it('falls back to integer-only formatting for malformed values without throwing', () => { + for (const malformed of ['abc', 'seven', 'NaN', '—']) { + localStorage.setItem(STORAGE_KEYS.decimalPlaces, malformed); + + const decimals = getDecimalPlaces(); + expect(Number.isNaN(decimals)).toBe(true); + expect(() => formatAmountWithPreference(15678901n)).not.toThrow(); + // No fractional digits survive a NaN slice bound. + expect(formatAmountWithPreference(15678901n)).toBe('1'); + } + }); + + it('hydrates the hook with malformed decimals without throwing', async () => { + localStorage.setItem(STORAGE_KEYS.decimalPlaces, 'abc'); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + + expect(Number.isNaN(result.current.decimalPlaces)).toBe(true); + // The remaining settings still hydrate with their documented defaults. + expect(result.current.displayCurrency).toBe(DEFAULT_SETTINGS.displayCurrency); + expect(result.current.amountFormat).toBe(DEFAULT_SETTINGS.amountFormat); + }); + + it('hydrates the hook with an out-of-range value as-is', async () => { + localStorage.setItem(STORAGE_KEYS.decimalPlaces, '9'); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + + expect(result.current.decimalPlaces).toBe(9); + }); + + it('applies a valid value written after invalid ones (recovery)', async () => { + localStorage.setItem(STORAGE_KEYS.decimalPlaces, 'garbage'); + + const { result } = renderHook(() => useSettings()); + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + expect(Number.isNaN(result.current.decimalPlaces)).toBe(true); + + act(() => { + result.current.setDecimalPlaces(4); + }); + + expect(result.current.decimalPlaces).toBe(4); + expect(getDecimalPlaces()).toBe(4); + expect(formatAmountWithPreference(15678901n)).toBe('1.5678'); + }); + + it('falls back to defaults for empty currency and format values', () => { + expect(getDisplayCurrency()).toBe('USD'); + expect(getAmountFormat()).toBe('full'); + + localStorage.setItem(STORAGE_KEYS.displayCurrency, ''); + localStorage.setItem(STORAGE_KEYS.amountFormat, ''); + + expect(getDisplayCurrency()).toBe('USD'); + expect(getAmountFormat()).toBe('full'); + }); + }); }); diff --git a/frontend/src/hooks/useStreamingAmount.test.tsx b/frontend/src/hooks/useStreamingAmount.test.tsx index 612e9d9c..fd9f70cb 100644 --- a/frontend/src/hooks/useStreamingAmount.test.tsx +++ b/frontend/src/hooks/useStreamingAmount.test.tsx @@ -166,4 +166,317 @@ describe("useStreamingAmount", () => { // No ticker remains scheduled after unmount. expect(vi.getTimerCount()).toBe(0); }); + + // ─── Pause/resume transitions, clock drift, unmount (#1781) ───────────── + + describe('pause/resume transitions, drift and unmount (#1781)', () => { + // While the stream is paused no frame loop runs, so the fake clock only + // moves when a tick fires. Tests bump `nowMs` directly to simulate wall + // time passing during a pause. + const advanceWallClock = (ms: number) => { + nowMs += ms; + vi.advanceTimersByTime(ms); + }; + + it('starts at 0 for a stream that just began and accrues normally', () => { + const params: HookParams = { + deposited: 1000, + withdrawn: 0, + ratePerSecond: 1, + startTime: BASE_SECONDS, + isActive: true, + }; + + const { result } = renderHook((props: HookParams) => useStreamingAmount(props), { + initialProps: params, + }); + + expect(result.current).toBe(0); + + act(() => { + vi.advanceTimersByTime(3000); + }); + + expect(result.current).toBeCloseTo(3, 0); + }); + + it('freezes on pause and resumes from the pre-pause value', () => { + const params: HookParams = { + deposited: 1000, + withdrawn: 0, + ratePerSecond: 1, + startTime: BASE_SECONDS - 100, + isActive: true, + }; + + const { result, rerender } = renderHook( + (props: HookParams) => useStreamingAmount(props), + { initialProps: params }, + ); + + expect(result.current).toBe(100); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBeCloseTo(105, 0); + + // Pause at the current wall time (the value captured at pause time). + const pausedAt = nowMs / 1000; + rerender({ ...params, isPaused: true, pausedAt }); + expect(result.current).toBeCloseTo(105, 0); + // Paused: no frame loop remains scheduled. + expect(vi.getTimerCount()).toBe(0); + + // 10 seconds pass while paused — the value must not move. + act(() => { + advanceWallClock(10_000); + }); + expect(result.current).toBeCloseTo(105, 0); + + // Resume, excluding the paused window via totalPausedDuration. + rerender({ + ...params, + isPaused: false, + pausedAt: null, + totalPausedDuration: 10, + }); + expect(result.current).toBeCloseTo(105, 0); + // Exactly one frame loop after resume — no duplicates. + expect(vi.getTimerCount()).toBe(1); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBeCloseTo(110, 0); + }); + + it('excludes paused time across repeated pause/resume cycles', () => { + const params: HookParams = { + deposited: 1000, + withdrawn: 0, + ratePerSecond: 1, + startTime: BASE_SECONDS, + isActive: true, + }; + + const { result, rerender } = renderHook( + (props: HookParams) => useStreamingAmount(props), + { initialProps: params }, + ); + + expect(result.current).toBe(0); + + let totalPausedDuration = 0; + let activeSeconds = 0; + + for (let cycle = 1; cycle <= 3; cycle++) { + // Stream for 2 seconds. + act(() => { + vi.advanceTimersByTime(2000); + }); + activeSeconds += 2; + expect(result.current).toBeCloseTo(activeSeconds, 0); + expect(vi.getTimerCount()).toBe(1); + + // Pause for 2 seconds. + const pausedAt = Math.floor(nowMs / 1000); + rerender({ ...params, isPaused: true, pausedAt, totalPausedDuration }); + expect(vi.getTimerCount()).toBe(0); + const valueAtPause = result.current; + + act(() => { + advanceWallClock(2000); + }); + expect(result.current).toBe(valueAtPause); + + // Resume — paused time is excluded via totalPausedDuration. + totalPausedDuration += 2; + rerender({ + ...params, + isPaused: false, + pausedAt: null, + totalPausedDuration, + }); + expect(result.current).toBeCloseTo(activeSeconds, 0); + expect(vi.getTimerCount()).toBe(1); + } + + // Final 2-second burst after the last resume. + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current).toBeCloseTo(8, 0); + expect(vi.getTimerCount()).toBe(1); + }); + + it('freezes at 0 when the stream was paused at its start time', () => { + const params: HookParams = { + deposited: 1000, + withdrawn: 0, + ratePerSecond: 1, + startTime: BASE_SECONDS - 100, + isActive: true, + isPaused: true, + pausedAt: BASE_SECONDS - 100, + }; + + const { result } = renderHook((props: HookParams) => useStreamingAmount(props), { + initialProps: params, + }); + + expect(result.current).toBe(0); + + act(() => { + advanceWallClock(10_000); + }); + + expect(result.current).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('never schedules a frame loop for zero-rate or fully-claimed streams', () => { + const zeroRate: HookParams = { + deposited: 1000, + withdrawn: 0, + ratePerSecond: 0, + startTime: BASE_SECONDS - 100, + isActive: true, + }; + + const { result, unmount } = renderHook( + (props: HookParams) => useStreamingAmount(props), + { initialProps: zeroRate }, + ); + + expect(result.current).toBe(0); + expect(vi.getTimerCount()).toBe(0); + + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(result.current).toBe(0); + unmount(); + + const drained: HookParams = { + deposited: 100, + withdrawn: 100, + ratePerSecond: 1, + startTime: BASE_SECONDS - 100, + isActive: true, + }; + + const { result: drainedResult } = renderHook( + (props: HookParams) => useStreamingAmount(props), + { initialProps: drained }, + ); + + expect(drainedResult.current).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('resyncs to the wall clock after clock drift without duplicating timers', () => { + const params: HookParams = { + deposited: 1000, + withdrawn: 0, + ratePerSecond: 1, + startTime: BASE_SECONDS - 100, + isActive: true, + }; + + const { result } = renderHook((props: HookParams) => useStreamingAmount(props), { + initialProps: params, + }); + + expect(result.current).toBe(100); + + // The wall clock jumps 60s ahead (e.g. an NTP correction) while the + // animation-frame clock keeps ticking at the same rate. + vi.spyOn(Date, 'now').mockImplementation(() => nowMs + 60_000); + + act(() => { + vi.advanceTimersByTime(1000); + }); + // Frame-driven accrual is unaffected by the wall-clock jump. + expect(result.current).toBeCloseTo(101, 0); + + // Becoming visible again resyncs the displayed value to the true + // elapsed time per the visibilitychange contract. + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toBeCloseTo(161, 0); + // Still exactly one frame loop — the resync must not add another. + expect(vi.getTimerCount()).toBe(1); + + // Accrual continues from the corrected value. + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBeCloseTo(162, 0); + }); + + it('cancels timers and stops updating when unmounted mid-stream', () => { + const params: HookParams = { + deposited: 1000, + withdrawn: 0, + ratePerSecond: 1, + startTime: BASE_SECONDS - 100, + isActive: true, + }; + + const { result, unmount } = renderHook( + (props: HookParams) => useStreamingAmount(props), + { initialProps: params }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + const valueAtUnmount = result.current; + expect(valueAtUnmount).toBeGreaterThan(0); + expect(vi.getTimerCount()).toBe(1); + + unmount(); + + expect(globalThis.cancelAnimationFrame).toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + + // No stale ticker may keep mutating state after unmount. + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(result.current).toBe(valueAtUnmount); + }); + + it('leaves no timers behind when unmounted while paused', () => { + const params: HookParams = { + deposited: 1000, + withdrawn: 0, + ratePerSecond: 1, + startTime: BASE_SECONDS - 100, + isActive: true, + isPaused: true, + pausedAt: BASE_SECONDS - 50, + }; + + const { result, unmount } = renderHook( + (props: HookParams) => useStreamingAmount(props), + { initialProps: params }, + ); + + expect(result.current).toBe(50); + expect(vi.getTimerCount()).toBe(0); + + unmount(); + + expect(vi.getTimerCount()).toBe(0); + + act(() => { + advanceWallClock(60_000); + }); + expect(result.current).toBe(50); + }); + }); });