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", + ); +});