diff options
| author | Adam Soltys <[email protected]> | 2026-10-16 17:41:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-16 17:41:20 -0700 |
| commit | 46181214dd880934f620424376d8aa29ba8794eb (patch) | |
| tree | d8742862d8d50f23e7f33cb7672aa0b16048f8d7 | |
| parent | ef3ea098ef8b370ac71e0c3a5b638625556b0937 (diff) | |
| download | coinosv3-46181214dd880934f620424376d8aa29ba8794eb.tar.gz coinosv3-46181214dd880934f620424376d8aa29ba8794eb.zip | |
Threads open at once from the replies already in hand, and fill in as the relays answer.
Adam, 2026-10-16: "Still seeing a spinner sometimes when I click into a
thread." Three things stood between a tap and the conversation. (1) The feed
already fetches every reply to each post on screen — the count on the reply
button comes from them (watchZaps, kinds 1 and 1111 by #e) — and then kept
only their ids. Now the replies themselves are kept (replyEvents: the 400
targets most recently heard from, 60 each) and a thread is seeded from them
when it is opened (repliesInHand: those naming the root, and those naming
only the reply they answer), so a post that has been on screen opens as the
whole conversation, no relay asked; a thread fetched ahead before the count
answered takes them when opened, and the open thread takes them when the
count fetch lands (adoptRepliesInHand). (2) The loader waited for every
relay's EOSE — the slowest relay, 4.5 s at worst — before the thread
replaced its spinner; now replies go in as they arrive (queryStreamOn), the
page repainted in place, and the root the moment it is found. (3) The
spinner stood under whatever was already there while the load ran; now it
shows only while there is nothing to read, and "no replies" only once the
relays have spoken. And the fetch-ahead fires after 600 ms of reading
instead of 1.2 s (a quick reader beat it to the tap), a dozen a minute.
tools/thread-instant-test.js (the relays answered in the page): a post read
for a moment is fetched ahead, the count answer released only after that
entry exists, and the thread opens with its reply 12 ms after the tap, not
after its own 6 s ask; a post on screen opens whole in 16 ms with the three
replies the count brought, before the relays answer the thread's ask, and
their answer adds nothing twice; a post with nothing in hand shows a spinner,
then its first reply at ~470 ms with the slow one still on its way, the
spinner gone, the slow one landing in place. thread-prefetch-test follows
the new dwell and rate; thread-cache, thread-pager, thread-back-scroll,
feed-conversations, moderation, feed-live, feed-switch-seed, profile-pager,
notif-report-fold and reply-count pass (thread-reply-test's 30 s wait fails
on clean HEAD the same way).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
| -rw-r--r-- | src/features/messages.js | 74 | ||||
| -rw-r--r-- | src/thread-prefetch.js | 8 | ||||
| -rw-r--r-- | tools/thread-instant-test.js | 152 | ||||
| -rw-r--r-- | tools/thread-prefetch-test.js | 10 |
4 files changed, 227 insertions, 17 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 0d5fb02..88e032a 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4012,6 +4012,38 @@ export function messagesFeature(ctx) { // replies must not crowd its likes and zaps out of it. const REPLY_KINDS = [1, 1111]; const replyIds = new Map(); // note id -> Map(reply id -> author pubkey) + // The replies themselves, as the count fetch brought them — a thread's + // first page, in hand before it is opened: every post on screen has its + // replies asked for (watchZaps), so opening it paints the conversation at + // once and the relays only add to it. Bounded: the targets most recently + // heard from, a few dozen replies each. + const replyEvents = new Map(); // note id -> Map(reply id -> event) + const REPLY_KEEP_TARGETS = 400, REPLY_KEEP_EACH = 60; + function keepReply(target, ev) { + let m = replyEvents.get(target); + if (m) replyEvents.delete(target); else m = new Map(); // re-set: most recent last + if (!m.has(ev.id)) { if (m.size >= REPLY_KEEP_EACH) return; m.set(ev.id, ev); } + replyEvents.set(target, m); + while (replyEvents.size > REPLY_KEEP_TARGETS) replyEvents.delete(replyEvents.keys().next().value); + } + // The replies in hand for a thread: those naming the root, and those + // naming only the reply they answer (one hop down), oldest first. + function repliesInHand(rootId) { + const out = new Map(); + const take = (id) => { for (const [rid, ev] of replyEvents.get(id) || []) if (rid !== rootId && !out.has(rid)) out.set(rid, ev); }; + take(rootId); + for (const rid of [...out.keys()]) take(rid); + return [...out.values()].sort((a, b) => a.created_at - b.created_at); + } + // …into a thread's cache entry: on opening it, and again when the count + // fetch answers for a thread fetched ahead before the answer came + function adoptRepliesInHand(c) { + const have = new Set(c.replies.map((e) => e.id)); + let added = 0; + for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added++; } + if (added) c.replies.sort((a, b) => a.created_at - b.created_at); + return added; + } const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set<pubkey> }) // Resolve from this reaction's own tag: packs can reuse the same shortcode // for different pictures, and an unrelated event must not change its face. @@ -4381,6 +4413,7 @@ export function messagesFeature(ctx) { let m = replyIds.get(x[1]); if (!m) replyIds.set(x[1], m = new Map()); if (!m.has(ev.id)) { m.set(ev.id, ev.pubkey); changed = true; } + keepReply(x[1], ev); } if (changed) scheduleRepaint(); } @@ -4443,6 +4476,8 @@ export function messagesFeature(ctx) { noteCountsReady.add(id); if (c && (c.root?.id === id || c.replies.some((e) => e.id === id))) refreshThreadCount(id, false); } + // the open thread takes the replies this answer brought + if (c && slice.includes(c.rootId) && adoptRepliesInHand(c)) renderThreadStable(); if (c?.root) persistThread(c); saveZapTotals(); scheduleRepaint(); @@ -10212,6 +10247,7 @@ export function messagesFeature(ctx) { .find((c) => c.replies.some((e) => e.id === seed.id || e.id === requestedRootId)); if (c) { threadCache.set(requestedRootId, c); + if (adoptRepliesInHand(c) && c.status === 'ready' && !c.prefetched) persistThread(c); if (!prefetch) { c.focusId = seed.id; const wasPrefetched = c.prefetched; @@ -10248,6 +10284,9 @@ export function messagesFeature(ctx) { // a root already in hand (the post a conversation row answers sits in // the Following feed) paints at once; the relays only fill the replies root: stored?.root || (seed.id === rootId ? seed : noteInHand(rootId)), replies: stored?.replies || [], counts: stored?.counts || {} }; + // the replies the feed already fetched for the count go in now: the + // thread opens whole for any post that has been on screen + adoptRepliesInHand(c); if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed); c.replies.sort((a, b) => a.created_at - b.created_at); threadCache.set(requestedRootId, c); @@ -10265,16 +10304,29 @@ export function messagesFeature(ctx) { return [...new Set([...hints, ...sets.flat(), ...NOTE_RELAYS])].slice(0, 12); }; let relays = await relaysFor(c.root || seed); - const [roots, replies] = await Promise.all([ - c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll + // Replies go in as they arrive, the page repainted in place (not + // after the slowest relay's EOSE, which used to hold the whole + // thread behind a spinner); the root the moment it is found. + const onScreen = () => ui.noteThread && (ui.noteThread.rootId === c.rootId || ui.noteThread.rootId === requestedRootId); + let paintT = null; + const paint = () => { if (paintT || !onScreen()) return; paintT = setTimeout(() => { paintT = null; if (onScreen()) renderThreadStable(); }, 150); }; + const got = new Set(c.replies.map((e) => e.id)); + const take = (e) => { + if (!e || e.id === c.rootId || got.has(e.id)) return; + got.add(e.id); noteForSpam(e); + c.replies = [...c.replies, e].sort((a, b) => a.created_at - b.created_at); + paint(); + }; + await Promise.all([ + c.root ? Promise.resolve() : queryOn(relays, { ids: [rootId] }, 4000).then((roots) => { // a root can be a poll + if (!c.root && (roots || [])[0]) { c.root = roots[0]; paint(); } + }), // kind 1 replies, and NIP-22 comments naming it as their root - Promise.all([ - queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500).catch(() => []), - queryOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, 4500).catch(() => []), - ]).then(([a, b]) => [...(a || []), ...(b || [])]), + queryStreamOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, take, 4500).catch(() => {}), + queryStreamOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, take, 4500).catch(() => {}), ]); - if (!c.root) c.root = (roots || [])[0] || null; - let all = [...c.replies, ...(replies || [])]; + clearTimeout(paintT); paintT = null; + let all = [...c.replies]; // A client that tags only the note it answered leaves the root // unnamed: what we resolved as the root is itself a reply. Climb — // fetch ITS root and re-root the thread there — a few hops at most. @@ -10638,8 +10690,10 @@ export function messagesFeature(ctx) { } }; walk(c.rootId, 0); - if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' }))); - else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); + // the spinner only while there is nothing to read yet: replies in hand + // (or arriving) are the conversation, and the rest slot in as they come + if (c.status === 'loading' && !c.replies.length) kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' }))); + else if (c.status !== 'loading' && !c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies'))); if (!boxPlaced) kids.push(replyBox(false)); // A tapped reply deep in a long thread has to be ON SCREEN when the // thread opens — highlighting it somewhere below the fold reads as the diff --git a/src/thread-prefetch.js b/src/thread-prefetch.js index 6d34db6..c21289c 100644 --- a/src/thread-prefetch.js +++ b/src/thread-prefetch.js @@ -1,5 +1,9 @@ // Dwell is measured on mounted rows, including posts taller than the viewport. // Sampling also catches fast movement within a large post (IO alone cannot). +// A post read for half a second is a post that may be opened: its thread is +// fetched then (it was 1.2 s, which a quick reader beat to the tap), one at +// a time, a dozen a minute. +const DWELL_MS = 600, PER_MINUTE = 12; 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(); @@ -23,12 +27,12 @@ export function createThreadPrefetch({ load, key, cached, doc = globalThis.docum 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); + if (time - d.since >= DWELL_MS) 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) { + if (!busy && starts.length < PER_MINUTE && ready.length) { const seed = ready[0], token = generation; busy = true; starts.push(time); // The loader inserts synchronously, so clicks share its in-flight work. diff --git a/tools/thread-instant-test.js b/tools/thread-instant-test.js new file mode 100644 index 0000000..095b6cf --- /dev/null +++ b/tools/thread-instant-test.js @@ -0,0 +1,152 @@ +// A thread opens whole, at once, for a post that has been on screen: the +// feed already fetches every post's replies for the count, and the thread +// is seeded from them — the relays only add to it. A thread with nothing in +// hand paints its replies as they arrive, not after the slowest relay. +// Run: bun tools/thread-instant-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB), SKC = generateSecretKey(), CAROL = getPublicKey(SKC); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago, sk, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk); +const A1 = post('alice asks the room a question', 900, SKA); +const B1 = post('bob posts something quieter', 800, SKB); +const R1 = post('bob answers alice first', 700, SKB, [['e', A1.id, '', 'root'], ['p', ALICE]]); +const R2 = post('carol answers alice too', 600, SKC, [['e', A1.id, '', 'root'], ['p', ALICE]]); +const R3 = post('alice answers bob back', 500, SKA, [['e', A1.id, '', 'root'], ['e', R1.id, '', 'reply'], ['p', BOB]]); +const S1 = post('carol on bob, quick relay', 400, SKC, [['e', B1.id, '', 'root'], ['p', BOB]]); +const S2 = post('alice on bob, slow relay', 300, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]); +const C1 = post('carol posts last, fetched ahead', 200, SKC); +const U1 = post('bob on carol, held back', 100, SKB, [['e', C1.id, '', 'root'], ['p', CAROL]]); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +// The relays, answered in the page. The feed's count fetch (one REQ, no +// limit, every post on screen) is held until the test releases it: then +// alice's three replies and carol's one land, bob's none. A thread's own +// ask (a limit): alice's answered after 3 s, carol's after 6 s, bob's one +// reply at 300 ms and the other at 2.5 s, EOSE after. +await page.evaluateOnNewDocument((fx) => { + window.__reqs = []; + // the thread prefetch (on at first) is switched off (data saver) once the + // part of the test that wants it is done + window.__conn = { saveData: false }; + Object.defineProperty(navigator, 'connection', { value: window.__conn, configurable: true }); + window.__held = []; + window.__releaseC = () => { for (const f of window.__held) f(); window.__held = []; }; + const Real = window.WebSocket; + const send1 = (ws, id, ev) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); }; + const eose = (ws, id) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); }; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const fs = m.slice(2); + window.__reqs.push({ t: Date.now(), fs }); + const on = (id) => fs.find((f) => (f['#e'] || []).includes(id) && (f.kinds || []).includes(1)); + const onA = on(fx.A1), onB = on(fx.B1), onC = on(fx.C1); + const count = [onA, onB, onC].find((f) => f && !f.limit); + if (count) { window.__held.push(() => { for (const ev of [...fx.aReplies, ...fx.cReplies]) send1(ws, m[1], ev); eose(ws, m[1]); }); return; } + if (onC && onC.limit) { setTimeout(() => { send1(ws, m[1], fx.cReplies[0]); eose(ws, m[1]); }, 6000); return; } + if (onA && onA.limit) { setTimeout(() => { for (const ev of fx.aReplies) send1(ws, m[1], ev); eose(ws, m[1]); }, 3000); return; } + if (onB && onB.limit) { setTimeout(() => send1(ws, m[1], fx.bReplies[0]), 300); setTimeout(() => { send1(ws, m[1], fx.bReplies[1]); eose(ws, m[1]); }, 2500); return; } + } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); +}, { A1: A1.id, B1: B1.id, C1: C1.id, aReplies: [R1, R2, R3], bReplies: [S1, S2], cReplies: [U1] }); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; }; +const thread = () => page.evaluate(() => ({ + rows: [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')), + spinner: !!document.querySelector('.thread-card .spinner'), + text: document.querySelector('.thread-card')?.innerText || '', +})); +const openRow = (id) => page.evaluate((id) => document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), id); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, alice, bob, carol, notes]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(notes)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Asker', t: Date.now() }, [bob]: { name: 'Bob Quiet', t: Date.now() }, [carol]: { name: 'Carol Quick', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, ALICE, BOB, CAROL, [C1, B1, A1]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the feed paints the posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 3, null, 20000)); + console.log('\n[fetched ahead before the count answered]'); + check('reading carol\'s post for a moment fetches its thread ahead', await waitFor((id) => window.__reqs.some((r) => r.fs.some((f) => (f['#e'] || []).includes(id) && f.limit)), C1.id, 15000)); + await sleep(300); + await page.evaluate(() => window.__releaseC()); // now the count answer lands, after the thread entry was made + check('…and the count answer brings its reply', await waitFor((id) => /\b1\b/.test(document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]')?.innerText || ''), C1.id, 8000)); + await page.evaluate(() => { window.__conn.saveData = true; }); // no more fetching ahead: the next parts want threads untouched + const t2 = Date.now(); + await openRow(C1.id); + let ahead = null; + for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows.includes(U1.id)) { ahead = { ...th, ms: Date.now() - t2 }; break; } await sleep(25); } + check('the thread opens with the reply from the count, not after its own 6 s ask', !!ahead && ahead.ms < 1500 && !ahead.spinner, ahead ? ahead.ms + ' ms' : 'never'); + await page.goBack(); await sleep(800); + await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000); + + check('the count fetch brought alice\'s replies', await waitFor((id) => /\b3\b/.test(document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]')?.innerText || ''), A1.id, 15000), 'reply count on the row'); + const asked = await page.evaluate(() => window.__reqs.length); + + console.log('\n[a post that has been on screen]'); + const t0 = Date.now(); + await openRow(A1.id); + let first = null; + for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows.length >= 4) { first = { ...th, ms: Date.now() - t0 }; break; } await sleep(25); } + check('the thread opens whole, at once — the replies in hand, no spinner', !!first && first.ms < 1500 && first.rows[0] === A1.id && [R1.id, R2.id, R3.id].every((id) => first.rows.includes(id)) && !first.spinner, first ? first.ms + ' ms, ' + first.rows.length + ' rows, spinner ' + first.spinner : 'never'); + check('…before the relays answered the thread\'s own ask', (Date.now() - t0) < 2500); + await sleep(3500); + const later = await thread(); + check('the relays\' answer adds nothing twice; no spinner, no "no replies"', later.rows.length === 4 && !later.spinner && !/no replies/i.test(later.text), later.rows.length + ' rows'); + await page.goBack(); await sleep(800); + await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000); + + console.log('\n[a post with nothing in hand]'); + const t1 = Date.now(); + await openRow(B1.id); + check('a spinner while nothing is there to read', await waitFor(() => !!document.querySelector('.thread-card .spinner'), null, 1500, 20)); + let streamed = null; + for (let i = 0; i < 80; i++) { const th = await thread(); if (th.rows.includes(S1.id)) { streamed = { ...th, ms: Date.now() - t1 }; break; } await sleep(25); } + check('the first reply shows as it arrives, the slow one still on its way', !!streamed && streamed.ms < 2000 && !streamed.rows.includes(S2.id), streamed ? streamed.ms + ' ms' : 'never'); + check('…the spinner gone once there is something to read', !!streamed && !streamed.spinner); + check('the slow one lands in its place', await waitFor((id) => { const rows = [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return rows.length === 3 && rows[2] === id && !document.querySelector('.thread-card .spinner'); }, S2.id, 6000)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ threads open at once from the replies in hand, and fill in as they arrive' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/thread-prefetch-test.js b/tools/thread-prefetch-test.js index 6329e64..22107c6 100644 --- a/tools/thread-prefetch-test.js +++ b/tools/thread-prefetch-test.js @@ -15,7 +15,7 @@ const row = (id, top = 0, height = 300) => ({ id, top, height, }); const tick = async (ms = 250) => { time += ms; const fn = task; task = null; fn?.(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }; const watch = (...rs) => { rows = rs; for (const r of rs) p.watch({ id: r.id }); }; -const dwell = async () => { for (let i = 0; i < 7; i++) await tick(); }; +const dwell = async () => { for (let i = 0; i < 4; i++) await tick(); }; // a second: past the 600 ms watch(row('passing')); await tick(); rows = []; await dwell(); assert.deepEqual(calls, []); watch(row('moving')); for (let i = 0; i < 8; i++) { rows[0].top += 30; await tick(); } assert.deepEqual(calls, []); watch(row('hidden')); doc.hidden = true; await dwell(); assert.deepEqual(calls, []); doc.hidden = false; @@ -24,8 +24,8 @@ watch(row('tall', -200, 1800), row('second', 100)); await dwell(); assert.deepEq await dwell(); assert.deepEqual(calls, ['tall'], 'only one in-flight fetch'); resolve(); await tick(); await tick(); assert.deepEqual(calls, ['tall', 'second']); resolve(); await tick(); await dwell(); assert.equal(calls.length, 2, 'cached posts are reused'); -for (let i = 0; i < 5; i++) { watch(row('rate' + i)); await dwell(); resolve(); await tick(); } -assert.equal(calls.length, 6, 'six starts per minute'); -await tick(60000); assert.equal(calls.length, 7); resolve(); await tick(); -watch(row('cancelled')); p.stop(); await dwell(); assert.equal(calls.length, 7); +for (let i = 0; i < 11; i++) { watch(row('rate' + i)); await dwell(); resolve(); await tick(); } +assert.equal(calls.length, 12, 'twelve starts per minute'); +await tick(60000); assert.equal(calls.length, 13); resolve(); await tick(); +watch(row('cancelled')); p.stop(); await dwell(); assert.equal(calls.length, 13); console.log('✓ dwell, fast scrolling, unmount, hidden tab, data saver, tall posts, concurrency, cache, rate limit and stop'); |
