Lists open at once from what the page keeps and fetches ahead, or with a spinner (#247)

A click on a feed, All Subscriptions or a filter fetched the list and only then drew it, so
nothing answered the click until the server did. The page now keeps the first page of each list
(the newest 40, by view, filter, sort and search), draws a kept one at once and refreshes it
behind, drawing again only if it changed. It fetches ahead: All Subscriptions and the twenty
feeds with the most unread once the page is idle, two at a time, and a feed or filter when the
pointer or a finger reaches it. A feed_row or a read forgets that feed's lists and All
Subscriptions'; the forgotten are fetched again behind. A list with nothing kept shows a
spinner if its answer takes more than 120 ms.

list_shown (#246) is reported when the list first shows, so a kept one reports its few ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:16:19 +00:00
parent 62a3241139
commit 6640ca9612
8 changed files with 120 additions and 10 deletions

View File

@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247).
### Changed
- The page reports how long each list you open takes to show, from the click to the list drawn, for the dashboard (#246).

View File

@@ -797,6 +797,10 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
font-size:12px;color:var(--faint)}
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
/* A list being waited for (#247): the pull-to-refresh spinner, in the list's place. */
.listload{display:flex;align-items:center;justify-content:center;gap:8px;padding:28px 8px;color:var(--dim);font-size:13px}
.listload::before{content:"";width:12px;height:12px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
#pullspin{position:fixed;left:50%;top:calc(58px + var(--safe-t));transform:translateX(-50%);z-index:55;
display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-size:12px;
color:var(--dim);background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow)}

View File

@@ -19,6 +19,8 @@ function connect(){
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
// The lists a change made stale, fetched again behind, once a burst of rows has passed (#247).
const preloadSoon=soon(preloadLists,2000);
sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){
@@ -61,7 +63,7 @@ function connect(){
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
// The server sends a feed's new row after anything changes it (counts, error, last check),
// only to those who subscribe; the page used to reload the whole list after each event.
else if(ev.ev==='feed_row') patchFeed(ev.row);
else if(ev.ev==='feed_row'){ patchFeed(ev.row); forgetLists(ev.row.id); preloadSoon(); }
// Only a scan that checked something: the scheduler scans every minute, due or not, and
// every open page reloaded the whole list each time (#103).
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
@@ -73,4 +75,4 @@ function connect(){
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
}
connect();
loadFeeds().catch(()=>{});
loadFeeds().then(preloadLists).catch(()=>{});

View File

@@ -70,6 +70,7 @@ function renderFeed(){
showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!));
$$('#content .tabs button').forEach(b=>b.onpointerenter=b.ontouchstart=()=>{ if(S.feed) prefetch(S.feed,b.dataset.f!); });
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f!; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}

View File

@@ -99,6 +99,7 @@ function renderFeeds(){
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
el.onclick=()=>{ selectFeed(id); nav(false); };
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
places.appendChild(el);
}
list.appendChild(places);
@@ -160,6 +161,8 @@ function renderFeeds(){
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); };
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
list.appendChild(el);

View File

