diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index d13c1f15..1b052db0 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -21,12 +21,21 @@ import { ONE_MINUTE_MS } from "./constants.js"; const LOCAL_STORAGE_QUEUE_KEY = "bitvid:watch-history:queue:v1"; const SESSION_STORAGE_VERSION = 1; const POINTER_THROTTLE_MS = ONE_MINUTE_MS; +// On a cold load (nothing cached to show) we decrypt only the newest chunks up +// front so the UI renders quickly instead of freezing behind a deep history; +// the rest are filled in the background. Monthly chunks, so ~3 covers recent +// activity for the first paint. +const WATCH_HISTORY_INITIAL_CHUNK_DECRYPT_LIMIT = 3; + const state = { restored: false, queues: new Map(), inflightSnapshots: new Map(), fingerprintCache: new Map(), listeners: new Map(), + // Actors with an in-flight background backfill of deferred (older) chunks, + // so we never launch two at once for the same actor. + chunkBackfills: new Set(), }; function getLoggedInActorKey() { @@ -1009,6 +1018,40 @@ async function snapshot(items, options = {}) { return run; } +function maybeBackfillDeferredChunks(actorKey) { + if (!actorKey || state.chunkBackfills.has(actorKey)) { + return; + } + const deferred = + typeof nostrClient.getWatchHistoryDeferredChunkCount === "function" + ? Number(nostrClient.getWatchHistoryDeferredChunkCount(actorKey)) || 0 + : 0; + if (deferred <= 0) { + return; + } + state.chunkBackfills.add(actorKey); + devLogger.info("[watchHistoryService] Backfilling deferred watch history chunks.", { + actor: actorKey, + deferred, + }); + // Full decrypt pass (no chunk cap). Already-decrypted chunks are served from + // cache, so only the deferred older chunks incur signer RPCs. The resulting + // larger item set changes the fingerprint, which emits "fingerprint" and lets + // listeners (e.g. the history view) re-render with the complete history. + Promise.resolve() + .then(() => nostrClient.resolveWatchHistory(actorKey, { forceRefresh: true })) + .then((fullItems) => updateFingerprintCache(actorKey, fullItems)) + .catch((error) => { + devLogger.warn( + "[watchHistoryService] Deferred chunk backfill failed:", + error, + ); + }) + .finally(() => { + state.chunkBackfills.delete(actorKey); + }); +} + function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}, options = {}) { if (!actorKey) { return Promise.resolve([]); @@ -1018,6 +1061,15 @@ function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}, options = {}) { return cacheEntry.promise; } + // Cold load = nothing cached to show yet, which is exactly when a deep + // history would freeze the first render. Bound the first decrypt pass to the + // newest chunks and backfill the rest in the background. On a warm refresh we + // already have items on screen, so resolve the full history directly to avoid + // a transient "history shrank then grew" flicker. + const hasCachedItems = + Array.isArray(cacheEntry?.items) && cacheEntry.items.length > 0; + const useBoundedFirstPass = !hasCachedItems; + const promise = (async () => { const permissionResult = await ensureWatchHistoryExtensionPermissions( actorKey, @@ -1026,10 +1078,20 @@ function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}, options = {}) { if (!permissionResult.ok) { throw permissionResult.error; } - return nostrClient.resolveWatchHistory(actorKey, { forceRefresh: true }); + return nostrClient.resolveWatchHistory(actorKey, { + forceRefresh: true, + ...(useBoundedFirstPass + ? { chunkDecryptLimit: WATCH_HISTORY_INITIAL_CHUNK_DECRYPT_LIMIT } + : {}), + }); })() .then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems)) .then(() => { + if (useBoundedFirstPass) { + // Older chunks were skipped to render fast; fill them in the background + // (paced by the serial NIP-46 queue) and emit when done. + maybeBackfillDeferredChunks(actorKey); + } const latest = state.fingerprintCache.get(actorKey); return latest?.items || []; }) @@ -1367,6 +1429,7 @@ function resetProgress(actorInput) { state.queues.delete(actorKey); state.fingerprintCache.delete(actorKey); state.inflightSnapshots.delete(actorKey); + state.chunkBackfills.delete(actorKey); persistQueueState(); notifyQueueChange(actorKey); return; @@ -1374,6 +1437,7 @@ function resetProgress(actorInput) { state.queues.clear(); state.fingerprintCache.clear(); state.inflightSnapshots.clear(); + state.chunkBackfills.clear(); persistQueueState(); emit("queue-changed", { actor: null, items: [] }); } diff --git a/tests/watch-history-cold-load-backfill.test.mjs b/tests/watch-history-cold-load-backfill.test.mjs new file mode 100644 index 00000000..78da20c0 --- /dev/null +++ b/tests/watch-history-cold-load-backfill.test.mjs @@ -0,0 +1,199 @@ +// Scenario test for bounded watch-history loading (slice B part 2). +// +// SCN-watch-history-cold-load-fast-then-backfill: +// Given a COLD watch-history load (nothing cached to show yet) whose first, +// chunk-capped decrypt pass leaves older chunks undecrypted, +// When the history is loaded, +// Then the first resolve pass is bounded (a finite chunkDecryptLimit) so the +// UI can render recent history immediately, +// And a SINGLE background pass with NO chunk cap then backfills the deferred +// older chunks and emits "fingerprint" with the complete history. +// +// This is what keeps a deep history under a NIP-46 remote signer from freezing +// the first render (and flooding the relay rate limit) while still surfacing +// the full history without the user having to do anything. + +import "./test-helpers/setup-localstorage.mjs"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { nostrClient } = await import("../js/nostrClientFacade.js"); +const { watchHistoryService } = await import("../js/watchHistoryService.js"); + +const ACTOR = "b".repeat(64); + +test("cold load decrypts newest chunks first, then backfills deferred chunks in the background", async () => { + const original = { + pubkey: nostrClient.pubkey, + sessionActor: nostrClient.sessionActor, + resolveWatchHistory: nostrClient.resolveWatchHistory, + getWatchHistoryDeferredChunkCount: + nostrClient.getWatchHistoryDeferredChunkCount, + getWatchHistoryFingerprint: nostrClient.getWatchHistoryFingerprint, + }; + + // Logged-in, non-session actor so the feature is enabled for ACTOR. + nostrClient.pubkey = ACTOR; + nostrClient.sessionActor = null; + watchHistoryService.resetProgress(ACTOR); + + const recentItems = [{ pointer: { type: "e", value: "recent" }, watchedAt: 200 }]; + const fullItems = [ + { pointer: { type: "e", value: "recent" }, watchedAt: 200 }, + { pointer: { type: "e", value: "older" }, watchedAt: 100 }, + ]; + + const chunkLimitArgs = []; + let deferred = 0; + + nostrClient.resolveWatchHistory = async (_actor, opts = {}) => { + chunkLimitArgs.push(opts.chunkDecryptLimit); + if (chunkLimitArgs.length === 1) { + // First (bounded) pass: leave one older chunk undecrypted. + deferred = 1; + return recentItems; + } + // Background backfill pass: full history, nothing left deferred. + deferred = 0; + return fullItems; + }; + nostrClient.getWatchHistoryDeferredChunkCount = () => deferred; + // Fingerprint derived from the item set so recent vs full differ (the change + // is what drives the "fingerprint" emit the history view re-renders on). + nostrClient.getWatchHistoryFingerprint = async (_actor, items) => + `fp-${Array.isArray(items) ? items.length : 0}`; + + const emittedItemCounts = []; + let resolveSecondEmit; + const gotSecondEmit = new Promise((resolve) => { + resolveSecondEmit = resolve; + }); + const unsubscribe = watchHistoryService.subscribe("fingerprint", (payload) => { + emittedItemCounts.push( + Array.isArray(payload?.items) ? payload.items.length : 0, + ); + if (emittedItemCounts.length === 2) { + resolveSecondEmit(); + } + }); + + try { + const firstPassItems = await watchHistoryService.loadLatest(ACTOR, { + forceRefresh: true, + }); + + // The blocking load returns only the recent (fast) items. + assert.deepStrictEqual( + firstPassItems.map((item) => item.pointer.value), + ["recent"], + "the initial (non-blocking) render should show only recent history", + ); + + // The first pass is bounded (a finite, positive chunk limit) regardless of + // how quickly the background backfill follows. + assert.equal( + typeof chunkLimitArgs[0], + "number", + "the first pass must pass a finite chunkDecryptLimit (bounded)", + ); + assert.ok( + chunkLimitArgs[0] > 0, + "the first pass chunk limit must be a positive bound", + ); + + // Background backfill runs without us asking — wait for its emit. + await gotSecondEmit; + + assert.equal(chunkLimitArgs.length, 2, "a background backfill pass should run"); + assert.equal( + chunkLimitArgs[1], + undefined, + "the backfill pass must be UNbounded (full history)", + ); + assert.equal(deferred, 0, "no chunks should remain deferred after backfill"); + assert.deepStrictEqual( + emittedItemCounts, + [1, 2], + "should emit recent-only first, then the full history after backfill", + ); + } finally { + unsubscribe(); + nostrClient.pubkey = original.pubkey; + nostrClient.sessionActor = original.sessionActor; + nostrClient.resolveWatchHistory = original.resolveWatchHistory; + nostrClient.getWatchHistoryDeferredChunkCount = + original.getWatchHistoryDeferredChunkCount; + nostrClient.getWatchHistoryFingerprint = original.getWatchHistoryFingerprint; + watchHistoryService.resetProgress(ACTOR); + } +}); + +test("warm refresh (items already cached) is NOT bounded, to avoid a shrink-then-grow flicker", async () => { + const original = { + pubkey: nostrClient.pubkey, + sessionActor: nostrClient.sessionActor, + resolveWatchHistory: nostrClient.resolveWatchHistory, + getWatchHistoryDeferredChunkCount: + nostrClient.getWatchHistoryDeferredChunkCount, + getWatchHistoryFingerprint: nostrClient.getWatchHistoryFingerprint, + }; + + const WARM_ACTOR = "c".repeat(64); + nostrClient.pubkey = WARM_ACTOR; + nostrClient.sessionActor = null; + watchHistoryService.resetProgress(WARM_ACTOR); + + const recentItems = [{ pointer: { type: "e", value: "recent" }, watchedAt: 200 }]; + const fullItems = [ + { pointer: { type: "e", value: "recent" }, watchedAt: 200 }, + { pointer: { type: "e", value: "older" }, watchedAt: 100 }, + ]; + + const chunkLimitArgs = []; + let deferred = 0; + nostrClient.resolveWatchHistory = async (_actor, opts = {}) => { + chunkLimitArgs.push(opts.chunkDecryptLimit); + if (chunkLimitArgs.length === 1) { + deferred = 1; + return recentItems; + } + deferred = 0; + return fullItems; + }; + nostrClient.getWatchHistoryDeferredChunkCount = () => deferred; + let fpCounter = 0; + // Force a fingerprint change every call so each refresh genuinely re-resolves. + nostrClient.getWatchHistoryFingerprint = async () => `fp-${(fpCounter += 1)}`; + + try { + // Cold load primes the cache (bounded first pass). + await watchHistoryService.loadLatest(WARM_ACTOR, { forceRefresh: true }); + // Let any background backfill settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + const passesAfterCold = chunkLimitArgs.length; + assert.ok(passesAfterCold >= 1, "cold load should resolve at least once"); + assert.equal( + chunkLimitArgs[0], + 3, + "cold load first pass is bounded to the initial chunk limit", + ); + + // Warm refresh: items are now cached, so this must be UNbounded. + chunkLimitArgs.length = 0; + await watchHistoryService.loadLatest(WARM_ACTOR, { forceRefresh: true }); + assert.ok(chunkLimitArgs.length >= 1, "warm refresh should resolve"); + assert.equal( + chunkLimitArgs[0], + undefined, + "warm refresh must be unbounded (no transient history shrink)", + ); + } finally { + nostrClient.pubkey = original.pubkey; + nostrClient.sessionActor = original.sessionActor; + nostrClient.resolveWatchHistory = original.resolveWatchHistory; + nostrClient.getWatchHistoryDeferredChunkCount = + original.getWatchHistoryDeferredChunkCount; + nostrClient.getWatchHistoryFingerprint = original.getWatchHistoryFingerprint; + watchHistoryService.resetProgress(WARM_ACTOR); + } +});