diff --git a/CLAUDE.md b/CLAUDE.md index 5cd2e44..25470e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -442,6 +442,7 @@ tests/ settings-host.test.ts # nibbleDisabledDomains normalize / toggle language-detection.test.ts # RTL detection, title lookup, supported codes selection-popup.test.ts # @mousedown.prevent.stop regression + caption-translation-gate.test.ts # whole-caption translation: settle ~150 ms, skip the caption already sent (E-06); in-flight sharing is in translate.service.test.ts (E-04) nibble-surface.test.ts # bridge-driven hide/show translate-card.test.ts # popup translate input flow login-password.test.ts # popup password form (ENABLE_PASSWORD_AUTH=true) diff --git a/src/common/services/translate.service.ts b/src/common/services/translate.service.ts index 33e4163..93918f6 100644 --- a/src/common/services/translate.service.ts +++ b/src/common/services/translate.service.ts @@ -36,6 +36,8 @@ export class TranslateService { // Cache for translation results private translationCache: TranslationCache = {}; private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + // Requests on their way, by cache key (E-04). + private inFlight = new Map>(); /** * The learner's own language code, the one translations are written in. @@ -135,6 +137,28 @@ export class TranslateService { }; } + /** + * E-04: register a request as in flight, so an identical call made before + * it answers waits for it instead of sending a second request. The entry is + * removed when the request settles, so a failure is never reused or cached. + */ + private startInFlight(cacheKey: string) { + let resolve!: (value: any) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // Nobody may be waiting on it: a failure here must not be "unhandled". + promise.catch(() => {}); + this.inFlight.set(cacheKey, promise); + const settle = () => this.inFlight.delete(cacheKey); + return { + resolve: (value: any) => (settle(), resolve(value)), + reject: (reason: unknown) => (settle(), reject(reason)), + }; + } + // Clean expired cache entries private cleanExpiredCache(): void { const now = Date.now(); @@ -160,6 +184,11 @@ export class TranslateService { return cachedResult; } + // The same request is already on its way: share it (E-04). + const pending = this.inFlight.get(cacheKey); + if (pending) return pending; + const inFlight = this.startInFlight(cacheKey); + // If not cached, fetch from API try { const result = await withAuthRetry(() => @@ -180,11 +209,13 @@ export class TranslateService { // Cache the result this.cacheResult(cacheKey, result); + inFlight.resolve(result); return result; } catch (error) { // Log error but don't cache failed requests console.error("Translation error:", error); + inFlight.reject(error); throw error; } } @@ -204,6 +235,11 @@ export class TranslateService { return cachedResult; } + // The same request is already on its way: share it (E-04). + const pending = this.inFlight.get(cacheKey); + if (pending) return pending; + const inFlight = this.startInFlight(cacheKey); + // If not cached, fetch from API try { const data = await withAuthRetry(() => @@ -229,11 +265,13 @@ export class TranslateService { // Cache the result this.cacheResult(cacheKey, data); + inFlight.resolve(data); return data; } catch (error) { // Log error but don't cache failed requests console.error("Detailed translation error:", error); + inFlight.reject(error); throw error; } } diff --git a/src/subtitle/_support-template/components/Subtitle.vue b/src/subtitle/_support-template/components/Subtitle.vue index 2639cdc..c6946fb 100644 --- a/src/subtitle/_support-template/components/Subtitle.vue +++ b/src/subtitle/_support-template/components/Subtitle.vue @@ -69,10 +69,14 @@