fix(balance): notify every open riding the in-flight read

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.
This commit is contained in:
bowenliang123 committed 2026-09-21 10:36:38 +08:00
1 parent cd53e1403c
commit 61415ade19
3 files changed
+62 -8

No files matched your search

+20 -6
View File
@@ -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<void> | 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<PlatformBalance | null> {
* 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 {
+17 -2
View File
@@ -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'],
@@ -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<void>((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()
})
})