49e86e2e67
* fix(transactions): share bank-sync busy state across surfaces useBankSync() kept busyId/syncingAll/connections as hook-local state, but the header "Synka bank nu" split-button row and the footer "Synka nu" button each call the hook independently: a sync started from one surface left the other enabled and spinner-less, so a second concurrent sync of the same connection could be started. Hoist the state into a module-level store (lib/transactions/ bank-sync-store.ts) consumed via useSyncExternalStore, so every instance shares busy state and the connection list: - both surfaces spin and disable while either one syncs - runFor/syncAll re-check the live snapshot before firing, so a click racing a sync from the other surface is a no-op instead of a second paid PSD2 call - the bank_connections query runs once per company instead of once per surface (first mounted instance claims the fetch; failures release the claim so a later mount retries) - a sync that hits a dead PSD2 session flips the connection to expired on every surface at once, not just the one that ran it Fixes #1162. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): discard stale connection loads after company switch publishConnections now requires the caller to still own the load claim: a fetch resolving after the active company switched (and re-claimed the slot) no longer clobbers the newer company's published list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
126 lines
4.1 KiB
TypeScript
126 lines
4.1 KiB
TypeScript
/**
|
|
* Module-level store for on-demand bank sync state, shared by every
|
|
* `useBankSync()` instance via `useSyncExternalStore`.
|
|
*
|
|
* Two surfaces on the transactions page trigger syncs independently: the
|
|
* "Synka bank nu" row in the Importera split button (TransactionStatusBar)
|
|
* and the footer "Synka nu" button (BankSyncNowButton). With hook-local
|
|
* state, a sync started from one surface left the other enabled and
|
|
* spinner-less, so a second concurrent sync of the same connection could be
|
|
* started (#1162). Hoisting the state here makes busy/connections identical
|
|
* across all instances, and lets the first mounted instance's fetch serve
|
|
* the rest (no duplicate `bank_connections` query per surface).
|
|
*
|
|
* Pure state + pub/sub only: the Supabase fetch stays in the hook so this
|
|
* module is trivially unit-testable.
|
|
*/
|
|
|
|
export interface BankConn {
|
|
id: string
|
|
bank_name: string
|
|
status: string
|
|
provider: string
|
|
last_synced_at: string | null
|
|
}
|
|
|
|
export interface BankSyncState {
|
|
/** null until the first load for `companyId` has completed */
|
|
connections: BankConn[] | null
|
|
/** company the loaded connections belong to */
|
|
companyId: string | null
|
|
/** id of the connection currently syncing or reconnecting */
|
|
busyId: string | null
|
|
/** true across a whole "sync everything" run, including between connections */
|
|
syncingAll: boolean
|
|
}
|
|
|
|
const INITIAL_STATE: BankSyncState = {
|
|
connections: null,
|
|
companyId: null,
|
|
busyId: null,
|
|
syncingAll: false,
|
|
}
|
|
|
|
let state: BankSyncState = INITIAL_STATE
|
|
let loadingCompanyId: string | null = null
|
|
const listeners = new Set<() => void>()
|
|
|
|
function emit(next: BankSyncState): void {
|
|
state = next
|
|
for (const listener of listeners) listener()
|
|
}
|
|
|
|
export function subscribeBankSync(listener: () => void): () => void {
|
|
listeners.add(listener)
|
|
return () => {
|
|
listeners.delete(listener)
|
|
}
|
|
}
|
|
|
|
/** Stable-identity snapshot: only replaced when something actually changed. */
|
|
export function getBankSyncSnapshot(): BankSyncState {
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Claim the connections fetch for a company. Returns true when the caller
|
|
* should run the query (first instance to mount, or a retry after a failed
|
|
* load); false when the list is already loaded for that company or another
|
|
* instance's fetch is in flight.
|
|
*/
|
|
export function claimConnectionsLoad(companyId: string): boolean {
|
|
if (state.companyId === companyId && state.connections !== null) return false
|
|
if (loadingCompanyId === companyId) return false
|
|
loadingCompanyId = companyId
|
|
return true
|
|
}
|
|
|
|
export function publishConnections(companyId: string, connections: BankConn[]): void {
|
|
// Only the fetch that still owns the load claim may publish. A resolve
|
|
// arriving after the active company switched (and re-claimed the slot)
|
|
// would otherwise clobber the newer company's list with stale data.
|
|
if (loadingCompanyId !== companyId) return
|
|
loadingCompanyId = null
|
|
emit({ ...state, companyId, connections })
|
|
}
|
|
|
|
/** Fetch failed: release the claim so a later mount can retry. */
|
|
export function releaseConnectionsLoad(companyId: string): void {
|
|
if (loadingCompanyId === companyId) loadingCompanyId = null
|
|
}
|
|
|
|
export function setBusyConnection(connectionId: string | null): void {
|
|
if (state.busyId === connectionId) return
|
|
emit({ ...state, busyId: connectionId })
|
|
}
|
|
|
|
/** Clear busy only if this connection still owns it (mirrors the old
|
|
* `setBusyId(prev => prev === id ? null : prev)` guard). */
|
|
export function clearBusyConnection(connectionId: string): void {
|
|
if (state.busyId !== connectionId) return
|
|
emit({ ...state, busyId: null })
|
|
}
|
|
|
|
export function setSyncingAll(syncingAll: boolean): void {
|
|
if (state.syncingAll === syncingAll) return
|
|
emit({ ...state, syncingAll })
|
|
}
|
|
|
|
/** Reflect a status change (e.g. a sync hit a dead PSD2 session) on every
|
|
* surface at once. */
|
|
export function markConnectionStatus(connectionId: string, status: string): void {
|
|
if (!state.connections) return
|
|
emit({
|
|
...state,
|
|
connections: state.connections.map((c) =>
|
|
c.id === connectionId ? { ...c, status } : c,
|
|
),
|
|
})
|
|
}
|
|
|
|
/** Reset to the initial state (tests, sign-out). */
|
|
export function resetBankSyncStore(): void {
|
|
loadingCompanyId = null
|
|
emit(INITIAL_STATE)
|
|
}
|