diff --git a/js/historyView.js b/js/historyView.js index 01fa8085..1bf8082b 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -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; } diff --git a/js/ui/watchHistoryController.js b/js/ui/watchHistoryController.js index f122ee71..f2d3058d 100644 --- a/js/ui/watchHistoryController.js +++ b/js/ui/watchHistoryController.js @@ -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) : []; diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 408a6a83..a201cd6f 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -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) { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 2c537da5..56a201b0 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -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();