aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-17 11:41:50 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-17 11:41:50 -0800
commit3bd52a03323a9983aa7896af4d3fc3668e4c1252 (patch)
tree408065c8f4b21030c2ae3e070447e10fc124be70 /frontend-vanilla/src
parentea9ec7f41c0447027b66f52c0c4cd0d5c9777bfa (diff)
downloadneko-3bd52a03323a9983aa7896af4d3fc3668e4c1252.tar.gz
neko-3bd52a03323a9983aa7896af4d3fc3668e4c1252.tar.bz2
neko-3bd52a03323a9983aa7896af4d3fc3668e4c1252.zip
Fix regression: mark-as-read not triggering on window scroll
Diffstat (limited to 'frontend-vanilla/src')
-rw-r--r--frontend-vanilla/src/main.ts50
-rw-r--r--frontend-vanilla/src/regression.test.ts44
2 files changed, 65 insertions, 29 deletions
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts
index aa00bd3..6a605c4 100644
--- a/frontend-vanilla/src/main.ts
+++ b/frontend-vanilla/src/main.ts
@@ -271,31 +271,17 @@ export function renderItems() {
if (scrollRoot) {
let readTimeoutId: number | null = null;
scrollRoot.onscroll = () => {
- // Infinite scroll: check immediately on every scroll event (cheap comparison).
- // Guard: only when content actually overflows the container (scrollHeight > clientHeight).
+ // Infinite scroll check (container only)
if (!store.loading && store.hasMore && scrollRoot.scrollHeight > scrollRoot.clientHeight) {
if (scrollRoot.scrollHeight - scrollRoot.scrollTop - scrollRoot.clientHeight < 200) {
loadMore();
}
}
- // Mark-as-read: debounced to avoid excessive DOM queries
+ // Mark-as-read: debounced
if (readTimeoutId === null) {
readTimeoutId = window.setTimeout(() => {
- const containerRect = scrollRoot.getBoundingClientRect();
-
- store.items.forEach((item) => {
- if (item.read) return;
-
- const el = document.querySelector(`.feed-item[data-id="${item._id}"]`);
- if (el) {
- const rect = el.getBoundingClientRect();
- // Mark as read if the bottom of the item is above the top of the container
- if (rect.bottom < containerRect.top) {
- updateItem(item._id, { read: true });
- }
- }
- });
+ checkReadItems(scrollRoot);
readTimeoutId = null;
}, 250);
}
@@ -303,6 +289,22 @@ export function renderItems() {
}
}
+function checkReadItems(scrollRoot: HTMLElement) {
+ const containerRect = scrollRoot.getBoundingClientRect();
+ store.items.forEach((item) => {
+ if (item.read) return;
+
+ const el = document.querySelector(`.feed-item[data-id="${item._id}"]`);
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ // Mark as read if the bottom of the item is above the top of the container
+ if (rect.bottom < containerRect.top) {
+ updateItem(item._id, { read: true });
+ }
+ }
+ });
+}
+
// Polling fallback for infinite scroll (matches V1 behavior)
// This ensures that even if scroll events are missed or layout shifts occur without scroll,
// we still load more items when near the bottom.
@@ -319,18 +321,8 @@ if (typeof window !== 'undefined') {
if (store.loading || !store.hasMore) return;
if (scrollRoot) {
- // DEBUG LOGGING
- /*
- console.log('Scroll Poll', {
- scrollHeight: scrollRoot.scrollHeight,
- scrollTop: scrollRoot.scrollTop,
- clientHeight: scrollRoot.clientHeight,
- offset: scrollRoot.scrollHeight - scrollRoot.scrollTop - scrollRoot.clientHeight,
- docHeight: document.documentElement.scrollHeight,
- winHeight: window.innerHeight,
- winScroll: window.scrollY
- });
- */
+ // Check for read items periodically (robustness fallback)
+ checkReadItems(scrollRoot);
// Check container scroll (if container is scrollable)
if (scrollRoot.scrollHeight > scrollRoot.clientHeight) {
diff --git a/frontend-vanilla/src/regression.test.ts b/frontend-vanilla/src/regression.test.ts
index 813e4bb..0c10d95 100644
--- a/frontend-vanilla/src/regression.test.ts
+++ b/frontend-vanilla/src/regression.test.ts
@@ -167,6 +167,50 @@ describe('Scroll-to-Read Regression Tests', () => {
// API should NOT be called
expect(apiFetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/item/888'), expect.anything());
});
+
+ it('should mark item as read when WINDOW scrolls (robustness fallback)', async () => {
+ vi.useRealTimers();
+ const mockItem = {
+ _id: 12345,
+ title: 'Window Scroll Item',
+ read: false,
+ url: 'http://example.com/window',
+ publish_date: '2023-01-01'
+ } as any;
+
+ store.setItems([mockItem]);
+ renderItems();
+
+ // Setup successful detection scenario
+ const mainContent = document.getElementById('main-content');
+ if (mainContent) {
+ mainContent.getBoundingClientRect = vi.fn(() => ({
+ top: 0, bottom: 800, height: 800, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { }
+ }));
+ }
+
+ const itemEl = document.querySelector(`.feed-item[data-id="12345"]`);
+ if (itemEl) {
+ // Fully scrolled past
+ itemEl.getBoundingClientRect = vi.fn(() => ({
+ top: -150, bottom: -50, height: 100, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { }
+ }));
+ }
+
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
+
+ // Dispatch scroll on WINDOW, not mainContent
+ window.dispatchEvent(new Event('scroll'));
+
+ // Wait for potential debounce/poll
+ await new Promise(resolve => setTimeout(resolve, 1100));
+
+ // Expect it to handle it
+ expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/12345'), expect.objectContaining({
+ method: 'PUT',
+ body: expect.stringContaining('"read":true')
+ }));
+ });
});
// NK-t8qnrh: Links in feed item descriptions should have no underlines (match v1 style)