mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 04:12:25 +00:00
perf(watch-history): load in background; re-run For You when it resolves
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 <[email protected]>
This commit is contained in:
1 parent
be602a5a10
commit
52ceb4f211
5 files changed
+172
-3
No files matched your search
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user