diff options
| author | Adam Soltys <[email protected]> | 2026-11-14 00:04:40 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-14 00:04:40 -0700 |
| commit | 32ae7775d6351ef7acc887de812d5200da95874d (patch) | |
| tree | f03775602348909a45697131b6ed65ac9d2c0ed7 /tools | |
| parent | 8d92028c0a4eeb19258e81156c5b9efc3c1d1f88 (diff) | |
| download | coinosv3-32ae7775d6351ef7acc887de812d5200da95874d.tar.gz coinosv3-32ae7775d6351ef7acc887de812d5200da95874d.zip | |
A profile's posts stream in as the relays send them, with post-shaped placeholders until the first arrive, and search warms its top two results first. "when i open someone's profile that i'm visiting for the first time it can take several seconds for any posts to show up. there's no indication that it's loading posts. then suddenly a whole feed appears. can we stream them as they arrive … maybe it could start fetching posts for the top one or two search results before we even click them?" (Adam, 2026-11-13, Ben Arc from search). fetchNotes looked up the author's relay list first, then waited for every relay (4.5 s cap), then for every post's media (8 s cap) before painting anything. Now it asks the home and archive relays at once and the author's own relays as soon as their list is known (queryStreamOn), paints the first post the moment it's verified and a batch every 300 ms after, gives the rows about to be on screen 1.2 s to size their media rather than 8, and merges each batch into the page as before. With nothing in hand the page shows three faint breathing post shapes (aria-busy, "Loading posts…"; still no spinner) instead of an invisible empty line. User search used to queue a profile prefetch for every row of every letter typed, two at a time, so the one tapped waited behind a dozen; now only the top two of the current answer are warmed, ahead of anything queued. Measured on the real relays (tools/profile-stream-test.js, search "fiatjaf", tap the top result): first post 5.8 s / >10 s before, 0.35–0.9 s now, placeholder within 20 ms. profile-open, -feed, -replies, -repost, -pager, feed-custom and features-smoke pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/profile-stream-test.js | 58 |
1 files changed, 58 insertions, 0 deletions
diff --git a/tools/profile-stream-test.js b/tools/profile-stream-test.js new file mode 100644 index 0000000..8d15775 --- /dev/null +++ b/tools/profile-stream-test.js @@ -0,0 +1,58 @@ +// A profile visited for the first time, from search: its posts used to land +// all at once after several seconds of blank (the slowest relay, then every +// post's media). Now the page shows post-shaped placeholders while it waits, +// the first posts paint as the quick relays answer, and the top search +// results are fetched before they're tapped. Reads real relays +// (relays: 'read'; nothing is published). +// Run: bun tools/profile-stream-test.js (Q="ben arc" to search someone else) +import puppeteer from './puppeteer.js'; +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 Q = process.env.Q || 'fiatjaf'; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +try { + await page.setViewport({ width: 390, height: 844 }); + if (process.env.DARK) await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]); + await page.goto(server.url.href + 'feed', { waitUntil: 'domcontentloaded' }); + await sleep(1500); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === 'Search')?.click()); + await page.waitForSelector('.user-search-main', { timeout: 10000 }); + await page.type('.user-search-main', Q, { delay: 60 }); + // the result rows: people (not the "posts that say this" or #topic rows) + const rows = () => page.evaluate(() => [...document.querySelectorAll('.list .item')].filter((r) => !/^[\u{1F50D}#]/u.test(r.textContent.trim())).map((r) => r.textContent.trim().slice(0, 40))); + let found = []; + for (let i = 0; i < 60 && !found.length; i++) { found = await rows(); if (!found.length) await sleep(250); } + if (!found.length) console.log(await page.evaluate(() => document.querySelector('.user-search-main')?.closest('.col')?.parentElement?.innerHTML.slice(0, 1500))); + check('search finds people', found.length > 0, found.slice(0, 2).join(' | ')); + const wait = Number(process.env.WAIT || 0); // ms between results and the tap (prefetch's head start) + if (wait) await sleep(wait); + const t0 = Date.now(); + await page.evaluate(() => [...document.querySelectorAll('.list .item')].filter((r) => !/^[\u{1F50D}#]/u.test(r.textContent.trim()))[0].click()); + let ghostAt = 0, firstAt = 0, rowsAt3 = 0; + for (let i = 0; i < 200; i++) { + const s = await page.evaluate(() => ({ + ghost: !!document.querySelector('.note-ghosts'), + rows: document.querySelectorAll('.notes-feed:not(.note-ghosts) > [data-key]').length, + })); + const t = Date.now() - t0; + if (s.ghost && !ghostAt) { ghostAt = t; if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT }); } + if (s.rows && !firstAt) firstAt = t; + if (t >= 3000 && !rowsAt3) rowsAt3 = s.rows || -1; + if (firstAt && t >= 3000) break; + await sleep(50); + } + console.log(` placeholder ${ghostAt ? ghostAt + ' ms' : 'not seen'}, first post ${firstAt || '—'} ms, rows at 3 s ${rowsAt3}`); + check('posts arrive', firstAt > 0); + check('...while waiting, the page says posts are loading', !!ghostAt || firstAt < 300, ghostAt ? 'placeholder shown' : 'posts were already in hand'); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); } +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); |
