From e76d643835c45f6624856850137e71e986315eb0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 8 Oct 2026 13:50:44 -0700 Subject: Prefetch visible post threads after a bounded reading dwell --- src/thread-prefetch.js | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/thread-prefetch.js (limited to 'src/thread-prefetch.js') diff --git a/src/thread-prefetch.js b/src/thread-prefetch.js new file mode 100644 index 0000000..6d34db6 --- /dev/null +++ b/src/thread-prefetch.js @@ -0,0 +1,53 @@ +// Dwell is measured on mounted rows, including posts taller than the viewport. +// Sampling also catches fast movement within a large post (IO alone cannot). +export function createThreadPrefetch({ load, key, cached, doc = globalThis.document, + nav = globalThis.navigator, now = Date.now, schedule = setTimeout, cancel = clearTimeout }) { + const seeds = new Map(), dwell = new Map(); + let timer = null, busy = false, starts = [], generation = 0; + const allowed = () => !doc.hidden && nav?.onLine !== false && !nav?.connection?.saveData + && !['slow-2g', '2g'].includes(nav?.connection?.effectiveType); + function tick() { + timer = null; + const time = now(), visible = new Set(), mounted = new Set(), ready = []; + const height = doc.documentElement.clientHeight; + for (const row of doc.querySelectorAll('[data-thread-prefetch]')) { + const id = row.getAttribute('data-thread-prefetch'), seed = seeds.get(id); + mounted.add(id); + if (!seed || !allowed() || cached(seed)) continue; + const r = row.getBoundingClientRect(); + const overlap = Math.min(r.bottom, height) - Math.max(r.top, 0); + if (overlap < Math.min(r.height, height) * 0.5 || overlap <= 0) continue; + visible.add(id); + let d = dwell.get(id); + // More than 100px/s is still scrolling past; slow reading can qualify. + if (!d || Math.abs(r.top - d.top) > 25) d = { since: time, top: r.top }; + d.top = r.top; + dwell.set(id, d); + if (time - d.since >= 1200) ready.push(seed); + } + for (const id of seeds.keys()) if (!mounted.has(id)) seeds.delete(id); + for (const id of dwell.keys()) if (!visible.has(id)) dwell.delete(id); + starts = starts.filter((t) => time - t < 60000); + if (!busy && starts.length < 6 && ready.length) { + const seed = ready[0], token = generation; + busy = true; starts.push(time); + // The loader inserts synchronously, so clicks share its in-flight work. + Promise.resolve().then(() => token === generation && load(seed)).catch(() => {}).finally(() => { + if (token === generation) busy = false; + }); + for (const [id, ev] of seeds) if (key(ev) === key(seed)) dwell.delete(id); + } + if (seeds.size) timer = schedule(tick, 250); + } + return { + watch(seed) { + if (!doc) return; + seeds.set(seed.id, seed); + if (timer === null) timer = schedule(tick, 250); + }, + stop() { + generation++; cancel(timer); timer = null; busy = false; + seeds.clear(); dwell.clear(); starts = []; + }, + }; +} -- cgit v1.2.3