aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-26 23:42:50 -0700
committerAdam Soltys <[email protected]>2026-09-26 23:42:50 -0700
commit93a95d1ce45d09cb833120cd61573fd88eb8ed26 (patch)
treeba08e857cac823647366ffa59e8d419694927161
parentb9226f695c827a4b16719fd0c752801f4e768e6c (diff)
downloadcoinosv3-93a95d1ce45d09cb833120cd61573fd88eb8ed26.tar.gz
coinosv3-93a95d1ce45d09cb833120cd61573fd88eb8ed26.zip
Media sources wait until the node is in the page; thumbnails use the cache.
Every render builds a fresh tree to diff against the live one, and a media element starts downloading the moment its src is set, even off the document: a sixteen-second feed scroll built 204 <video> and 1,416 <img> elements whose loads the morph then threw away — 375 cancelled video range requests, and full re-downloads from hosts that send no cache headers. h() now puts the source on data-lazy-src and it becomes src only once the node is in the page (render promotes it; a MutationObserver covers nodes appended by hand); the morph treats the two as one attribute. The thumbnail maker fetched every original with cache: 'reload' (38 MB in one scroll); the face was just painted from that URL, so the cache serves. holdScroll now recovers an anchor row that a large insert above pushed out of the window, from the height cache. Tests: the YouTube test accepts the in-view players autoplay loads; the stability test watches the notice for the catch-up, since the new rows land inside the window's spacer. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D
-rw-r--r--src/app.js41
-rw-r--r--src/features/messages.js31
-rw-r--r--tools/feed-stability-test.js5
-rw-r--r--tools/youtube-embed-test.js6
4 files changed, 74 insertions, 9 deletions
diff --git a/src/app.js b/src/app.js
index e507776..196d90d 100644
--- a/src/app.js
+++ b/src/app.js
@@ -138,8 +138,10 @@ function blankSend() {
// ---------------------------------------------------------------- DOM helper
function h(tag, attrs = {}, ...children) {
const e = document.createElement(tag);
+ const lazy = lazySrcTag(e.nodeName);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
+ if (k === 'src' && lazy) { e.setAttribute('data-lazy-src', v); continue; }
if (k === 'class') e.className = v;
else if (k === 'html') { e.innerHTML = v; e._html = v; }
else if (k === 'value') e.value = v;
@@ -160,6 +162,33 @@ function h(tag, attrs = {}, ...children) {
return e;
}
+// A media element starts downloading the moment its src is set — even off
+// the document. Every render builds a fresh tree to diff against the live
+// one, so an <img src> or <video src> built here used to start a load per
+// repaint that the morph then threw away (a feed scroll built two hundred
+// videos and fourteen hundred images in sixteen seconds). The source waits
+// on data-lazy-src and becomes src only once the node is in the page (see
+// promoteLazySrc); the morph treats the two as the same attribute.
+// (a function declaration: h() is called at module load before a const here
+// would be initialized)
+function lazySrcTag(name) { return name === 'IMG' || name === 'VIDEO' || name === 'AUDIO' || name === 'SOURCE'; }
+function promoteLazySrc(root) {
+ for (const el of root.querySelectorAll('[data-lazy-src]')) {
+ const v = el.getAttribute('data-lazy-src');
+ el.removeAttribute('data-lazy-src');
+ if (el.getAttribute('src') !== v) el.setAttribute('src', v);
+ }
+}
+// a node appended by hand, outside render(), gets its source the same way
+if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
+ new MutationObserver((ms) => {
+ for (const m of ms) for (const n of m.addedNodes) {
+ if (n.nodeType !== 1) continue;
+ if (n.hasAttribute('data-lazy-src')) promoteLazySrc({ querySelectorAll: () => [n] });
+ promoteLazySrc(n);
+ }
+ }).observe(document.documentElement, { childList: true, subtree: true });
+}
// ---------------------------------------------------------------- morphing
// render() builds a fresh tree exactly as before, but instead of replacing
// the document we PATCH the live DOM into its shape. Nodes keep their
@@ -204,8 +233,15 @@ function morph(a, b) {
// click), like a field's value: a render that doesn't set `open` leaves it
// alone instead of collapsing the section on every background repaint.
const userOwned = (at) => a.nodeName === 'DETAILS' && at.name === 'open';
- for (const at of [...a.attributes]) if (!b.hasAttribute(at.name) && !userOwned(at)) a.removeAttribute(at.name);
- for (const at of [...b.attributes]) if (a.getAttribute(at.name) !== at.value) a.setAttribute(at.name, at.value);
+ for (const at of [...a.attributes]) {
+ if (b.hasAttribute(at.name) || userOwned(at)) continue;
+ if (at.name === 'src' && b.hasAttribute('data-lazy-src')) continue; // the lazy source stands for it
+ a.removeAttribute(at.name);
+ }
+ for (const at of [...b.attributes]) {
+ if (at.name === 'data-lazy-src') { if (a.getAttribute('src') !== at.value) a.setAttribute('src', at.value); continue; }
+ if (a.getAttribute(at.name) !== at.value) a.setAttribute(at.name, at.value);
+ }
if (snap) {
a.style.transition = 'none';
requestAnimationFrame(() => requestAnimationFrame(() => { a.style.transition = ''; }));
@@ -639,6 +675,7 @@ function renderInner() {
}
applyAnim(screen, 'anim-page', (performance.now() - _navAt) < 340 ? performance.now() - _navAt : -1);
morphChildren(root, [screen, footer(), ...(ui.lightbox ? [imageViewer()] : [])]);
+ promoteLazySrc(root); // sources on the nodes that actually made it into the page
try { document.documentElement.classList.toggle('no-scroll', !!ui.lightbox); } catch {}
if (fpath) {
const el = nodeAtPath(fpath);
diff --git a/src/features/messages.js b/src/features/messages.js
index b1c78d3..bc74ba9 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -598,7 +598,7 @@ export function messagesFeature(ctx) {
? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}),
...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1,
thumbFailVersion: p.thumbFailVersion || 0 } : {}) };
- if (!profFlush) profFlush = setTimeout(flushProfiles, 1500);
+ if (!profFlush) profFlush = setTimeout(flushProfiles, 500);
}
// A punk picture is OUR OWN art. Every coinos user who keeps the default
// publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and
@@ -727,7 +727,10 @@ export function messagesFeature(ctx) {
(async () => {
let bmp = null;
try {
- const res = await fetch(url, { mode: 'cors', cache: 'reload', signal: AbortSignal.timeout(THUMB_SLOW) });
+ // the browser's cache is welcome: the face was just painted from this
+ // very URL, and 'reload' re-downloaded every original (38 MB in one
+ // scroll) — a stale copy still makes a fine thumbnail
+ const res = await fetch(url, { mode: 'cors', signal: AbortSignal.timeout(THUMB_SLOW) });
if (!res.ok) throw new Error('http ' + res.status);
bmp = await createImageBitmap(await res.blob());
// centre-crop to a square, the way background-size:cover paints it
@@ -4523,7 +4526,7 @@ export function messagesFeature(ctx) {
});
}
- function holdScroll(paint) {
+ function holdScroll(paint, c = feed) {
let key = null, top = 0;
try {
for (const r of document.querySelectorAll('.notes-feed > .row[data-key]')) {
@@ -4533,8 +4536,28 @@ export function messagesFeature(ctx) {
} catch {}
paint();
if (!key) return;
+ const find = () => document.querySelector('.notes-feed > [data-key="' + CSS.escape(key) + '"]');
try {
- const r = document.querySelector('.notes-feed > [data-key="' + CSS.escape(key) + '"]');
+ let r = find();
+ // Not mounted any more: a batch went in above (a catch-up of thirty
+ // posts, say) and the window, computed for the old scroll offset, now
+ // holds the newcomers instead of the reader's row. Estimate where the
+ // row went from the height cache, scroll there, and paint once more
+ // so the window catches up — then the usual correction lands it.
+ if (!r && c && c.heights && c.winList) {
+ const list = c.winList();
+ const i = list.findIndex((ev) => ev.id === key);
+ if (i >= 0) {
+ let sum = 0, n = 0;
+ for (const v of c.heights.values()) { sum += v; n++; }
+ const avg = n ? sum / n : 200;
+ let y = 0;
+ for (let j = 0; j < i; j++) y += (c.heights.get(list[j].id) || avg) + ROW_GAP;
+ window.scrollTo(0, Math.max(0, (c.listTop || 0) + y - top));
+ paint();
+ r = find();
+ }
+ }
const d = r ? r.getBoundingClientRect().top - top : 0;
if (Math.abs(d) > 1) window.scrollBy(0, d);
} catch {}
diff --git a/tools/feed-stability-test.js b/tools/feed-stability-test.js
index a176a0b..8b969f1 100644
--- a/tools/feed-stability-test.js
+++ b/tools/feed-stability-test.js
@@ -190,7 +190,10 @@ try {
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
});
- await page.waitForFunction(() => document.querySelector('.notes-feed')?.innerText.includes('Large catch-up 24'), { timeout: 15000 });
+ // the new posts go in above the reader — inside the window's top spacer,
+ // not the DOM — so what shows is the notice counting them
+ try { await page.waitForFunction(() => parseInt((document.querySelector('.feed-new-pill')?.textContent || '0').replace(/\D/g, ''), 10) >= 25, { timeout: 15000 }); }
+ catch (e) { console.log(' catch-up did not surface:', JSON.stringify(await page.evaluate(() => ({ pill: document.querySelector('.feed-new-pill')?.textContent || null, rows: document.querySelectorAll('.notes-feed > .row').length, h: document.documentElement.scrollHeight, y: scrollY, top: document.querySelector('[data-key="win-top"]')?.offsetHeight || 0, first: document.querySelector('.notes-feed > .row')?.innerText.slice(0, 40), catchupLeft: (window.__catchup || []).length, reqs: (window.__feedRequests || []).length }))), errors.slice(0, 3)); }
const after = (await snapshot()).find((r) => r.id === before.id);
check('a large catch-up preserves the reading position', !!after && Math.abs(after.top - before.top) <= 1, JSON.stringify({ before: before.top, after: after?.top }));
// Start on the wallet and let its small background warm-up finish. The
diff --git a/tools/youtube-embed-test.js b/tools/youtube-embed-test.js
index b32eb98..1c66676 100644
--- a/tools/youtube-embed-test.js
+++ b/tools/youtube-embed-test.js
@@ -50,7 +50,9 @@ await sleep(3500);
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
-const posters = await page.evaluate(() => [...document.querySelectorAll('.yt-embed img')].map((i) => i.getAttribute('src')));
+if (process.env.DEBUG) console.log('ROWS:', await page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row')].map((r) => r.innerText.replace(/\n+/g, ' | ').slice(0, 80) + ' :: embeds=' + r.querySelectorAll('.yt-embed').length + ' links=' + r.querySelectorAll('a').length)));
+// a box in view has already swapped its poster for a muted player (autoplay), so read either
+const posters = await page.evaluate(() => [...document.querySelectorAll('.yt-embed')].map((b) => b.querySelector('iframe')?.getAttribute('src') || b.querySelector('img')?.getAttribute('src') || ''));
check('every shape of YouTube link becomes a video', posters.length === 3, posters.length + ' embed(s)');
check('...the long one', posters.some((u) => u.includes('dQw4w9WgXcQ')));
check('...the short one', posters.some((u) => u.includes('aqz-KE-bpKQ')));
@@ -58,7 +60,7 @@ check('...and a Shorts link', posters.some((u) => u.includes('abc123XYZ_-')));
const other = await page.evaluate(() => [...document.querySelectorAll('.notes-feed a[href^="http"]')].map((a) => a.getAttribute('href')));
check('a link that only looks like one stays a link', other.includes('https://example.com/watch?v=nope'));
-check('nothing has loaded a player yet', (await page.evaluate(() => document.querySelectorAll('iframe').length)) === 0);
+check('players load only for boxes in view, muted', await page.evaluate(() => [...document.querySelectorAll('.yt-embed')].every((b) => { const r = b.getBoundingClientRect(); const inView = r.top < innerHeight && r.bottom > 0; const f = b.querySelector('iframe'); return inView ? !!f && /mute=1/.test(f.src) : !f; })));
await page.evaluate(() => document.querySelector('.yt-play').click());
await sleep(1200);
const src = await page.evaluate(() => { const f = document.querySelector('iframe'); return f ? f.getAttribute('src') : ''; });