nip71: defer ingest until feed renders; diagnose display path

Live logs proved ingest works (subscribed to 17 whitelisted authors,
received 61 events, injected 39) — but injecting foreign authors during
cold-start triggers the moderation/WoT stage to hydrate their social graph
(kind 30000), which competes with the initial feed load and stalls it
("Fetching recent videos…" hanging, needs a refresh).

- startWhenFeedReady(): defer the first ingest subscription until the
  native feed's first videos:updated (with a 12s fallback for empty
  feeds), keeping ingest off the cold-start critical path. Wired in
  applicationBootstrap (kept thin so the size cap holds).
- emitRefresh now logs how many ingested videos survive the feed filter
  ("N ingested of M in filtered feed") so we can tell whether foreign
  videos are filtered out vs. injected-but-not-rendered.

Tests: defer-until-feed-ready + fallback-timer; mutation-verified.
1236/1237 unit (known UploadModal flake).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-22 16:23:45 -04:00
1 parent c4df08d5a1
commit d1246dc113
3 files changed
+98 -5

No files matched your search

+59
View File
@@ -36,6 +36,11 @@ const SUBSCRIPTION_KEY = "nip71-ingest";
// subscribing.
const OPEN_RETRY_DELAY_MS = 2000;
const MAX_OPEN_ATTEMPTS = 30; // ~60s of retries
// Defer the first subscription until the native feed has rendered (signalled by
// the first videos:updated). Injecting foreign authors hydrates their WoT graph
// (kind 30000), which during cold-start competes with the initial feed load; a
// fallback covers the empty-feed case where no videos:updated ever fires.
const FEED_READY_FALLBACK_MS = 12000;
export function createNip71IngestService({
nostrClient,
@@ -48,6 +53,7 @@ export function createNip71IngestService({
openRetryDelayMs = OPEN_RETRY_DELAY_MS,
maxOpenAttempts = MAX_OPEN_ATTEMPTS,
refreshThrottleMs = REFRESH_THROTTLE_MS,
feedReadyFallbackMs = FEED_READY_FALLBACK_MS,
} = {}) {
let subscription = null;
let buffer = [];
@@ -58,6 +64,9 @@ export function createNip71IngestService({
let started = false;
let lastRefreshAt = 0;
let pendingRefreshTimer = null;
let deferArmed = false;
let deferTimer = null;
let offFeedReady = null;
function isAvailable() {
return (
@@ -199,6 +208,13 @@ export function createNip71IngestService({
} catch (error) {
logger?.warn?.("[nip71Ingest] getFilteredActiveVideos failed", error);
}
// Confirm ingested videos survive the feed filter (whitelist/NSFW/etc.).
const foreignCount = Array.isArray(videos)
? videos.filter((v) => v?.source === "nip71-ingest").length
: 0;
userLogger.info(
`[nip71Ingest] refresh: ${foreignCount} ingested of ${videos.length} in filtered feed`,
);
if (typeof nostrService?.emit === "function") {
nostrService.emit("videos:updated", {
videos,
@@ -208,6 +224,35 @@ export function createNip71IngestService({
}
}
// Defer the first start until the native feed has rendered (first
// videos:updated), with a fallback timer for the empty-feed case. Keeps ingest
// off the cold-start critical path.
function startWhenFeedReady() {
if (started || deferArmed || !isAvailable()) {
return false;
}
deferArmed = true;
const begin = () => {
if (typeof offFeedReady === "function") {
offFeedReady();
offFeedReady = null;
}
if (deferTimer) {
clearTimeout(deferTimer);
deferTimer = null;
}
deferArmed = false;
start();
};
if (typeof nostrService?.on === "function") {
offFeedReady = nostrService.on("videos:updated", begin);
}
deferTimer = setTimeout(begin, feedReadyFallbackMs);
return true;
}
function start() {
if (started || !isAvailable()) {
return false;
@@ -313,6 +358,19 @@ export function createNip71IngestService({
function stop() {
closeSubscription();
if (typeof offFeedReady === "function") {
try {
offFeedReady();
} catch (error) {
// best-effort
}
offFeedReady = null;
}
if (deferTimer) {
clearTimeout(deferTimer);
deferTimer = null;
}
deferArmed = false;
if (typeof offWhitelistChange === "function") {
try {
offWhitelistChange();
@@ -326,6 +384,7 @@ export function createNip71IngestService({
return {
isAvailable,
startWhenFeedReady,
start,
stop,
restart,
+7 -5
View File
@@ -858,6 +858,12 @@ export default class ApplicationBootstrap {
// Inbound NIP-71 ingest: pull video events published by other Nostr apps
// (scoped to whitelisted authors while whitelist mode is on) and surface
// them in the feed. Gated by FEATURE_NIP71_INGEST. Best-effort.
//
// Deferred until the native feed has rendered once: injecting foreign
// authors triggers the moderation/WoT stage to hydrate their social graph
// (kind 30000), which during cold-start competes with the initial feed load
// and can stall it. Starting after the first videos:updated keeps ingest off
// the critical path; a fallback timer covers the empty-feed case.
const nip71IngestService = createNip71IngestService({
nostrClient,
nostrService: app.nostrService,
@@ -865,11 +871,7 @@ export default class ApplicationBootstrap {
});
app.nip71IngestService = nip71IngestService;
if (nip71IngestService.isAvailable()) {
try {
nip71IngestService.start();
} catch (error) {
devLogger.warn("[applicationBootstrap] NIP-71 ingest start failed", error);
}
nip71IngestService.startWhenFeedReady();
nostrUnsubscribes.push(() => nip71IngestService.stop());
}
nostrUnsubscribes.push(
+32
View File
@@ -43,10 +43,17 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
getActiveKey: activeKey,
getSubscriptionManager: () => manager,
};
const feedListeners = new Set();
const nostrService = {
emit(name, detail) {
emitted.push({ name, detail });
},
on(name, fn) {
if (name === "videos:updated") {
feedListeners.add(fn);
}
return () => feedListeners.delete(fn);
},
getFilteredActiveVideos: () => Array.from(activeMap.values()),
};
const accessControl = {
@@ -72,6 +79,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) {
},
fireEvent: (e) => onEvent?.(e),
triggerWhitelistChange: () => whitelistListener?.(),
triggerFeedReady: () => feedListeners.forEach((fn) => fn({})),
nostrClient,
nostrService,
accessControl,
@@ -218,6 +226,30 @@ test("re-subscribes when the whitelist changes", () => {
assert.deepEqual(h.capturedFilters[0].authors, ["aa", "cc"], "subscription re-scoped after change");
});
test("startWhenFeedReady defers subscription until the native feed renders", () => {
const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] });
const svc = makeService(h, { feedReadyFallbackMs: 100000 });
assert.equal(svc.startWhenFeedReady(), true);
assert.equal(h.capturedFilters, null, "does not subscribe during cold-start");
// Native feed renders -> first videos:updated -> ingest starts now.
h.triggerFeedReady();
assert.ok(h.capturedFilters, "subscribes once the feed is ready");
assert.deepEqual(h.capturedFilters[0].authors, ["aa"]);
svc.stop();
});
test("startWhenFeedReady falls back to starting if the feed never emits", async () => {
const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] });
const svc = makeService(h, { feedReadyFallbackMs: 5 });
svc.startWhenFeedReady();
assert.equal(h.capturedFilters, null);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.ok(h.capturedFilters, "fallback timer started ingest");
svc.stop();
});
test("coalesces refresh signals so an event burst can't storm the feed", () => {
const h = makeHarness();
const svc = makeService(h, { refreshThrottleMs: 10000 });