[watch-history] activate fast cold-load render + background chunk backfill

Slice B part 2 (final). On a cold watch-history load (nothing cached to
show yet) the service now decrypts only the newest chunks first
(chunkDecryptLimit = 3) so the UI renders recent history immediately
instead of freezing behind a deep, monthly-chunked history — the original
NIP-46 symptom. If older chunks were deferred, a single background pass
then backfills them (no chunk cap; already-decrypted chunks are served
from cache, so only the deferred older chunks cost signer RPCs) and emits
"fingerprint", which the history view already re-renders on. The serial
NIP-46 request queue paces the backfill, so there is no rate-limit flood.

Scoped to cold loads only: a warm refresh (items already on screen)
resolves the full history directly, avoiding a transient "history shrank
then grew" flicker. No view / feed-engine changes and no change to the
shared For You / explore paths beyond a fast first render then a fill-in.

Chosen over strict scroll-triggered lazy loading, which would have
required risky surgery on the history view's feed-engine + cursor
pagination and the shared loadLatest path pre-launch.

Tests: cold load passes a finite chunk limit on the first resolve, then an
unbounded background backfill runs and emits recent-only -> full; warm
refresh is unbounded. Both mutation-verified (removing the bound and
no-op'ing the backfill each fail).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-17 18:23:10 -04:00
1 parent 362016ead6
commit 70ce597a86
2 files changed
+264 -1

No files matched your search

+65 -1
View File
@@ -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: [] });
}
@@ -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);
}
});