mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
362016ead6
commit
70ce597a86
2 files changed
+264
-1
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user