From 512b6c3f6a5235fbb44ed750400b7e47561a812d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:04:54 -0400 Subject: [PATCH] feat(feed): warm watch history in background after login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The For You feed's watch-history suppression and tag-affinity previously loaded only when the For You / History view was first opened (feedCoordinator loadLatest({allowStale:true})), so on a cold login the feed first rendered without suppression and then re-ran ~1-3s later on the watch-history "fingerprint" event. Watch history was the one feed-driving list not eagerly warmed in the background on login. It was deliberately deferred because a cold load used to be ~150+ serialized nip-07 decrypts that starved the single-threaded signer during the fragile post-login handshake. That cost has since been bounded (format-routing + newest-first chunk cap + deferred backfill), so the authSessionCoordinator.js comment's condition for re-enabling an eager preload is now met. Kick a fire-and-forget watch-history preload AFTER the blocks/subscriptions/ hashtags lists finish decrypting (never concurrently — same reasoning as the DM deferral). loadLatest({allowStale:true}) returns immediately and runs the chunk-capped refresh in the background, so the For You feed's suppression is warm by the time the user reaches it instead of waiting on the tab. Extracted as schedulePostLoginWatchHistoryPreload() so the contract is unit-tested in isolation (correct pubkey + allowStale, pubkey guard, fire-and-forget / non-blocking, swallows a failed refresh). Build + full unit suite green; mutation-verified. Co-Authored-By: Claude Opus 4.8 --- js/app.js | 1 + js/app/authSessionCoordinator.js | 63 ++++++++- .../post-login-watch-history-preload.test.mjs | 120 ++++++++++++++++++ 3 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 tests/post-login-watch-history-preload.test.mjs diff --git a/js/app.js b/js/app.js index 1f03fa71..45bf9d79 100644 --- a/js/app.js +++ b/js/app.js @@ -647,6 +647,7 @@ class Application { userBlocks, subscriptions, hashtagPreferences, + watchHistoryService, storageService, relayManager, torrentClient, diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 1fffe76f..dc79c790 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -14,6 +14,44 @@ import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js" import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js"; import { FEED_TYPES } from "../constants.js"; +/** + * Kick a background watch-history refresh after login, once the feed-driving + * lists have already finished decrypting. + * + * `loadLatest({ allowStale: true })` returns immediately and runs the + * chunk-capped refresh in the background, so this is strictly fire-and-forget: + * it must never block, await, or throw into the login path. The For You feed + * re-runs on the watch-history "fingerprint" event once the refresh resolves. + * + * @param {object} args + * @param {{ loadLatest?: Function }} [args.watchHistoryService] + * @param {string} [args.activePubkey] + * @param {{ warn?: Function }} [args.devLogger] + * @returns {boolean} Whether a preload was scheduled. + */ +export function schedulePostLoginWatchHistoryPreload({ + watchHistoryService, + activePubkey, + devLogger, +} = {}) { + if (!activePubkey || typeof watchHistoryService?.loadLatest !== "function") { + return false; + } + + Promise.resolve() + .then(() => + watchHistoryService.loadLatest(activePubkey, { allowStale: true }), + ) + .catch((error) => { + devLogger?.warn?.( + "[Application] Background watch-history preload after login failed:", + error, + ); + }); + + return true; +} + /** * @param {object} deps - Injected dependencies. * @returns {object} Methods to be bound to the Application instance. @@ -27,6 +65,7 @@ export function createAuthSessionCoordinator(deps) { userBlocks, subscriptions, hashtagPreferences, + watchHistoryService, storageService, relayManager, torrentClient, @@ -492,13 +531,6 @@ export function createAuthSessionCoordinator(deps) { const taskOutcomes = await Promise.all(parallelListTasks); - // NOTE: watch history is intentionally NOT eagerly preloaded on login. - // A cold watch history is ~150+ serialized nip-07 decrypts; preloading it - // here (even after the lists) saturated the extension and starved the - // profile modal's hashtag/subscription/DM loads. It now loads lazily when - // the For You feed or History view is opened. Re-enable an eager preload - // only once the cold-load decrypt cost is reduced (see task: ~180 calls). - const requiredTaskOutcomes = taskOutcomes.filter((outcome) => ["blocks", "subscriptions", "hashtags"].includes(outcome.name), ); @@ -615,6 +647,23 @@ export function createAuthSessionCoordinator(deps) { pubkey: activePubkey, authLoadingState: this.authLoadingState, }); + + // Warm the watch history in the background now that the feed-driving + // lists (blocks/subscriptions/hashtags) have finished decrypting. This + // runs AFTER the required lists — never concurrently — so it does not + // re-introduce the signer starvation that previously made this an + // intentionally-deferred load. The cold load is now bounded to a few + // decrypts + deferred backfill (not the old ~150 serialized decrypts), + // so the For You feed's suppression and tag-affinity are ready by the + // time the user reaches it instead of waiting until the For You / + // History view is opened. Fire-and-forget: the feed re-runs on the + // watch-history "fingerprint" event. + schedulePostLoginWatchHistoryPreload({ + watchHistoryService, + activePubkey, + devLogger, + }); + return listSyncDetail; }); diff --git a/tests/post-login-watch-history-preload.test.mjs b/tests/post-login-watch-history-preload.test.mjs new file mode 100644 index 00000000..4be2043d --- /dev/null +++ b/tests/post-login-watch-history-preload.test.mjs @@ -0,0 +1,120 @@ +// On login, after the feed-driving lists (blocks/subscriptions/hashtags) finish +// decrypting, the auth coordinator must warm the watch history in the background +// so the For You feed's suppression/affinity are ready without the user first +// opening the For You / History view. The preload must be strictly +// fire-and-forget: it can never block, await, or throw into the login path +// (re-introducing a blocking cold load would starve the single-threaded signer +// during the fragile post-login handshake — the original reason it was deferred). + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { schedulePostLoginWatchHistoryPreload } from "../js/app/authSessionCoordinator.js"; + +const PK = "a".repeat(64); + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test("warms watch history for the active pubkey with a stale-tolerant read", async () => { + const calls = []; + const watchHistoryService = { + loadLatest: (actor, options) => { + calls.push({ actor, options }); + return Promise.resolve([]); + }, + }; + + const scheduled = schedulePostLoginWatchHistoryPreload({ + watchHistoryService, + activePubkey: PK, + devLogger: { warn() {} }, + }); + + assert.equal(scheduled, true, "a preload must be scheduled for a valid login"); + + await flushMicrotasks(); + + assert.equal(calls.length, 1, "loadLatest must be invoked exactly once"); + assert.equal(calls[0].actor, PK, "preload must target the logged-in pubkey"); + assert.deepEqual( + calls[0].options, + { allowStale: true }, + "preload must use allowStale so it never blocks on the cold fetch", + ); +}); + +test("does nothing when there is no active pubkey", async () => { + let called = false; + const watchHistoryService = { + loadLatest: () => { + called = true; + return Promise.resolve([]); + }, + }; + + const scheduled = schedulePostLoginWatchHistoryPreload({ + watchHistoryService, + activePubkey: "", + devLogger: { warn() {} }, + }); + + await flushMicrotasks(); + + assert.equal(scheduled, false, "no pubkey means no scheduled preload"); + assert.equal(called, false, "loadLatest must not run without a pubkey"); +}); + +test("tolerates a watch-history service without loadLatest", () => { + const scheduled = schedulePostLoginWatchHistoryPreload({ + watchHistoryService: {}, + activePubkey: PK, + devLogger: { warn() {} }, + }); + assert.equal(scheduled, false, "missing loadLatest must be a no-op, not a throw"); +}); + +test("is fire-and-forget: a rejecting refresh never throws into the login path", async () => { + let warned = false; + const watchHistoryService = { + // Simulate a dead signer / decrypt failure. + loadLatest: () => Promise.reject(new Error("signer channel closed")), + }; + + // Must return synchronously (not await the refresh) and not throw. + const scheduled = schedulePostLoginWatchHistoryPreload({ + watchHistoryService, + activePubkey: PK, + devLogger: { + warn() { + warned = true; + }, + }, + }); + + assert.equal(scheduled, true, "scheduling still succeeds even if the refresh later fails"); + + // Give the rejected promise a chance to settle; the helper's .catch must + // swallow it (a leaked rejection would crash the login flow / fail the test). + await flushMicrotasks(); + + assert.equal(warned, true, "a failed background refresh is logged, not propagated"); +}); + +test("is non-blocking: a never-resolving refresh still returns immediately", () => { + const watchHistoryService = { + loadLatest: () => new Promise(() => {}), // never settles + }; + + const start = Date.now(); + const scheduled = schedulePostLoginWatchHistoryPreload({ + watchHistoryService, + activePubkey: PK, + devLogger: { warn() {} }, + }); + + assert.equal(scheduled, true); + assert.ok( + Date.now() - start < 50, + "the call must return without awaiting the background refresh", + ); +});