| 1 | /** |
| 2 | * Coalesces identical read-only backend queries that a single UI transition |
| 3 | * fires more than once. |
| 4 | * |
| 5 | * Switching a session fans out ~20-28 bridge calls, and measurement showed a |
| 6 | * third of them are duplicates within the same transition: independent hooks |
| 7 | * each ask for the context panel, the project tree, the tab meta. Deduping at |
| 8 | * the call sites would mean threading a cache through a dozen hooks; deduping |
| 9 | * at the bridge is one place and cannot be forgotten by the next hook. |
| 10 | * |
| 11 | * Only queries on the allowlist are coalesced, and only for the few |
| 12 | * milliseconds a transition takes: a stale answer is worse than a slow one, so |
| 13 | * nothing here outlives the burst it exists to collapse. |
| 14 | */ |
| 15 | |
| 16 | /** How long an in-flight or just-settled answer may be shared. */ |
| 17 | const WINDOW_MS = 200; |
| 18 | |
| 19 | /** |
| 20 | * COALESCED lists read-only queries whose answer cannot change within one UI |
| 21 | * transition. Anything that mutates, starts work, or reads a value the user is |
| 22 | * actively editing stays off this list. |
| 23 | */ |
| 24 | const COALESCED = new Set([ |
| 25 | "ListProjectTree", |
| 26 | "ListTabs", |
| 27 | "ContextPanel", |
| 28 | "MetaForTab", |
| 29 | "EffortForTab", |
| 30 | "JobsForTab", |
| 31 | "BackgroundRuntimes", |
| 32 | "BalanceForTab", |
| 33 | ]); |
| 34 | |
| 35 | // Any command that can change backend-visible state starts a new query epoch. |
| 36 | // Prefixes match the generated binding names while leaving ordinary reads |
| 37 | // (List/Get/Meta/History/Context/Jobs/Checkpoints) available for coalescing. |
| 38 | const MUTATION_PREFIX = /^(Activate|Add|Answer|Apply|Approve|Cancel|Clear|Close|Connect|Create|Delete|Ensure|Fetch|Install|New|Open|Refresh|Remove|Rename|Reorder|Replay|Resolve|Resume|Run|Save|Send|Set|Start|Steer|Stop|Switch|Trash|Try|Update|Upgrade)/; |
| 39 | |
| 40 | type Entry = { promise: Promise<unknown>; at: number }; |
| 41 | |
| 42 | const inflight = new Map<string, Entry>(); |
| 43 | |
| 44 | function now(): number { |
| 45 | return typeof performance !== "undefined" ? performance.now() : Date.now(); |
| 46 | } |
| 47 | |
| 48 | export function coalescesQuery(method: string): boolean { |
| 49 | return COALESCED.has(method); |
| 50 | } |
| 51 | |
| 52 | export function invalidatesQueryCoalescing(method: string): boolean { |
| 53 | return MUTATION_PREFIX.test(method); |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * maybeShare is the bridge's single entry point: it decides whether a call is |
| 58 | * shareable and shares it, so the proxy never has to know the allowlist. |
| 59 | */ |
| 60 | export function maybeShare(method: string, args: unknown[], run: () => unknown): unknown { |
| 61 | if (!coalescesQuery(method)) { |
| 62 | if (invalidatesQueryCoalescing(method)) inflight.clear(); |
| 63 | return run(); |
| 64 | } |
| 65 | return shareQuery(method, args, async () => run()); |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * shareQuery returns the in-flight answer for an identical call made inside the |
| 70 | * window, or runs it. A rejection is never shared beyond its own settlement: |
| 71 | * the next caller retries rather than inheriting a stale failure. Callers that |
| 72 | * cross an external state boundary may explicitly invalidate that query, while |
| 73 | * bridge mutations invalidate the whole burst automatically. |
| 74 | */ |
| 75 | export function shareQuery<T>(method: string, args: unknown[], run: () => Promise<T>): Promise<T> { |
| 76 | const key = `${method}|${safeKey(args)}`; |
| 77 | const at = now(); |
| 78 | const hit = inflight.get(key); |
| 79 | if (hit && at - hit.at < WINDOW_MS) return hit.promise as Promise<T>; |
| 80 | const promise = run(); |
| 81 | inflight.set(key, { promise, at }); |
| 82 | void promise.then( |
| 83 | () => { |
| 84 | if (method === "ListTabs") { |
| 85 | forget(key, promise); |
| 86 | return; |
| 87 | } |
| 88 | setTimeout(() => forget(key, promise), WINDOW_MS); |
| 89 | }, |
| 90 | () => forget(key, promise), |
| 91 | ); |
| 92 | return promise; |
| 93 | } |
| 94 | |
| 95 | function forget(key: string, promise: Promise<unknown>): void { |
| 96 | if (inflight.get(key)?.promise === promise) inflight.delete(key); |
| 97 | } |
| 98 | |
| 99 | /** Arguments are identifiers and flags; anything unserialisable disables the share. */ |
| 100 | function safeKey(args: unknown[]): string { |
| 101 | try { |
| 102 | return JSON.stringify(args); |
| 103 | } catch { |
| 104 | return `\u0000${Math.random()}`; |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | /** Drop a settled burst answer at an external state boundary (for example turn_done). */ |
| 109 | export function invalidateSharedQuery(method: string, args: unknown[]): void { |
| 110 | inflight.delete(`${method}|${safeKey(args)}`); |
| 111 | } |
| 112 | |
| 113 | /** Test seam: forget everything remembered so far. */ |
| 114 | export function resetQueryCoalescing(): void { |
| 115 | inflight.clear(); |
| 116 | } |
| 117 |