aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-24 08:33:46 -0700
committerAdam Soltys <[email protected]>2026-09-24 08:33:46 -0700
commit189c1a3d24b859d3ebf351cff24bbec788a0251a (patch)
tree13e52ea66f136ccf753114f98b297acd64981d4f
parentcdca3fa061c856f9063aadfabf1c1e713f512123 (diff)
downloadcoinosv3-189c1a3d24b859d3ebf351cff24bbec788a0251a.tar.gz
coinosv3-189c1a3d24b859d3ebf351cff24bbec788a0251a.zip
Preserve restored thread state across rapid refreshes
-rw-r--r--src/app.js4
-rw-r--r--src/features/messages.js18
-rw-r--r--tools/boot-signin-flash-test.js17
-rw-r--r--tools/thread-reply-test.js17
4 files changed, 46 insertions, 10 deletions
diff --git a/src/app.js b/src/app.js
index 5087def..e00e7e0 100644
--- a/src/app.js
+++ b/src/app.js
@@ -607,7 +607,7 @@ function renderInner() {
// Get started here is what flashed the sign-in page over a
// wallet that was about to restore. Every other screen is a
// deliberate choice and still paints; this one waits.
- ? h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false))
+ ? h('div', { class: 'col', style: 'gap:16px' }, BOOT_NAV ? null : brandHeader(false))
: shouldOnboard() ? onboardScreen() : unlockScreen());
// Navigation animates; background repaints must not. The key is every
// ui field that decides which page is on screen.
@@ -5579,7 +5579,7 @@ const INTENT_URI = (() => {
} catch { return null; }
})();
const FEATURES = buildFeatures(ctx);
-// Deferred features (gifts, NWC, hats) hold placeholder slots in FEATURES —
+// Deferred features (gifts, NWC, POS) hold placeholder slots in FEATURES —
// hook precedence is position — and arrive as separate chunks right after
// boot, filled in place. If a wallet opened before they landed, each newcomer
// gets the init() it missed; if the wallet locked again, it doesn't.
diff --git a/src/features/messages.js b/src/features/messages.js
index 9cc0175..a73809e 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1811,7 +1811,13 @@ export function messagesFeature(ctx) {
const ref = m ? parseNostrRef(m[1].toLowerCase()) : null;
return ref && ref.type === 'event' ? ref : null;
})();
- if (urlNote) {
+ // The app's history already holds this exact thread on a reload. Leave its
+ // state and URL intact so wallet restore can paint the header and thread
+ // together; the public deep-link path is only for a NEW visit.
+ const restoringUrlThread = (() => {
+ try { return !!urlNote && history.state?.nav?.noteThread?.focusId === urlNote.id; } catch { return false; }
+ })();
+ if (urlNote && !restoringUrlThread) {
try { history.replaceState(null, '', '/'); } catch {}
ui.pubProf = true;
// a reload of a thread the history restores by itself needs no fetch
@@ -5731,6 +5737,16 @@ export function messagesFeature(ctx) {
const noteSep = () => h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' });
function renderThreadStable() {
if (!ui.noteThread || ui.noteThread.scrollPending || typeof document === 'undefined') { render(); return; }
+ // At the very top the HEADER is the anchor. Pinning the first post instead
+ // turns a late header/control row into an unwanted downward scroll.
+ if (window.scrollY <= 1) {
+ render();
+ if (window.scrollY) window.scrollTo(0, 0);
+ requestAnimationFrame(() => {
+ if (ui.noteThread && window.scrollY <= 80) window.scrollTo(0, 0);
+ });
+ return;
+ }
const rows = [...document.querySelectorAll('.thread-page [data-zap-post]')];
const anchor = rows.find((el) => el.getAttribute('data-focus-note') === '1'
&& el.getBoundingClientRect().top >= 0 && el.getBoundingClientRect().top < innerHeight)
diff --git a/tools/boot-signin-flash-test.js b/tools/boot-signin-flash-test.js
index 6e22ebb..75f5b50 100644
--- a/tools/boot-signin-flash-test.js
+++ b/tools/boot-signin-flash-test.js
@@ -66,15 +66,19 @@ try {
await page.evaluate(({ rootId, replyId, author }) => {
const root = { id: rootId, pubkey: author, kind: 1, content: 'Cached thread root', tags: [], created_at: 1 };
const reply = { ...root, id: replyId, content: 'Cached thread reply', tags: [['e', rootId, '', 'root']], created_at: 2 };
- localStorage.setItem('btc-wallet-note-threads', JSON.stringify([{ rootId, root, replies: [reply], profiles: {}, counts: {} }]));
+ const replies = [reply, ...Array.from({ length: 24 }, (_, i) => ({ ...reply,
+ id: (i + 20).toString(16).padStart(64, '0'), content: 'Another cached reply '.repeat(8), created_at: i + 3 }))];
+ localStorage.setItem('btc-wallet-note-threads', JSON.stringify([{ rootId, root, replies, profiles: {}, counts: {} }]));
const nav = { ...history.state.nav, screen: 'wallet', noteThread: { rootId, focusId: replyId, seed: reply } };
history.replaceState({ nav, i: 0 }, '', location.href);
}, { rootId, replyId, author });
await page.evaluateOnNewDocument(() => {
- window.__threadBoot = { homeFrames: 0, animatedFrames: 0, threadAt: null };
+ window.__threadBoot = { homeFrames: 0, partialHeaderFrames: 0, animatedFrames: 0, maxScroll: 0, threadAt: null };
const tick = () => {
const state = window.__threadBoot;
+ state.maxScroll = Math.max(state.maxScroll, window.scrollY);
if (document.querySelector('.balance')) state.homeFrames++;
+ if (document.querySelector('.brand') && !document.querySelector('.header-avatar')) state.partialHeaderFrames++;
const thread = document.querySelector('.thread-page');
if (thread?.classList.contains('anim-page')) state.animatedFrames++;
if (thread) state.threadAt ??= Math.round(performance.now());
@@ -83,10 +87,15 @@ try {
requestAnimationFrame(tick);
});
await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(250);
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(1200);
const thread = await page.evaluate(() => window.__threadBoot);
- const steady = thread.threadAt != null && !thread.homeFrames && !thread.animatedFrames;
- console.log(` ${steady ? '✓' : '✗'} thread reload: ${thread.homeFrames} home frames, ${thread.animatedFrames} page animation frames`);
+ const atTop = await page.evaluate(() => window.scrollY === 0);
+ const steady = thread.threadAt != null && !thread.homeFrames && !thread.partialHeaderFrames && !thread.animatedFrames
+ && !thread.maxScroll && atTop;
+ console.log(` ${steady ? '✓' : '✗'} rapid second thread reload: ${thread.homeFrames} home frames, ${thread.partialHeaderFrames} partial-header frames, ${thread.animatedFrames} page animation frames, max scroll ${thread.maxScroll}px`);
if (!steady) ok = false;
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ no sign-in flash' : '\n❌ the sign-in page flashed');
diff --git a/tools/thread-reply-test.js b/tools/thread-reply-test.js
index 3dba87f..37c22b4 100644
--- a/tools/thread-reply-test.js
+++ b/tools/thread-reply-test.js
@@ -22,9 +22,11 @@ function render() {
if (!feature) return;
document.querySelector('#app').replaceChildren(feature.screenView() || feature.row(author, reply, 'Someone'));
}
-feature = messagesFeature({ h, wallet, ui, render, toast() {}, brandHeader: () => null,
+feature = messagesFeature({ h, wallet, ui, render, toast() {}, brandHeader: () => window.test?.showNav
+ ? h('div', { style: 'height:60px' }) : null,
hook: k => k === 'nostrLoginIdentity' ? { pubkey: mine, signer } : null });
-window.test = { ui, reply, other, root, render, waiting,
+window.test = { ui, reply, other, root, render, waiting, showNav: false,
+ stable: () => feature.testStable(),
seedFace: () => createThreadStore().save({ root, replies: [reply] }, reply.id, {
[author]: { name: 'Nostrich', picture: 'https://example.com/nostrich.webp', eventAt: 10, t: Date.now(),
thumbFor: 'https://example.com/nostrich.webp', thumb: 'data:image/png;base64,iVBORw0KGgo=', thumbPx: 144 },
@@ -46,7 +48,7 @@ const bundle = await Bun.build({ entrypoints: ['thread-test-entry'], target: 'br
build.onResolve({ filter: /^thread-test-entry$/ }, () => ({ path: 'entry', namespace: 'thread-test' }));
build.onLoad({ filter: /.*/, namespace: 'thread-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text())
- .replace("id: 'messages',", "id: 'messages', row: noteRow, actions: noteActions,") }));
+ .replace("id: 'messages',", "id: 'messages', row: noteRow, actions: noteActions, testStable: renderThreadStable,") }));
build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text())
.replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { return window.query(filter);')
.replace('export async function publishOn(relays, evt) {', 'export async function publishOn(relays, evt) { window.sent.push(evt); return true;')
@@ -114,6 +116,15 @@ try {
const buttons = row?.querySelectorAll('.note-acts > button');
return buttons?.[1].textContent === '7' && buttons?.[3].textContent === '29' && buttons?.[4].textContent === '210';
}));
+ await page.evaluate(() => {
+ document.querySelector('#outside').style.height = '2000px';
+ window.scrollTo(0, 0);
+ test.showNav = true;
+ test.stable();
+ });
+ await new Promise((r) => setTimeout(r, 80));
+ check('adding header controls while at the top does not scroll to the first post',
+ await page.evaluate(() => window.scrollY === 0));
assert.deepEqual(errors, []);
console.log('✓ no browser errors');
} finally { await browser.close(); server.stop(true); }