From 24c40a6494b8364680d6686c824536711ac5d6cc Mon Sep 17 00:00:00 2001 From: opascal221-design <278520907+opascal221-design@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:45:54 +0100 Subject: [PATCH] fix: add batchUpdates option to WalletStatusTracker and verify transport cleanup Closes #761 Closes #748 Closes #760 Closes #749 --- src/tests/logger.test.ts | 12 +++++++ src/tests/walletStatusTracker.test.ts | 48 +++++++++++++++++++++++++++ src/wallet/walletStatusTracker.ts | 23 +++++++++++++ 3 files changed, 83 insertions(+) diff --git a/src/tests/logger.test.ts b/src/tests/logger.test.ts index 2348cd27..c01a06c3 100644 --- a/src/tests/logger.test.ts +++ b/src/tests/logger.test.ts @@ -144,6 +144,18 @@ describe("shared/logger", () => { expect((records[0]?.context?.nested as StructuredLogMeta).authToken).toBe("[redacted]"); }); + it("cleanup function removes the transport so subsequent logs are not delivered", () => { + const records: StructuredLogRecord[] = []; + const unregister = registerLogTransport({ write: (record) => records.push(record) }); + const logger = createLogger({ logLevel: "info" }); + + logger.info("before.unregister"); + unregister(); + logger.info("after.unregister"); + + expect(records.map((r) => r.message)).toEqual(["before.unregister"]); + }); + it("supports per-module log levels and custom transports", () => { const records: StructuredLogRecord[] = []; const logger = createLogger({ diff --git a/src/tests/walletStatusTracker.test.ts b/src/tests/walletStatusTracker.test.ts index b4dd9619..d85a87fb 100644 --- a/src/tests/walletStatusTracker.test.ts +++ b/src/tests/walletStatusTracker.test.ts @@ -206,4 +206,52 @@ describe("wallet status tracker", () => { expect(getAdapterName(WalletType.FREIGHTER)).toBe("Freighter"); expect(truncatePublicKey("short")).toBe("short"); }); + + describe("batchUpdates", () => { + it("coalesces rapid state changes into one listener call per microtask flush", async () => { + const listener = vi.fn(); + const tracker = new WalletStatusTracker({ batchUpdates: true }); + tracker.subscribe(listener); + + tracker.setError("first"); + tracker.setDisconnected(); + tracker.setError("last"); + + // No calls yet — flush is deferred to microtask + expect(listener).not.toHaveBeenCalled(); + + await Promise.resolve(); + + // Exactly one call, with the latest state + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0]![0].status).toBe("error"); + expect(listener.mock.calls[0]![0].error).toBe("last"); + }); + + it("without batchUpdates, each state change calls listeners synchronously", () => { + const listener = vi.fn(); + const tracker = new WalletStatusTracker(); + tracker.subscribe(listener); + + tracker.setError("a"); + tracker.setDisconnected(); + tracker.setError("b"); + + expect(listener).toHaveBeenCalledTimes(3); + }); + + it("does not emit after destroy even when a flush was pending", async () => { + const listener = vi.fn(); + const tracker = new WalletStatusTracker({ batchUpdates: true }); + tracker.subscribe(listener); + + tracker.setError("will be destroyed"); + tracker.destroy(); + + await Promise.resolve(); + + // destroy cleared _flushPending so the queued microtask finds no listeners + expect(listener).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/wallet/walletStatusTracker.ts b/src/wallet/walletStatusTracker.ts index 7ce8cfa4..6f85faac 100644 --- a/src/wallet/walletStatusTracker.ts +++ b/src/wallet/walletStatusTracker.ts @@ -33,6 +33,12 @@ export type WalletStatusUnsubscribe = () => void; export interface WalletStatusTrackerConfig { onStatusChange?: WalletStatusListener; + /** + * When true, rapid status changes within the same microtask checkpoint are + * coalesced and emitted once. Listeners always receive the latest state. + * Defaults to false (synchronous, unchanged behaviour). + */ + batchUpdates?: boolean; } const ADAPTER_NAMES: Record = { @@ -96,11 +102,14 @@ const INITIAL_STATUS: WalletStatus = { export class WalletStatusTracker { private _status: WalletStatus = { ...INITIAL_STATUS }; private _listeners: Set = new Set(); + private _batchUpdates: boolean; + private _flushPending = false; constructor(config?: WalletStatusTrackerConfig) { if (config?.onStatusChange) { this._listeners.add(config.onStatusChange); } + this._batchUpdates = config?.batchUpdates ?? false; } get status(): WalletStatus { @@ -131,6 +140,19 @@ export class WalletStatusTracker { } private _emit(): void { + if (this._batchUpdates) { + if (!this._flushPending) { + this._flushPending = true; + queueMicrotask(() => { + this._flushPending = false; + const status = this._status; + for (const listener of this._listeners) { + listener(status); + } + }); + } + return; + } const status = this._status; for (const listener of this._listeners) { listener(status); @@ -244,5 +266,6 @@ export class WalletStatusTracker { destroy(): void { this._listeners.clear(); this._status = { ...INITIAL_STATUS }; + this._flushPending = false; } }