From 61415ade19af13408bf1e4faa4ba810d98f13860 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Mon, 21 Sep 2026 10:36:38 +0800 Subject: [PATCH] fix(balance): notify every open riding the in-flight read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashboard reopen landing inside the balance read's window joined the in-flight route read but never registered its landing callback — the read reported only to the open that started it, so the reopened dashboard showed the stale remembered figure for its whole session (issue #82, exactly as reported). The landing callbacks now collect in a set and all fire when the read lands; the previous behavior was even pinned by a test asserting the joiner stayed silent, which now asserts the opposite. The store-level join test and a capsule-level close-reopen repro pin the regression both ways. --- src/client/balance.ts | 26 ++++++++++++++----- tests/client/balance.spec.ts | 19 ++++++++++++-- .../client/components/balanceCapsule.spec.ts | 25 ++++++++++++++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/client/balance.ts b/src/client/balance.ts index f0db678..8202973 100644 --- a/src/client/balance.ts +++ b/src/client/balance.ts @@ -96,6 +96,14 @@ export function balanceEntryOf( let cached: PlatformBalance | null = null /** The route read in flight, so a second open joins it instead of starting one. */ let refreshing: Promise | null = null +/** + * The landing callbacks of every open riding the in-flight read — all + * notified when it lands, so an open that JOINS mid-flight (a fast + * close-reopen, a second capsule) settles on the live figure too, exactly + * as this function's contract promises (issue #82). Cleared when the read + * settles: the next open starts a fresh read and a fresh set. + */ +const landingCallbacks = new Set<(value: PlatformBalance) => void>() /** The storage this module reads and writes; swapped in tests. */ let storage: StorageFace | null | undefined @@ -155,20 +163,25 @@ async function readRoute(): Promise { * capsule paints the remembered total immediately and settles on the live * one. `refreshing` is assigned before anything awaits, so an open arriving * while a read is still in flight joins it rather than starting a second one; - * it paints the remembered figure and takes the in-flight read's landing for - * its own. A failed or absent read notifies nobody and keeps serving the - * remembered figure, so an offline open never trades a number for a blank - * pill; `null` (whether pending, absent, or failed) means nothing to show yet. + * its callback joins the landing set, so the read's landing reports to EVERY + * open riding it — the joiner included (issue #82). A failed or absent read + * notifies nobody and keeps serving the remembered figure, so an offline open + * never trades a number for a blank pill; `null` (whether pending, absent, or + * failed) means nothing to show yet. */ export function readPlatformBalance(onRefresh: (value: PlatformBalance) => void): PlatformBalance | null { + landingCallbacks.add(onRefresh) if (refreshing === null) { refreshing = (async () => { const value = await readRoute() if (value === null) return cached = value rememberBalance(value) - onRefresh(value) - })().finally(() => { refreshing = null }) + for (const waiter of landingCallbacks) waiter(value) + })().finally(() => { + refreshing = null + landingCallbacks.clear() + }) } return cached ?? storedBalance() } @@ -177,6 +190,7 @@ export function readPlatformBalance(onRefresh: (value: PlatformBalance) => void) export function resetPlatformBalance(): void { cached = null refreshing = null + landingCallbacks.clear() try { storageFn()?.removeItem(STORAGE_KEY) } catch { diff --git a/tests/client/balance.spec.ts b/tests/client/balance.spec.ts index f02d3eb..c68218c 100644 --- a/tests/client/balance.spec.ts +++ b/tests/client/balance.spec.ts @@ -212,7 +212,7 @@ describe('readPlatformBalance', () => { assert.deepEqual(readPlatformBalance(() => {}), fresh, 'memory now outranks storage') }) - test('an open while a read is in flight joins that read instead of starting one', async () => { + test('an open while a read is in flight joins that read and lands with it (issue #82)', async () => { const { release, calls } = gatedRoute() const a = collector() const b = collector() @@ -222,11 +222,26 @@ describe('readPlatformBalance', () => { release() await drain() assert.deepEqual(a.seen, [WIRE_BALANCE], 'the open that started the read reports its figure') - assert.deepEqual(b.seen, [], 'the joining open takes the landing through memory rather than a callback of its own') + assert.deepEqual(b.seen, [WIRE_BALANCE], 'the joining open takes the landing for its own too') assert.equal(calls(), 1, 'one read serves both opens') assert.deepEqual(readPlatformBalance(() => {}), WIRE_BALANCE, 'and the joined read is the memory figure afterwards') }) + test('a joiner after one read landed starts a fresh read of its own', async () => { + stubRoute({ ok: true, value: WIRE_BALANCE }) + const first = collector() + readPlatformBalance(first.onRefresh) + await drain() + const gated = gatedRoute() + const second = collector() + assert.deepEqual(readPlatformBalance(second.onRefresh), WIRE_BALANCE, 'the memory figure paints at once') + await Promise.resolve() + gated.release() + await drain() + assert.deepEqual(second.seen, [WIRE_BALANCE], 'its own read revalidates and reports') + assert.equal(gated.calls(), 1, 'the fresh read went through the new gate') + }) + test('every absent answer notifies nobody and keeps the remembered figure', async () => { for (const [label, body, mode] of [ ['route absent (404)', undefined, 'status'], diff --git a/tests/client/components/balanceCapsule.spec.ts b/tests/client/components/balanceCapsule.spec.ts index fbd2175..8af7b11 100644 --- a/tests/client/components/balanceCapsule.spec.ts +++ b/tests/client/components/balanceCapsule.spec.ts @@ -180,3 +180,28 @@ describe('BalanceCapsule', () => { await m.unmount() }) }) + +describe('BalanceCapsule — the fast close-reopen window (issue #82)', () => { + test('a reopen inside the read window joins it and settles on the landing figure', async () => { + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + vi.stubGlobal('fetch', async () => { + await gate + return { ok: true, json: async () => ({ ok: true, value: WIRE_BALANCE }) } + }) + // The first open: its read goes in flight, then the dashboard closes. + const Capsule = makeBalanceCapsule(asClientCtx(new TestClientCtx({ locale: 'en' })), kit) + const first = await mount(h(Capsule, {})) + assert.equal(text(first.container), '', 'nothing remembered yet') + await first.unmount() + // The reopen lands inside the first read's window: it joins the read — + // and settles on the landing figure exactly like the first open would. + const Reopened = makeBalanceCapsule(asClientCtx(new TestClientCtx({ locale: 'en' })), kit) + const second = await mount(h(Reopened, {})) + assert.equal(text(second.container), '', 'the joined read has not landed yet') + release() + await flush() + assert.equal(query(second.container, '.lc-ov-balance-value')?.textContent, '$12.50', 'the landing reaches the reopen') + await second.unmount() + }) +})