aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js74
-rw-r--r--src/thread-prefetch.js8
-rw-r--r--tools/thread-instant-test.js152
-rw-r--r--tools/thread-prefetch-test.js10
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');