/** * Combines multiple AbortSignals into one that aborts when any input does. * Prefers the native `AbortSignal.any` where present (modern browsers); * falls back to manual event-listener wiring where it isn't. * * That fallback is load-bearing, not defensive padding: React Native * doesn't provide a native AbortController at all — `setUpXHR.js` installs * one from the third-party `abort-controller` npm package (pinned at * v3.0.0 in this tree), which predates `AbortSignal.any` entering the * WHATWG spec. Calling it unconditionally on native evaluates to invoking * `undefined` as a function, which Hermes reports as the unhelpfully * generic "undefined is not a function" — confirmed for real on * 2026-08-17, the first time a native call site (a vault item detail * screen, via its own cancellation AbortSignal) exercised the * `signals.length > 1` branch this function has always had; every * previous native caller only ever supplied the internal timeout signal, * so `AbortSignal.any` had simply never been reached before. */ function combineSignals(signals: readonly AbortSignal[]): AbortSignal { if (signals.length === 1) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked return signals[0]!; } if (typeof AbortSignal.any === "function") { return AbortSignal.any([...signals]); } const combined = new AbortController(); for (const s of signals) { if (s.aborted) { combined.abort(); break; } s.addEventListener("abort", () => combined.abort(), { once: true }); } return combined.signal; } /** * `fetch` with a timeout abort signal merged onto the caller's own signal. * The default timeout is 15s. Pass `Infinity` to disable the timeout * (the caller's own `signal` still applies). Passing `0` aborts * immediately — almost never what you want — so we treat any * non-positive value as "no timeout" to avoid the footgun. * * **Lockstep duplicate** of `vault/clients/shared/src/utils/fetch.ts`. * `@vault/shared` is the vault-specific lower layer; `@entropy-softworks/ui` * is the cross-portfolio UI package. Neither can depend on the other * (shared → ui would invert layering; ui → shared would chain every * downstream Entropy Softworks app to vault). The function is small, * pure, and stable; keep both copies byte-identical and update both * when changing. */ export function fetchWithTimeout( url: string, options: RequestInit = {}, timeoutMs = 15000 ): Promise { const controller = new AbortController(); const id = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => controller.abort(), timeoutMs) : null; const signals = [controller.signal, options.signal].filter((s): s is AbortSignal => s != null); const combinedSignal = combineSignals(signals); return fetch(url, { ...options, signal: combinedSignal }).finally(() => { if (id !== null) { clearTimeout(id); } }); }