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:
1 parent
cd53e1403c
commit
61415ade19
3 files changed
+62
-8
No files matched your search
+20
-6
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user