From 52ceb4f21141a01b51c3ff339e31c872247b4880 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:44:01 -0400 Subject: [PATCH] perf(watch-history): load in background; re-run For You when it resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The For You feed awaited watchHistoryService.loadLatest() before rendering, and loadLatest blocked on the full ~1500-item watch-history fetch on a cold load even with allowStale:true (the `!allowStale || !hasCachedItems` guard awaited whenever there was no cache). So the main feed froze behind the entire watch-history resolution, and nothing re-ran the feed once history finished loading — watched videos were never suppressed and watch-history tags never applied until a manual refresh. - watchHistoryService.loadLatest: allowStale callers now NEVER block. On a cold load the refresh is kicked off in the background and the call returns immediately with whatever is available (cached items, else the local queue). - feedCoordinator.subscribeWatchHistoryFeedRefresh: subscribes to the "fingerprint" event and, when the For You feed is active, debounced-re-runs it so newly-resolved watched videos are suppressed and tags applied. Wired in at bootstrap alongside feed registration. The re-run reads the now-fresh cache (no further refresh scheduled), so there is no loop. Adds feedCoordinatorWatchHistoryRefresh.test.mjs (debounced single refresh while active; no refresh when inactive; idempotent subscription). Co-Authored-By: Claude Opus 4.8 --- js/app.js | 9 ++ js/app/feedCoordinator.js | 39 ++++++ js/ui/applicationBootstrap.js | 2 + js/watchHistoryService.js | 13 +- ...eedCoordinatorWatchHistoryRefresh.test.mjs | 112 ++++++++++++++++++ 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs diff --git a/js/app.js b/js/app.js index c4a54347..afef1170 100644 --- a/js/app.js +++ b/js/app.js @@ -3765,6 +3765,15 @@ class Application { return this._feed.registerForYouFeed(...args); } + /** + * Subscribe the For You feed to background watch-history resolution so it + * re-runs (suppressing watched videos, applying tags) once history loads. + */ + subscribeWatchHistoryFeedRefresh(...args) { + this._initCoordinators(); + return this._feed.subscribeWatchHistoryFeedRefresh(...args); + } + /** * Register the "kids" feed pipeline. */ diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index d3437053..19f8880d 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -1271,6 +1271,45 @@ export function createFeedCoordinator(deps) { return this.feedTelemetryState?.activeFeed === FEED_TYPES.FOR_YOU; }, + // Watch history loads in the background (the For You feed no longer blocks on + // it). When a refresh resolves and emits "fingerprint", re-run the For You + // feed so newly-resolved watched videos are suppressed and watch-history tags + // are applied. Debounced and gated to when the For You feed is active. + subscribeWatchHistoryFeedRefresh() { + if ( + this._watchHistoryFeedUnsub || + !watchHistoryService || + typeof watchHistoryService.subscribe !== "function" + ) { + return; + } + let debounceId = null; + this._watchHistoryFeedUnsub = watchHistoryService.subscribe( + "fingerprint", + () => { + if (!this.isForYouFeedActive()) { + return; + } + if (debounceId) { + clearTimeout(debounceId); + } + debounceId = setTimeout(() => { + debounceId = null; + Promise.resolve( + this.refreshFeed(FEED_TYPES.FOR_YOU, { + reason: "watch-history-resolved", + }), + ).catch((error) => { + devLogger.warn( + "[feedCoordinator] Failed to refresh For You after watch-history update:", + error, + ); + }); + }, 400); + }, + ); + }, + updateFeedTelemetryMetadata(feedName = "", items = [], metadata = {}) { if (!this.isFeedActive(feedName)) { return; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 65605954..63c44fb1 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -275,6 +275,8 @@ export default class ApplicationBootstrap { app.registerExploreFeed(); app.registerSubscriptionsFeed(); app.registerWatchHistoryFeed(); + // Re-run the For You feed when background watch-history resolution completes. + app.subscribeWatchHistoryFeedRefresh(); app.watchHistoryController = new WatchHistoryController({ watchHistoryService, diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 85c5e198..b3bfafdc 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -1278,7 +1278,7 @@ async function loadLatest(actorInput, options = {}) { return mergeQueuedItemsIfNeeded(actorKey, resolved); } - if (!allowStale || !hasCachedItems) { + if (!allowStale) { const resolved = await scheduleWatchHistoryRefresh( actorKey, cacheEntry, @@ -1287,6 +1287,13 @@ async function loadLatest(actorInput, options = {}) { return mergeQueuedItemsIfNeeded(actorKey, resolved); } + // allowStale callers (e.g. the For You feed) must NEVER block on the full + // fetch — even on a cold load with no cache. Previously this path awaited the + // refresh whenever there were no cached items, so the feed froze behind the + // entire ~1500-item watch-history resolution. Instead, kick the refresh off in + // the background and return immediately with whatever is already available + // (cached items, else the local queue). These callers re-run when the later + // "fingerprint" event fires with the resolved history. const refreshPromise = scheduleWatchHistoryRefresh( actorKey, cacheEntry, @@ -1295,14 +1302,14 @@ async function loadLatest(actorInput, options = {}) { refreshPromise.catch(() => {}); devLogger.info( - "[watchHistoryService] Returning stale watch list items while refresh is pending.", + "[watchHistoryService] Returning immediately; watch history refreshing in background.", { actor: actorKey, itemCount: cacheEntry?.items?.length || 0, } ); - return mergeQueuedItemsIfNeeded(actorKey, cacheEntry.items || []); + return mergeQueuedItemsIfNeeded(actorKey, cacheEntry?.items || []); } async function getFingerprint(actorInput) { diff --git a/tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs b/tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs new file mode 100644 index 00000000..67c04ece --- /dev/null +++ b/tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs @@ -0,0 +1,112 @@ +// Verifies the For You feed re-runs when background watch-history resolution +// completes, so newly-resolved watched videos are suppressed and watch-history +// tags applied — without the feed ever blocking on the watch-history load. +// +// Scenario (SCN-foryou-rerun-on-watch-history): +// Given the For You feed is the active feed, +// When watch history finishes resolving in the background and emits +// "fingerprint", +// Then the For You feed is refreshed (once, debounced) with the resolved +// watch-history reason; and it is NOT refreshed when a different feed is +// active. + +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import { createFeedCoordinator } from "../../js/app/feedCoordinator.js"; +import { FEED_TYPES } from "../../js/constants.js"; + +function makeCoordinator() { + let fingerprintCb = null; + const watchHistoryService = { + subscribe: (eventName, cb) => { + if (eventName === "fingerprint") fingerprintCb = cb; + return () => {}; + }, + }; + const noop = mock.fn(); + const deps = { + devLogger: { log: noop, warn: noop, info: noop, debug: noop }, + userLogger: { warn: noop }, + nostrClient: {}, + watchHistoryService, + subscriptions: {}, + getSidebarLoadingMarkup: noop, + pointerKey: () => "", + isValidMagnetUri: () => false, + readCachedUrlHealth: noop, + persistUrlHealth: noop, + createActiveNostrSource: noop, + createBlacklistFilterStage: noop, + createDisinterestFilterStage: noop, + createDedupeByRootStage: noop, + createExploreDiversitySorter: noop, + createExploreScorerStage: noop, + createKidsAudienceFilterStage: noop, + createKidsScorerStage: noop, + createKidsScoreSorter: noop, + createModerationStage: noop, + createResolvePostedAtStage: noop, + createTagPreferenceFilterStage: noop, + createWatchHistorySuppressionStage: noop, + createChronologicalSorter: noop, + createSubscriptionAuthorsSource: noop, + registerWatchHistoryFeed: noop, + }; + const coordinator = createFeedCoordinator(deps); + const app = { + ...coordinator, + feedTelemetryState: { activeFeed: FEED_TYPES.FOR_YOU }, + refreshFeed: mock.fn(async () => {}), + }; + return { app, fireFingerprint: () => fingerprintCb && fingerprintCb() }; +} + +test("re-runs For You (debounced) when watch history resolves while active", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { app, fireFingerprint } = makeCoordinator(); + app.subscribeWatchHistoryFeedRefresh(); + + // Two rapid fingerprint events should collapse into one refresh (debounce). + fireFingerprint(); + fireFingerprint(); + assert.equal(app.refreshFeed.mock.callCount(), 0, "should debounce, not fire immediately"); + + mock.timers.tick(450); + assert.equal(app.refreshFeed.mock.callCount(), 1, "should refresh once after debounce"); + const [feedType, opts] = app.refreshFeed.mock.calls[0].arguments; + assert.equal(feedType, FEED_TYPES.FOR_YOU); + assert.equal(opts.reason, "watch-history-resolved"); + } finally { + mock.timers.reset(); + } +}); + +test("does not re-run when the active feed is not For You", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { app, fireFingerprint } = makeCoordinator(); + app.feedTelemetryState.activeFeed = FEED_TYPES.EXPLORE; + app.subscribeWatchHistoryFeedRefresh(); + + fireFingerprint(); + mock.timers.tick(450); + assert.equal(app.refreshFeed.mock.callCount(), 0, "must not refresh a non-active For You feed"); + } finally { + mock.timers.reset(); + } +}); + +test("subscribing twice does not double-register", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const { app, fireFingerprint } = makeCoordinator(); + app.subscribeWatchHistoryFeedRefresh(); + app.subscribeWatchHistoryFeedRefresh(); + fireFingerprint(); + mock.timers.tick(450); + assert.equal(app.refreshFeed.mock.callCount(), 1, "idempotent subscription => single refresh"); + } finally { + mock.timers.reset(); + } +});