From 32ae7775d6351ef7acc887de812d5200da95874d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 14 Nov 2026 00:04:40 -0700 Subject: 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS --- tools/profile-stream-test.js | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tools/profile-stream-test.js (limited to 'tools') 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); -- cgit v1.2.3