fix(liveness): eager-probe hide-until-verified cards (fixes vanished foreign feed)

Setting CARD_LIVENESS_POLICY=hide-foreign made all foreign/NIP-71 content
disappear: a hide-until-verified card starts display:none, which has no layout
box, so the IntersectionObserver never fires for it — its liveness probe never
runs, it never gets a healthy verdict, and it stays hidden forever (deadlock).

Fix: cardNeedsEagerLivenessProbe() identifies cards governed by a
hide-until-verified policy (foreign under hide-foreign; all non-owner under
hide-all). Both health observers now kick the probe for those cards from
onCardRegister — which fires for every card regardless of visibility — instead of
waiting for an intersection that a hidden card can never produce. The URL probe is
deferred one microtask so attachUrlHealthBadges has wired state.onCheck after
observe(). Probes stay concurrency-capped + priority-ordered; show-pending is
unchanged (no eager probing).

Tests: cardNeedsEagerLivenessProbe predicate coverage in
tests/video-card-source-visibility.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-25 09:33:41 -04:00
1 parent a3b66f8841
commit 129d877d38
5 files changed
+131 -39

No files matched your search

+20 -2
View File
@@ -1,6 +1,9 @@
import { createCardObserver } from "./dom/cardObserver.js";
import { infoHashFromMagnet } from "./magnets.js";
import { updateVideoCardSourceVisibility } from "./utils/cardSourceVisibility.js";
import {
updateVideoCardSourceVisibility,
cardNeedsEagerLivenessProbe,
} from "./utils/cardSourceVisibility.js";
import { TorrentClient, torrentClient } from "./webtorrent.js";
import { userLogger } from "./utils/logger.js";
import {
@@ -532,9 +535,24 @@ const gridCardObserver = createCardObserver({
const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
return isIntersecting && ratio > 0;
},
onCardRegister: ({ card }) => {
onCardRegister: ({ card, state }) => {
if (!card.dataset.magnet) {
setBadge(card, "unhealthy", { reason: "missing-source" });
return;
}
// Hide-until-verified cards start hidden, so the viewport observer can never
// fire for them — probe eagerly on register (still concurrency-capped) so
// they can be verified and revealed instead of staying hidden forever.
if (
cardNeedsEagerLivenessProbe(card) &&
state &&
state.pendingByCard instanceof WeakMap
) {
handleCardVisible({
card,
pendingByCard: state.pendingByCard,
priority: PRIORITY_BASELINE,
});
}
},
onCardVisible: ({ card, meta, state }) => {
+49 -34
View File
@@ -2,6 +2,7 @@ import { createCardObserver } from "./dom/cardObserver.js";
import { safeDecodeURIComponent } from "./utils/safeDecode.js";
import { userLogger } from "./utils/logger.js";
import { getLivenessProbePrefetchMargin } from "./constants.js";
import { cardNeedsEagerLivenessProbe } from "./utils/cardSourceVisibility.js";
// Prefetch margin lets cards just below the fold start probing before they scroll
// into view, so they're verified by the time the user reaches them.
@@ -20,44 +21,58 @@ const urlCardObserver = createCardObserver({
return isIntersecting && ratio > 0;
},
createState: () => ({ onCheck: null }),
onCardVisible: ({ card, state }) => {
if (!state || typeof state.onCheck !== "function") {
return;
}
const badgeEl = card.querySelector("[data-url-health-state]");
if (!(badgeEl instanceof HTMLElement)) {
return;
}
const encodedUrl =
card.dataset.urlHealthUrl || badgeEl.dataset.urlHealthUrl || "";
const eventId =
card.dataset.urlHealthEventId || badgeEl.dataset.urlHealthEventId || "";
if (!encodedUrl || !eventId) {
return;
}
const currentState =
card.dataset.urlHealthState || badgeEl.dataset.urlHealthState;
if (currentState && currentState !== "checking") {
return;
}
const url = safeDecodeURIComponent(encodedUrl);
if (!url) {
return;
}
try {
state.onCheck({ card, badgeEl, url, eventId });
} catch (err) {
userLogger.warn("[urlHealthObserver] onCheck handler failed", err);
// Hide-until-verified cards start `display:none`, so the IntersectionObserver
// never fires for them — kick the URL probe eagerly on register so they can be
// verified and revealed (the alternative is they stay hidden forever).
onCardRegister: ({ card, state }) => {
if (cardNeedsEagerLivenessProbe(card)) {
// attachUrlHealthBadges sets state.onCheck right AFTER observe() returns, so
// defer one microtask to let that wiring land before we probe.
queueMicrotask(() => triggerUrlCheck(card, state));
}
},
onCardVisible: ({ card, state }) => {
triggerUrlCheck(card, state);
},
});
function triggerUrlCheck(card, state) {
if (!state || typeof state.onCheck !== "function") {
return;
}
const badgeEl = card.querySelector("[data-url-health-state]");
if (!(badgeEl instanceof HTMLElement)) {
return;
}
const encodedUrl =
card.dataset.urlHealthUrl || badgeEl.dataset.urlHealthUrl || "";
const eventId =
card.dataset.urlHealthEventId || badgeEl.dataset.urlHealthEventId || "";
if (!encodedUrl || !eventId) {
return;
}
const currentState =
card.dataset.urlHealthState || badgeEl.dataset.urlHealthState;
if (currentState && currentState !== "checking") {
return;
}
const url = safeDecodeURIComponent(encodedUrl);
if (!url) {
return;
}
try {
state.onCheck({ card, badgeEl, url, eventId });
} catch (err) {
userLogger.warn("[urlHealthObserver] onCheck handler failed", err);
}
}
export function attachUrlHealthBadges(container, onCheck) {
if (!(container instanceof HTMLElement)) {
return;
+22
View File
@@ -1,5 +1,27 @@
import { getCardLivenessPolicy } from "../constants.js";
/**
* Whether a card is governed by a hide-until-verified policy (so it starts hidden
* and must be PROBED to ever appear). Such cards can't rely on the viewport
* IntersectionObserver — a `display:none` card never intersects, so the probe
* must be kicked eagerly on register. Owner's own cards are always visible and
* never gated.
*/
export function cardNeedsEagerLivenessProbe(cardLike) {
const card = resolveCardElement(cardLike);
if (!card || card.dataset.ownerIsViewer === "true") {
return false;
}
const policy = getCardLivenessPolicy();
if (policy === "hide-all") {
return true;
}
if (policy === "hide-foreign") {
return card.dataset.foreign === "true";
}
return false;
}
function resolveCardElement(cardLike) {
if (!cardLike) {
return null;
+29 -1
View File
@@ -2,7 +2,10 @@ import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { updateVideoCardSourceVisibility } from "../js/utils/cardSourceVisibility.js";
import {
updateVideoCardSourceVisibility,
cardNeedsEagerLivenessProbe,
} from "../js/utils/cardSourceVisibility.js";
import { setCardLivenessPolicy } from "../js/constants.js";
import { VideoCard } from "../js/ui/components/VideoCard.js";
@@ -222,6 +225,31 @@ test("CARD_LIVENESS_POLICY=hide-all hides every pending non-owner card but never
assert.equal(nativeCard.hidden, false, "card appears once a source verifies");
});
// Hide-until-verified cards start display:none, so the viewport observer can't
// fire for them — they must be eager-probed. This predicate gates that, and it
// must mirror the visibility policy exactly so the right cards get probed.
test("cardNeedsEagerLivenessProbe gates eager probing to hidden-until-verified cards", (t) => {
const { document } = setupDom(t);
t.after(() => setCardLivenessPolicy("show-pending"));
const foreign = makeCard(document, { foreign: "true" });
const native = makeCard(document, { foreign: "false" });
const owner = makeCard(document, { foreign: "true", owner: "true" });
setCardLivenessPolicy("show-pending");
assert.equal(cardNeedsEagerLivenessProbe(foreign), false, "show-pending never eager-probes");
assert.equal(cardNeedsEagerLivenessProbe(native), false);
setCardLivenessPolicy("hide-foreign");
assert.equal(cardNeedsEagerLivenessProbe(foreign), true, "foreign card is eager-probed");
assert.equal(cardNeedsEagerLivenessProbe(native), false, "native card stays viewport-gated");
assert.equal(cardNeedsEagerLivenessProbe(owner), false, "owner card is never gated");
setCardLivenessPolicy("hide-all");
assert.equal(cardNeedsEagerLivenessProbe(native), true, "hide-all eager-probes native too");
assert.equal(cardNeedsEagerLivenessProbe(owner), false, "owner card is never gated");
});
test("VideoCard.closeMoreMenu only restores focus when the trigger was expanded", (t) => {
const { document } = setupDom(t);
+11 -2
View File
@@ -320,8 +320,17 @@ un-hides when WebTorrent flips green). The real gaps:
`rootMargin`, so cards just below the fold verify before the user scrolls to
them — makes hide-until-verified feel instant. Override:
`window.__BITVID_LIVENESS_PREFETCH_MARGIN__`.
- [ ] **DECIDE after live A/B**: pick the default policy that feels best
(recommend `hide-foreign`) and set `CARD_LIVENESS_POLICY` in instance-config.
- [x] **Deadlock fixed (2026-06-24).** First hide-foreign test made ALL foreign/
NIP-71 content vanish: a hide-until-verified card starts `display:none`, which
has no layout box, so the IntersectionObserver never fires for it → its
liveness probe never runs → it never gets a healthy verdict → hidden forever.
Fix: `cardNeedsEagerLivenessProbe()` + eager-probe such cards on
`onCardRegister` (fires regardless of visibility) in both `gridHealth` and
`urlHealthObserver`, instead of waiting for an intersection that can't happen.
Still concurrency-capped/priority-ordered; show-pending unchanged. Default is
now `hide-foreign`.
- [ ] **DECIDE after live A/B**: confirm `hide-foreign` feels right (foreign cards
now verify-then-appear); else flip `CARD_LIVENESS_POLICY` in instance-config.
### 18. Embed button / modal on the video player does not open — FIXED 2026-06-23
- [x] **Root cause: event-name mismatch.** `VideoModal.handleEmbedRequest`