@@ -1,17 +1,77 @@
/* ---------------- items ---------------- */
let loadSeq=0;
type ListPage={total: number, entries: Entry[]};
/// The first page of each list opened or fetched ahead, by what it shows, so a list seen before is
/// drawn the moment it is asked for and refreshed behind it (#247). The newest few dozen: a page is
/// 50 items, about 100 KB as JSON.
const lists=new Map<string,ListPage>();
const LISTS_KEPT=40;
/// Lists on their way, so a click on one already being fetched ahead waits for that answer.
const fetching=new Map<string,Promise<ListPage>>();
const listKey=(feed: string, filter=S.filter)=>[feed,filter,S.sort.col,S.sort.dir,S.q].join('\n');
function listUrl(feed: string, filter=S.filter, offset=0){
const p=new URLSearchParams({offset:String(offset),limit:String(LIMIT),filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
return feed===':all' ? `/api/entries?${p}` : `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`;
}
function keepList(key: string, r: ListPage){
lists.delete(key); lists.set(key,r);
if(lists.size>LISTS_KEPT) lists.delete(lists.keys().next().value!);
}
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
/// reaches a feed or a filter. Not one kept already or on its way.
function prefetch(feed: string, filter=S.filter){
// A folder's page lists its feeds, not items.
if(VIEWS[feed]?.url||S.feeds.some(c=>c.group===feed)) return;
const key=listKey(feed,filter);
if(lists.has(key)||fetching.has(key)) return;
const got: Promise<ListPage>=api(listUrl(feed,filter));
fetching.set(key,got);
got.then(r=>keepList(key,r),()=>{}).finally(()=>fetching.delete(key));
}
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
/// are fetched again when next wanted.
function forgetLists(feed: string){
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')) lists.delete(k);
}
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
/// and the feeds with something unread, most first.
function preloadLists(){
const want=[':all',...[...S.feeds].filter(f=>f.unread>0&&!f.group).sort((a,b)=>b.unread-a.unread).slice(0,20).map(f=>f.id)];
const next=()=>{ const f=want.shift(); if(!f) return; prefetch(f); (fetching.get(listKey(f))||Promise.resolve()).finally(next); };
const go=()=>{ next(); next(); };
'requestIdleCallback' in window ? (window as any).requestIdleCallback(go,{timeout:2000}) : setTimeout(go,300);
}
/// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed.
async function loadEntries(append?: boolean, quiet?: boolean){
// The Directory lists feeds, and Currently Listening draws its own episodes.
if(!S.feed || VIEWS[S.feed]?.url) return;
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
const asked=performance.now(), seq=++loadSeq;
const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
const feed=S.feed, key=listKey(feed), asked=performance.now(), seq=++loadSeq;
// From asking to the list drawn: what someone waits for after a click, under that click's trace
// (#246). Once, when the list first shows: from what is kept, at once.
let timed=quiet;
const shown=()=>{ if(!timed){ timed=true; report('list_shown',{ms:Math.round(performance.now()-asked),feed,filter:S.filter}); } };
const kept=append ? undefined : lists.get(key);
let spin=0;
if(kept){ showList(kept,false,asked); shown(); }
// Nothing to show yet: a spinner, if the answer is not back in a blink.
else if(!append&&!quiet) spin=setTimeout(()=>{ if(seq===loadSeq) listLoading(); },120);
let r: ListPage;
try{
r=await (!append&&!kept&&fetching.get(key) || api(listUrl(feed,S.filter,S.offset)));
}finally{ clearTimeout(spin); }
// Only the latest list counts: marking everything read reloads the All tab, and clicking
// Unread straight after could have that answer land last and replace the Unread one.
if(seq!==loadSeq) return;
if(!append) keepList(key,r);
// The kept one was this already: drawn again it only flickers.
if(kept&&JSON.stringify(kept)===JSON.stringify(r)) return;
showList(r,!!append,asked);
shown();
}
/// A list's page into S and onto the screen.
function showList(r: ListPage, append: boolean, asked: number){
S.total=r.total;
for(const e of r.entries){
const w=readWrites.get(readKey(e));
@@ -30,9 +90,11 @@ async function loadEntries(append?: boolean, quiet?: boolean){
}
S.entries = entries;
renderEntries();
// From asking to the list drawn: what someone waits for after a click, under that click's trace
// (#246). The API's own time is in the trace; this is the whole wait, the network included.
if(!quiet) report('list_shown',{ms:Math.round(performance.now()-asked),feed:S.feed,filter:S.filter});
}
/// A list being waited for: said with a spinner, so the click is seen to have been heard.
function listLoading(){
const box=$('#eps'); if(!box) return;
box.innerHTML='<p class="listload" role="status">Loading…</p>';
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
@@ -180,6 +242,8 @@ const readWrites=new Map<string, {read: boolean, done?: number}>();
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
function setRead(e: Entry,read: boolean){
e.read=read;
// Kept lists of its feed hold their own copy of it, still as it was.
forgetLists(e.feed_id);
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read})})