From 4d79b9babaaea9acc93fb2185db18413c282b8d4 Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Mon, 14 Sep 2026 13:57:27 -0400 Subject: [PATCH] Remove the decoded-avatar hot set A/B measurement against the live community showed warm and cold avatar decodes are identical at 1-2ms for thumbnail-sized media, so the 16 MiB decoded-element LRU bought nothing. Media preparation is now request warming only, the react-native-web Image model: fetch, decode(), release. Speculative warming is skipped under the Save-Data preference. Signed-off-by: Charlie Croom Co-authored-by: Amp Signed-off-by: Charlie Croom Amp-Thread-ID: https://ampcode.com/threads/T-01a0a053-f85b-754c-a353-03fb77a26363 --- docs/channels.md | 3 +- src/features/relay/media.test.ts | 84 +++++++++++++++++++++++--------- src/features/relay/media.ts | 45 ++++++++++------- src/features/relay/store.ts | 3 +- 4 files changed, 93 insertions(+), 42 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index 28ace4f5..c969f6cb 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -97,7 +97,8 @@ The port retains the prepared-store implementation and its behavior tests: Selecting an already-queued catch-up promotes that existing read without adding a request or resetting its deadline. - 1,024 profile entries / 2 MiB signed-record budget, narrow row profile selectors, - and a bounded avatar preparation cache. Signature verification yields in batches. + and request-warmed avatars (fetched and decoded, nothing retained; disabled + under the Save-Data preference). Signature verification yields in batches. - Account/relay-scoped IndexedDB: 64 records / 8 MiB global disk budget, 24-hour expiry. Cached events are reverified only after fresh roster authorization. - A 60-second head freshness lease; warm revisits reuse heads without new reads. diff --git a/src/features/relay/media.test.ts b/src/features/relay/media.test.ts index 743223e9..293d03b8 100644 --- a/src/features/relay/media.test.ts +++ b/src/features/relay/media.test.ts @@ -1,44 +1,84 @@ import { assert, afterEach, expect, it, vi } from "vitest"; -import { createMediaPreparation } from "./media"; +import { createMediaPreparation, saveData } from "./media"; afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); -it("bounds speculation, charges decoded pixels, and releases active image timers on dispose", async () => { - vi.useFakeTimers(); - const created: FakeImage[] = []; - class FakeImage { - onload: (() => void) | null = null; - onerror: (() => void) | null = null; - naturalWidth = 100; - naturalHeight = 100; - src = ""; - referrerPolicy = ""; - decode = vi.fn(async () => {}); - constructor() { - created.push(this); - } + +class FakeImage { + static created: FakeImage[] = []; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 100; + naturalHeight = 100; + src = ""; + referrerPolicy = ""; + decode = vi.fn(async () => {}); + constructor() { + FakeImage.created.push(this); } +} + +function stubImages() { + FakeImage.created = []; vi.stubGlobal("Image", FakeImage); - const media = createMediaPreparation({ maxBytes: 100_000, maxEntries: 2 }); + return FakeImage.created; +} + +const flushDecode = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +it("warms requests without retaining decoded bytes, and releases active image timers on dispose", async () => { + vi.useFakeTimers(); + const created = stubImages(); + const media = createMediaPreparation(); media.prepare(["a", "b", "c", "d"]); expect(media.stats()).toMatchObject({ active: 2, queued: 2 }); const first = created[0]; assert.exists(first); assert.exists(first.onload); first.onload(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - expect(media.stats().bytes).toBe(40_000); + await flushDecode(); + expect(first.decode).toHaveBeenCalled(); + // The freed slot immediately promotes the next queued speculation. + expect(media.stats()).toMatchObject({ active: 2, queued: 1, decoded: 1 }); const second = created[1]; assert.exists(second); - second.naturalWidth = 10000; + second.naturalWidth = 21000; assert.exists(second.onload); second.onload(); + // Oversized originals are not explicitly decoded just to prepare an avatar. expect(second.decode).not.toHaveBeenCalled(); media.dispose(); - expect(media.stats()).toEqual({ entries: 0, bytes: 0, active: 0, queued: 0 }); + expect(media.stats()).toEqual({ active: 0, queued: 0, decoded: 1 }); expect(vi.getTimerCount()).toBe(0); }); + +it("skips urls already in flight", () => { + stubImages(); + const media = createMediaPreparation(); + media.prepare(["a"]); + media.prepare(["a", "a"]); + expect(FakeImage.created).toHaveLength(1); +}); + +it("Save-Data disables warming but nothing else", () => { + stubImages(); + vi.stubGlobal("navigator", { connection: { saveData: true } }); + expect(saveData()).toBe(true); + const media = createMediaPreparation(); + media.prepare(["a", "b"]); + expect(media.stats()).toEqual({ active: 0, queued: 0, decoded: 0 }); + expect(FakeImage.created).toHaveLength(0); + + vi.stubGlobal("navigator", { connection: { saveData: false } }); + media.prepare(["a"]); + expect(FakeImage.created).toHaveLength(1); + + vi.stubGlobal("navigator", {}); + expect(saveData()).toBe(false); +}); diff --git a/src/features/relay/media.ts b/src/features/relay/media.ts index 93b96843..11dac2c8 100644 --- a/src/features/relay/media.ts +++ b/src/features/relay/media.ts @@ -1,16 +1,14 @@ -import { ByteLru } from "./budget"; -/** Small decoded-avatar hot set. Never warms originals/attachments that this renderer doesn't display. - * Natural dimensions account for decoded pixels, not compressed transfer bytes. */ -export function createMediaPreparation({ - maxBytes = 16 * 1024 * 1024, - maxEntries = 64, -} = {}) { - const images = new ByteLru(maxEntries, maxBytes); +/** Avatar request warming, the react-native-web Image model: fetch and + * decode() into detached images, then retain nothing. The browser's own HTTP + * and decoded-image caches serve the real mounts. Never warms + * originals/attachments that this renderer doesn't display. */ +export function createMediaPreparation() { const pending = new Set(); let queue: string[] = []; let disposed = false; const active = new Set(); const cancellations = new Map void>(); + let decoded = 0; function pump() { if (disposed || typeof Image === "undefined") return; while (active.size < 2 && queue.length) { @@ -33,12 +31,11 @@ export function createMediaPreparation({ image.src = ""; finish(); }, 10000); - cancellations.set(image, finish); - image.onerror = finish; + cancellations.set(image, () => finish()); + image.onerror = () => finish(); image.onload = () => { // Do not explicitly decode enormous originals just to prepare an avatar. - const bytes = image.naturalWidth * image.naturalHeight * 4; - if (bytes > maxBytes / 2) { + if (image.naturalWidth * image.naturalHeight * 4 > 8 * 1024 * 1024) { finish(); return; } @@ -46,11 +43,11 @@ export function createMediaPreparation({ .decode() .then( () => { - if (!disposed) images.set(url, image, bytes); + decoded++; }, () => {}, ) - .finally(finish); + .finally(() => finish()); }; image.referrerPolicy = "no-referrer"; image.src = url; @@ -58,26 +55,25 @@ export function createMediaPreparation({ } return { prepare(urls: readonly string[]) { - if (disposed || typeof Image === "undefined") return; + if (disposed || saveData() || typeof Image === "undefined") return; // New intent replaces queued speculation; active requests remain capped at two. for (const url of queue) pending.delete(url); queue = []; for (const url of [...new Set(urls)].slice(0, 24)) { - if (images.get(url) || pending.has(url)) continue; + if (pending.has(url)) continue; pending.add(url); queue.push(url); } pump(); }, stats: () => ({ - ...images.stats(), active: active.size, queued: queue.length, + decoded, }), dispose() { disposed = true; queue = []; - images.clear(); for (const image of active) { image.src = ""; cancellations.get(image)?.(); @@ -86,3 +82,16 @@ export function createMediaPreparation({ }, }; } + +/** The Save-Data preference (Network Information API) opts out of speculative + * media traffic. Demand fetches still happen; only warming is disabled. */ +export function saveData(): boolean { + try { + const connection = ( + navigator as Navigator & { connection?: { saveData?: boolean } } + ).connection; + return connection?.saveData === true; + } catch { + return false; + } +} diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 6b089586..4e63416d 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -17,7 +17,7 @@ import type { ProfileDirectory } from "./profile-directory"; import { parseWindow, windowFilter, type WindowCursor } from "./window"; import { ByteLru, byteSize } from "./budget"; import type { HeadPersistence, SavedHead } from "./persistence"; -import { createMediaPreparation } from "./media"; +import { createMediaPreparation, saveData } from "./media"; type Listener = () => void; type WindowState = { @@ -921,6 +921,7 @@ export function createChannelStore( const head = heads.get(channelId); if (head) prepareMedia(channelId); if ( + saveData() || preparing || (head && !head.cached && now() - head.savedAt < FRESH_FOR) )