From 35006cbf779ec10544bb201e1e3d63eddd967f33 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Fri, 16 Oct 2026 19:05:10 +0300 Subject: [PATCH] perf(translation): E-04 E-06 share in-flight translations and translate each caption once - E-04: TranslateService keeps a map of requests in flight by cache key. An identical call made before the first one answers waits for it and gets the same answer, so only one request goes out. The entry is removed when the request settles; a failure reaches every waiting caller and is never cached. - E-06: new src/subtitle/helpers/caption-translation-gate.ts. With "translate whole caption" on, the Netflix and support-template caption components wait about 150 ms for the caption to settle, then send it only when the joined text differs from the caption already translated on screen. A failed caption can be sent again. Turning the option on still translates at once. - Tests: in-flight cases in tests/translate.service.test.ts, new tests/caption-translation-gate.test.ts. Work list: subturtle-doc docs/tech/analytics-fixes-implementation.md Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + src/common/services/translate.service.ts | 38 +++++++++ .../_support-template/components/Subtitle.vue | 39 +++++++-- .../helpers/caption-translation-gate.ts | 71 ++++++++++++++++ .../web_netflix/components/Subtitle.vue | 37 +++++++-- tests/caption-translation-gate.test.ts | 83 +++++++++++++++++++ tests/translate.service.test.ts | 69 +++++++++++++++ 7 files changed, 322 insertions(+), 16 deletions(-) create mode 100644 src/subtitle/helpers/caption-translation-gate.ts create mode 100644 tests/caption-translation-gate.test.ts 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 @@