perf(translation): per-message cache subscription; bounded atom families

Every text message subscribed to the whole translation cache array, so
one translation re-rendered the entire timeline. Messages now subscribe
to their own keyed entry, the cache key is memoised, the auto-translate
"already tried" guard is module-scoped (no re-detect on virtualised
remount), and both atom families are FIFO-capped and evicted on logout.
Unit-tested.

Fixes #39

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-15 21:32:06 -04:00
co-authored by Claude Opus 5
parent 9e566807b3
commit 9c1c29f4fc
3 changed files with 194 additions and 21 deletions
+19 -12
View File
@@ -1,13 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
import {
translationCacheAtom,
translationEntryAtomFamily,
msgTranslationActiveAtomFamily,
makeCacheKey,
findTranslation,
addTranslation,
wasAutoTranslateTried,
markAutoTranslateTried,
} from '../state/translation';
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
import { sameLanguage } from '../utils/translation/langUtils';
@@ -47,12 +49,17 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
const [targetLang] = useSetting(settingsAtom, 'translateTargetLang');
const [autoTranslate] = useSetting(settingsAtom, 'autoTranslate');
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
const [cache, setCache] = useAtom(translationCacheAtom);
// Write-only: reading the whole cache array here (via useAtom) would
// resubscribe this message to every other message's writes. [Gitea #39]
const setCache = useSetAtom(translationCacheAtom);
// The key includes a fingerprint of `text`, so an edit (same event id, new
// body) misses the old entry and re-translates instead of showing stale text.
const key = makeCacheKey(eventId, targetLang, text);
const cached = findTranslation(cache, key);
// body) misses the old entry and re-translates instead of showing stale
// text. Memoised so the O(len) fingerprint isn't recomputed every render.
const key = useMemo(() => makeCacheKey(eventId, targetLang, text), [eventId, targetLang, text]);
// Subscribes to only this message's cache slot, so translating one message
// no longer re-renders every mounted message. [Gitea #39]
const cached = useAtomValue(translationEntryAtomFamily(key));
const [status, setStatus] = useState<MsgTranslationStatus>('idle');
const [translated, setTranslated] = useState<string | undefined>(undefined);
@@ -74,13 +81,13 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
// Auto-translate: when enabled, flip `active` on for foreign messages whose
// model is already downloaded (no gesture/download needed). Messages needing
// a first-time download keep the manual chip (which provides the gesture).
// Store the text we last auto-tried so an edit (new text) re-triggers auto
// detection instead of being suppressed by a one-shot flag.
const autoTriedText = useRef<string | undefined>(undefined);
// The "already tried" guard is hoisted to module scope (keyed by eventId) so
// it survives unmount — virtualised scrolling used to re-run detection every
// time the same message remounted with a per-hook-instance ref. [Gitea #39]
useEffect(() => {
if (!supported || !autoTranslate || active || cached || !eventId) return;
if (!text.trim() || autoTriedText.current === text) return;
autoTriedText.current = text;
if (!text.trim() || wasAutoTranslateTried(eventId, text)) return;
markAutoTranslateTried(eventId, text);
let alive = true;
(async () => {
const from = await engine.detect(text);