Improve watch history loading responsiveness

This commit is contained in:
thePR0M3TH3AN committed 2025-10-08 07:33:44 -04:00
1 parent 9d7efce44e
commit 10751a8fb9
4 files changed
+263 -35

No files matched your search

+60
View File
@@ -654,6 +654,12 @@ export function createWatchHistoryRenderer(config = {}) {
featureEnabled: syncEnabled || localSupported,
};
let rendererRef = null;
let fingerprintRefreshQueued = false;
const scheduleTask =
typeof queueMicrotask === "function"
? queueMicrotask
: (callback) => Promise.resolve().then(callback);
let elements = {
view: null,
grid: null,
@@ -733,6 +739,7 @@ export function createWatchHistoryRenderer(config = {}) {
}
}
subscriptions.clear();
fingerprintRefreshQueued = false;
}
function clearToasts() {
@@ -907,6 +914,50 @@ export function createWatchHistoryRenderer(config = {}) {
}
}
function queueFingerprintRefresh() {
if (!fingerprintRefreshQueued) {
return;
}
if (state.isLoading) {
return;
}
if (!rendererRef) {
return;
}
fingerprintRefreshQueued = false;
void rendererRef.refresh({ force: false });
}
function subscribeToFingerprintUpdates() {
if (typeof watchHistoryService.subscribe !== "function") {
return;
}
const unsubscribe = watchHistoryService.subscribe(
"fingerprint",
(payload) => {
if (!payload) {
return;
}
if (
payload.actor &&
state.actor &&
payload.actor !== state.actor
) {
return;
}
fingerprintRefreshQueued = true;
if (!state.isLoading) {
scheduleTask(() => {
queueFingerprintRefresh();
});
}
}
);
if (typeof unsubscribe === "function") {
subscriptions.add(unsubscribe);
}
}
function setLoadingState(loading) {
state.isLoading = loading;
if (elements.loading) {
@@ -1546,6 +1597,11 @@ export function createWatchHistoryRenderer(config = {}) {
result = null;
}
setLoadingState(false);
if (fingerprintRefreshQueued) {
scheduleTask(() => {
queueFingerprintRefresh();
});
}
const feedItems = Array.isArray(result?.items) ? result.items : [];
const normalized = [];
state.metadataCache.clear();
@@ -1644,6 +1700,7 @@ export function createWatchHistoryRenderer(config = {}) {
bindMetadataToggle();
subscribeToMetadataPreference();
subscribeToRepublishEvents();
subscribeToFingerprintUpdates();
updateInfoCallout();
state.metadataStorageEnabled =
typeof watchHistoryService.shouldStoreMetadata === "function"
@@ -1787,6 +1844,8 @@ export function createWatchHistoryRenderer(config = {}) {
state.cursor = 0;
state.hasMore = false;
state.metadataCache.clear();
rendererRef = null;
fingerprintRefreshQueued = false;
},
async handleRemove(pointerKeyValue) {
if (!pointerKeyValue) {
@@ -1884,6 +1943,7 @@ export function createWatchHistoryRenderer(config = {}) {
},
};
rendererRef = renderer;
return renderer;
}
+4 -1
View File
@@ -277,7 +277,10 @@ export default class WatchHistoryController {
if (!normalizedItems) {
try {
const latest = await this.watchHistoryService.loadLatest(actorCandidate);
const latest = await this.watchHistoryService.loadLatest(
actorCandidate,
{ allowStale: false },
);
normalizedItems = Array.isArray(latest)
? latest.map(normalizeEntry).filter(Boolean)
: [];
+73 -33
View File
@@ -1200,12 +1200,58 @@ async function snapshot(items, options = {}) {
return run;
}
async function loadLatest(actorInput) {
function scheduleWatchHistoryRefresh(actorKey, cacheEntry = {}) {
if (!actorKey) {
return Promise.resolve([]);
}
if (cacheEntry && typeof cacheEntry === "object" && cacheEntry.promise) {
return cacheEntry.promise;
}
const promise = nostrClient
.resolveWatchHistory(actorKey, { forceRefresh: true })
.then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems))
.then(() => {
const latest = state.fingerprintCache.get(actorKey);
return latest?.items || [];
})
.catch((error) => {
if (isDevMode) {
console.warn("[watchHistoryService] Failed to load watch history:", error);
}
throw error;
})
.finally(() => {
const entry = state.fingerprintCache.get(actorKey);
if (entry) {
delete entry.promise;
}
});
console.info("[watchHistoryService] Triggered nostr watch list refresh.", {
actor: actorKey,
});
const baseEntry = cacheEntry && typeof cacheEntry === "object" ? cacheEntry : {};
state.fingerprintCache.set(actorKey, {
...baseEntry,
promise,
});
return promise;
}
async function loadLatest(actorInput, options = {}) {
const actorKey = resolveActorKey(actorInput);
if (!actorKey) {
return [];
}
const normalizedOptions =
options && typeof options === "object" ? options : {};
const allowStale = normalizedOptions.allowStale !== false;
if (!isFeatureEnabled(actorKey)) {
if (!state.restored) {
restoreQueueState();
@@ -1231,12 +1277,13 @@ async function loadLatest(actorInput) {
cacheExpiresAt: cacheEntry?.expiresAt || null,
}
);
if (
cacheEntry?.items &&
cacheEntry.expiresAt &&
cacheEntry.expiresAt > now &&
Array.isArray(cacheEntry.items)
) {
const hasCachedItems = Array.isArray(cacheEntry?.items);
const cacheIsFresh =
hasCachedItems &&
cacheEntry?.expiresAt &&
cacheEntry.expiresAt > now;
if (cacheIsFresh) {
console.info(
"[watchHistoryService] Returning cached watch list items.",
{
@@ -1254,42 +1301,35 @@ async function loadLatest(actorInput) {
actor: actorKey,
}
);
if (allowStale && hasCachedItems) {
console.info(
"[watchHistoryService] Returning stale watch list items while refresh completes.",
{
actor: actorKey,
itemCount: cacheEntry.items.length,
}
);
return cacheEntry.items;
}
return cacheEntry.promise;
}
const promise = nostrClient
.resolveWatchHistory(actorKey, { forceRefresh: true })
.then((resolvedItems) => updateFingerprintCache(actorKey, resolvedItems))
.then(() => {
const latest = state.fingerprintCache.get(actorKey);
return latest?.items || [];
})
.catch((error) => {
if (isDevMode) {
console.warn("[watchHistoryService] Failed to load watch history:", error);
}
throw error;
})
.finally(() => {
const entry = state.fingerprintCache.get(actorKey);
if (entry) {
delete entry.promise;
}
});
if (!allowStale || !hasCachedItems) {
return scheduleWatchHistoryRefresh(actorKey, cacheEntry);
}
const refreshPromise = scheduleWatchHistoryRefresh(actorKey, cacheEntry);
refreshPromise.catch(() => {});
console.info(
"[watchHistoryService] Triggered nostr watch list refresh.",
"[watchHistoryService] Returning stale watch list items while refresh is pending.",
{
actor: actorKey,
itemCount: cacheEntry?.items?.length || 0,
}
);
state.fingerprintCache.set(actorKey, {
...(cacheEntry || {}),
promise,
});
return promise;
return cacheEntry.items || [];
}
async function getFingerprint(actorInput) {
+126 -1
View File
@@ -3,7 +3,11 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
const { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_MAX_ITEMS } =
const {
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_MAX_ITEMS,
WATCH_HISTORY_CACHE_TTL_MS,
} =
await import("../js/config.js");
const {
getWatchHistoryV2Enabled,
@@ -986,6 +990,126 @@ async function testWatchHistoryServiceIntegration() {
}
}
async function testWatchHistoryStaleCacheRefresh() {
console.log("Running watch history stale cache refresh test...");
const actor = "stale-cache-actor";
const originalResolve = nostrClient.resolveWatchHistory;
const originalFingerprint = nostrClient.getWatchHistoryFingerprint;
const originalDateNow = Date.now;
try {
watchHistoryService.resetProgress(actor);
nostrClient.getWatchHistoryFingerprint = async (_actor, items) => {
const values = (Array.isArray(items) ? items : []).map((entry) => {
if (!entry) {
return "";
}
if (typeof entry.value === "string") {
return entry.value;
}
if (
entry.pointer &&
typeof entry.pointer.value === "string"
) {
return entry.pointer.value;
}
return "";
});
return `fingerprint:${values.join("|")}`;
};
const firstItems = [
{ type: "e", value: "watch-history-old", watchedAt: 1_700_000_000 },
];
let resolveCallCount = 0;
nostrClient.resolveWatchHistory = async (requestedActor) => {
resolveCallCount += 1;
assert.equal(
requestedActor,
actor,
"initial load should request the expected actor",
);
return firstItems;
};
await watchHistoryService.loadLatest(actor, { allowStale: false });
const baseNow = originalDateNow();
Date.now = () => baseNow + WATCH_HISTORY_CACHE_TTL_MS + 1;
const refreshedItems = [
{ type: "e", value: "watch-history-new", watchedAt: 1_700_000_600 },
];
let refreshResolve;
const refreshGate = new Promise((resolve) => {
refreshResolve = resolve;
});
resolveCallCount = 0;
nostrClient.resolveWatchHistory = async (requestedActor) => {
resolveCallCount += 1;
assert.equal(
requestedActor,
actor,
"refresh should continue targeting the same actor",
);
await refreshGate;
return refreshedItems;
};
const staleResult = await watchHistoryService.loadLatest(actor);
assert.equal(
resolveCallCount,
1,
"stale load should trigger a single background refresh",
);
assert.equal(
staleResult.length,
firstItems.length,
"stale load should return cached entries immediately",
);
const staleValue =
staleResult[0]?.value || staleResult[0]?.pointer?.value || "";
assert.equal(
staleValue,
"watch-history-old",
"stale load should surface the cached pointer value",
);
refreshResolve();
await refreshGate;
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
nostrClient.resolveWatchHistory = async () => {
throw new Error("loadLatest should rely on refreshed cache");
};
const freshResult = await watchHistoryService.loadLatest(actor);
assert.equal(
freshResult.length,
refreshedItems.length,
"fresh load should surface refreshed watch history entries",
);
const freshValue =
freshResult[0]?.value || freshResult[0]?.pointer?.value || "";
assert.equal(
freshValue,
"watch-history-new",
"fresh load should include the updated pointer",
);
} finally {
Date.now = originalDateNow;
nostrClient.resolveWatchHistory = originalResolve;
nostrClient.getWatchHistoryFingerprint = originalFingerprint;
watchHistoryService.resetProgress(actor);
}
}
async function testWatchHistoryLocalFallbackWhenDisabled() {
console.log("Running watch history local fallback test...");
@@ -1199,6 +1323,7 @@ await testPublishSnapshotFailureRetry();
await testWatchHistoryPartialRelayRetry();
await testResolveWatchHistoryBatchingWindow();
await testWatchHistoryServiceIntegration();
await testWatchHistoryStaleCacheRefresh();
await testWatchHistoryLocalFallbackWhenDisabled();
await testWatchHistorySyncEnabledForLoggedInUsers();
await testWatchHistoryAppLoginFallback();