fix(core): retry bpm-detective import after transient failure (#2736)

loadBpmDetective cached the promise returned by dynamic import even when
that import rejected. A transient failure (network hiccup, bundler issue,
missing module at first access) was therefore cached as null for the rest
of the session, silently disabling BPM detection.

- Reset the cached promise on import failure so the next call retries.
- Only cache the default production import; custom loaders bypass the cache.
- Make loadBpmDetective testable by accepting an optional importFn.
- Add regression tests for failure/retry and module/default resolution.
This commit is contained in:
Santhi Prakash
2026-08-08 22:32:10 -07:00
committed by GitHub
parent 9ec9e3a711
commit b4bd670402
2 changed files with 66 additions and 9 deletions
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { loadBpmDetective } from "./beatDetection";
describe("loadBpmDetective", () => {
it("resolves to null when the import is unavailable", async () => {
const detect = await loadBpmDetective(() => Promise.reject(new Error("missing")));
expect(detect).toBeNull();
});
it("retries a previously failed import instead of caching null forever", async () => {
let shouldFail = true;
const importFn = async () => {
if (shouldFail) {
throw new Error("transient");
}
return { default: () => 120 };
};
const first = await loadBpmDetective(importFn);
expect(first).toBeNull();
shouldFail = false;
const second = await loadBpmDetective(importFn);
expect(second).not.toBeNull();
expect(second?.({} as AudioBuffer)).toBe(120);
});
it("uses the default export if present, otherwise the module object", async () => {
const importFn = async () => ({ default: () => 90 });
const detect = await loadBpmDetective(importFn);
expect(detect?.({} as AudioBuffer)).toBe(90);
});
it("falls back to the module object when there is no default export", async () => {
const fn = (buffer: AudioBuffer) => buffer.duration;
const importFn = async () => fn as unknown;
const detect = await loadBpmDetective(importFn);
expect(detect).toBe(fn);
});
});
+25 -9
View File
@@ -4,16 +4,32 @@
// in the browser.
type BpmDetect = (buffer: AudioBuffer) => number;
let bpmDetectivePromise: Promise<BpmDetect | null> | null = null;
function loadBpmDetective(): Promise<BpmDetect | null> {
if (!bpmDetectivePromise) {
bpmDetectivePromise = import(
// @ts-ignore -- no type declarations for bpm-detective
"bpm-detective"
)
.then((m) => ((m as { default?: BpmDetect }).default ?? (m as unknown as BpmDetect)) || null)
.catch(() => null);
const defaultBpmDetectiveImport = () =>
// @ts-ignore -- no type declarations for bpm-detective
import("bpm-detective");
export function loadBpmDetective(
importFn: () => Promise<unknown> = defaultBpmDetectiveImport,
): Promise<BpmDetect | null> {
const useCache = importFn === defaultBpmDetectiveImport;
if (useCache && bpmDetectivePromise) {
return bpmDetectivePromise;
}
return bpmDetectivePromise;
const promise = importFn()
.then((m) => ((m as { default?: BpmDetect }).default ?? (m as unknown as BpmDetect)) || null)
.catch(() => {
// Reset the cached promise so a transient import failure can be retried
// on the next call instead of being cached as null for the session.
if (useCache) bpmDetectivePromise = null;
return null;
});
if (useCache) {
bpmDetectivePromise = promise;
}
return promise;
}
const WINDOW_SIZE = 1024;