aboutsummaryrefslogtreecommitdiffstats
path: root/src/thread-prefetch.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/thread-prefetch.js')
-rw-r--r--src/thread-prefetch.js53
1 files changed, 53 insertions, 0 deletions
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 = [];
+ },
+ };
+}