Optimize feed lifecycle and navigation rendering

This commit is contained in:
thePR0M3TH3AN committed 2026-07-26 18:27:57 -04:00
1 parent 2c4aefe872
commit 687f8c438d
12 files changed
+287 -21

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { createCardObserver } from "../../js/dom/cardObserver.js";
test("card observer disconnect unregisters every card and releases its observer", () => {
const dom = new JSDOM("<div id=grid><article class='card' data-video-id=a></article><article class='card' data-video-id=b></article></div>");
const originalHTMLElement = globalThis.HTMLElement;
const originalIntersectionObserver = globalThis.IntersectionObserver;
globalThis.HTMLElement = dom.window.HTMLElement;
let disconnects = 0;
globalThis.IntersectionObserver = class {
observe() {}
takeRecords() { return []; }
disconnect() { disconnects += 1; }
};
const unregistered = [];
try {
const observer = createCardObserver({
onCardUnregister: ({ card }) => unregistered.push(card.dataset.videoId),
});
const grid = dom.window.document.getElementById("grid");
observer.observe(grid);
observer.disconnect(grid);
assert.deepEqual(unregistered.sort(), ["a", "b"]);
assert.equal(disconnects, 1);
assert.equal(observer.getState(grid), null);
} finally {
dom.window.close();
if (originalHTMLElement === undefined) delete globalThis.HTMLElement;
else globalThis.HTMLElement = originalHTMLElement;
if (originalIntersectionObserver === undefined) delete globalThis.IntersectionObserver;
else globalThis.IntersectionObserver = originalIntersectionObserver;
}
});
@@ -33,3 +33,33 @@ test("ExploreDataService stays idle until a recommendation feed activates it", a
assert.equal(idfRefreshes, 1, "inactive feeds do not rebuild recommendation indexes");
service.destroy();
});
test("ExploreDataService coalesces a refresh burst into one running pass and one follow-up", async () => {
const service = new ExploreDataService({
historyRefreshIntervalMs: 0,
idfRefreshIntervalMs: 0,
});
service.active = true;
let runs = 0;
let releaseFirst;
service._refreshTagIdf = async () => {
runs += 1;
if (runs === 1) {
await new Promise((resolve) => {
releaseFirst = resolve;
});
}
return new Map();
};
const first = service.refreshTagIdf({ force: true, reason: "initial" });
const duplicate = service.refreshTagIdf({ reason: "relay-event" });
service.refreshTagIdf({ reason: "timer" });
assert.equal(runs, 1, "only one rebuild starts during the burst");
releaseFirst();
await Promise.all([first, duplicate]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(runs, 2, "the burst produces exactly one follow-up rebuild");
service.destroy();
});
@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { VideoListView } from "../../js/ui/views/VideoListView.js";
test("VideoListView disposes health observers before replacing its card grid", () => {
const dom = new JSDOM("<div id=grid></div>");
const grid = dom.window.document.getElementById("grid");
let healthDetaches = 0;
let urlDetaches = 0;
const view = new VideoListView({
document: dom.window.document,
badgeHelpers: {
attachHealthBadges: () => () => { healthDetaches += 1; },
attachUrlHealthBadges: () => () => { urlDetaches += 1; },
},
});
view.mount(grid);
const videos = [{ id: "one", title: "One", pubkey: "a", created_at: 1 }];
view.render(videos);
view.render([{ ...videos[0], title: "Updated", created_at: 2 }]);
view.unmount();
assert.equal(healthDetaches, 2, "rerender and unmount each detach health observers");
assert.equal(urlDetaches, 2, "rerender and unmount each detach URL observers");
dom.window.close();
});
test("VideoListView restores cards when a navigation remount clears unchanged feed markup", () => {
const dom = new JSDOM("<div id=grid></div>");
const grid = dom.window.document.getElementById("grid");
const view = new VideoListView({ document: dom.window.document });
const videos = [{ id: "for-you-one", title: "For You", pubkey: "a", created_at: 1 }];
view.mount(grid);
view.render(videos);
assert.equal(grid.querySelectorAll('[data-component="video-card"]').length, 1);
// Loading a view replaces #videoList. The data can still be identical when
// returning to For You, so a signature-only cache must not skip the repaint.
grid.innerHTML = '<div data-for-you-empty-state="true"></div>';
view.render(videos);
assert.equal(grid.querySelectorAll('[data-component="video-card"]').length, 1);
assert.equal(grid.querySelector('[data-for-you-empty-state]'), null);
dom.window.close();
});
+22
View File
@@ -373,3 +373,25 @@ test("most-zapped sorter: sats desc, recency tie-break, muted sinks", () => {
"sats desc, newer wins ties, trusted-muted last regardless of sats",
);
});
test("most-zapped sorter keeps strict zap order when one creator has several top videos", () => {
const item = (id, author, sats, createdAt) => ({
video: { id, pubkey: author, created_at: createdAt },
metadata: {},
sats,
});
const items = [
item("creator-a-top", "creator-a", 900, 100),
item("creator-a-second", "creator-a", 800, 200),
item("creator-b", "creator-b", 700, 300),
];
const sorted = createMostZappedSorter()(items, {
runtime: { getZapTotal: (video) => items.find((entry) => entry.video === video)?.sats || 0 },
});
assert.deepEqual(
sorted.map((entry) => entry.video.id),
["creator-a-top", "creator-a-second", "creator-b"],
"a diversity rule must not move a lower-zapped video above a higher-zapped one",
);
});