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
+68 -5
View File
@@ -1,5 +1,5 @@
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage, atomFamily } from 'jotai/utils';
import { atomWithStorage, createJSONStorage, atomFamily, selectAtom } from 'jotai/utils';
import { normalizeLang } from '../utils/translation/langUtils';
export type CachedTranslation = {
@@ -73,15 +73,75 @@ export const addTranslation = (
return [entry, ...withoutDupe].slice(0, max);
};
// [Gitea #39] Every `atomFamily` here is capped with simple FIFO eviction:
// a message mounts one entry in each family regardless of whether it's ever
// translated, so scrolling a long-lived session used to grow both families
// (and, for the active family, forever — it was never `.remove()`d at all).
// The cap is generous relative to what's ever rendered at once, so evicting
// the oldest-*created* member (not oldest-used) doesn't touch on-screen
// messages in practice; a false eviction just means that message's atom is
// recreated at its default value next access, which is harmless here (a
// re-derived cache lookup / a re-collapsed "active" toggle).
const FAMILY_CAP = 500;
// Per-event "show translation" toggle, shared between the message menu action
// (which flips it) and the inline body render (which reacts to it). Keyed by
// event id so each message has its own independent state.
export const msgTranslationActiveAtomFamily = atomFamily((_eventId: string) => atom(false));
const activeFamilyOrder: string[] = [];
export const msgTranslationActiveAtomFamily = atomFamily((_eventId: string) => {
activeFamilyOrder.push(_eventId);
if (activeFamilyOrder.length > FAMILY_CAP) {
const oldest = activeFamilyOrder.shift();
if (oldest !== undefined) msgTranslationActiveAtomFamily.remove(oldest);
}
return atom(false);
});
/**
* Wipe the persisted translation cache. Called on logout — cached entries are
* decrypted message plaintext, so they must not survive a session on a shared
* device (mirrors the search-index wipe).
* Per-`(eventId, targetLang, textFingerprint)` cache slot, derived from the
* shared `translationCacheAtom` array via `selectAtom` with `Object.is`
* equality. `addTranslation` preserves the object reference of every entry it
* doesn't touch, so a message only re-renders when *its own* entry changes —
* fixing the render storm where translating one message re-rendered every
* mounted message subscribed to the whole array.
*/
const entryFamilyOrder: string[] = [];
export const translationEntryAtomFamily = atomFamily((key: string) => {
entryFamilyOrder.push(key);
if (entryFamilyOrder.length > FAMILY_CAP) {
const oldest = entryFamilyOrder.shift();
if (oldest !== undefined) translationEntryAtomFamily.remove(oldest);
}
return selectAtom(translationCacheAtom, (list) => findTranslation(list, key), Object.is);
});
// Auto-translate "already tried" guard, hoisted to module scope and keyed by
// event id (previously a `useRef` per hook instance, so virtualised scrolling
// re-ran `engine.detect` every time the same message remounted). Stores the
// text last auto-tried so an edit — same event id, new body — still
// re-triggers detection instead of being suppressed by a stale entry.
const AUTO_TRIED_CAP = 500;
const autoTriedTextByEvent = new Map<string, string>();
export const wasAutoTranslateTried = (eventId: string, text: string): boolean =>
autoTriedTextByEvent.get(eventId) === text;
export const markAutoTranslateTried = (eventId: string, text: string): void => {
// Re-insert so the Map's iteration (insertion) order tracks recency for
// the FIFO cap below.
autoTriedTextByEvent.delete(eventId);
autoTriedTextByEvent.set(eventId, text);
if (autoTriedTextByEvent.size > AUTO_TRIED_CAP) {
const oldest = autoTriedTextByEvent.keys().next().value;
if (oldest !== undefined) autoTriedTextByEvent.delete(oldest);
}
};
/**
* Wipe the persisted translation cache, every live family member, and the
* auto-translate guard. Called on logout — all of this is derived from
* decrypted message plaintext, so none of it must survive a session on a
* shared device (mirrors the search-index wipe).
*/
export const clearTranslationCache = (): void => {
try {
@@ -89,4 +149,7 @@ export const clearTranslationCache = (): void => {
} catch {
/* localStorage unavailable — nothing to clear */
}
activeFamilyOrder.splice(0).forEach((eventId) => msgTranslationActiveAtomFamily.remove(eventId));
entryFamilyOrder.splice(0).forEach((key) => translationEntryAtomFamily.remove(key));
autoTriedTextByEvent.clear();
};