Skip to content

Commit 5a56345

Browse files
authored
Keep first-visit bootstrap gated on relay data (#946)
* Keep first-run bootstrap gated on real data Seed import is an acceleration path, not proof that the GUI has usable relay data. The layout now waits for relay-check aggregates before rendering the app shell, while the leader continues showing the bootstrap UI and follower tabs keep waiting until the data gate opens. Constraint: Missing or empty seeds must not skip the runtime network bootstrap UX. Rejected: Treat seed completion as sufficient for content render | it reopens the empty-app first-visit regression. Confidence: high Scope-risk: narrow Directive: Do not loosen showContent without proving clean-state relayCheckAggregates is non-empty before the app shell renders. Tested: git diff --check Tested: pnpm -r --workspace-concurrency=1 build Tested: pnpm --filter @nostrwatch/gui test Tested: pnpm --filter @nostrwatch/gui build Tested: Playwright fresh-context preview smoke with external network blocked; booting=1, contentWrapper=0, pageErrors=[] Not-tested: pnpm --filter @nostrwatch/gui check still fails on current origin/next with baseline type diagnostics outside this file * Restore first-visit bootstrap data gate and relay fetch compatibility First-visit users could leave the seed checklist before relay-check aggregates existed, and the no-seed fallback path was broken by nostr-tools API drift between the GUI and websocket adapter graphs. The GUI now waits for actual aggregate data before rendering the app, the seed generator uses the current pool subscription API, and the runtime adapter supports both old and new nostr-tools pool APIs.\n\nConstraint: Missing seed artifacts must fall back to network bootstrap instead of entering an empty app.\nConstraint: GUI seed generation resolves nostr-tools 2.23 while the websocket adapter package can still resolve older 2.x behavior.\nRejected: Treat missing seeds as fatal | first-visit bootstrap must work without seeds.\nConfidence: high\nScope-risk: moderate\nDirective: Do not weaken the first-visit render gate without empty-profile browser proof and route66 cache counts.\nTested: pnpm --filter @nostrwatch/route66-wsadapter-nostrtools build; bounded pnpm --filter @nostrwatch/gui seed; pnpm --filter @nostrwatch/gui build; empty-profile Playwright no-seed and seeded bootstrap proofs with screenshots.\nNot-tested: Full pnpm -r build * Keep app chrome out of bootstrap loading screens The first-visit bootstrap gate now waits for actual data, but the layout still rendered the fixed header before deciding which boot surface to show. Moving the header into the content branch keeps bootstrap and follower waiting states as true full-screen loading screens, then restores the normal header once content is allowed to render.\n\nConstraint: Boot screens must not include the header or navigation chrome.\nRejected: Disable nav while booting | the screenshot requirement is no header/nav at all, not disabled chrome.\nConfidence: high\nScope-risk: narrow\nDirective: Keep app chrome behind showContent unless there is explicit browser proof for a different bootstrap UX.\nTested: Fresh Playwright profile showed boot #site-header count 0 and app #site-header count 1 after #content-wrapper appeared; pnpm --filter @nostrwatch/gui build.\nNot-tested: Full pnpm -r build --------- Co-authored-by: sandwich <dskvr@users.noreply.github.com>
1 parent 71ff416 commit 5a56345

5 files changed

Lines changed: 173 additions & 118 deletions

File tree

apps/gui/scripts/generate-seed.mjs

Lines changed: 84 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -128,33 +128,43 @@ async function queryMany(pool, relays, filters, { maxWaitMs }) {
128128
return await new Promise((resolve) => {
129129
const events = [];
130130
const closes = [];
131-
pool.subscribeManyEose(relays, filters, {
131+
let subcloser;
132+
let resolved = false;
133+
const finish = (reasons) => {
134+
if (resolved) return;
135+
resolved = true;
136+
if (Array.isArray(reasons)) closes.push(...reasons);
137+
resolve({ events, closes });
138+
};
139+
const params = {
132140
maxWait: maxWaitMs,
133141
onevent: (event) => {
134142
events.push(event);
135143
},
136-
onclose: (reasons) => {
137-
if (Array.isArray(reasons)) closes.push(...reasons);
138-
resolve({ events, closes });
144+
onclose: finish,
145+
};
146+
147+
if (filters.length === 1) {
148+
subcloser = pool.subscribeManyEose(relays, filters[0], params);
149+
return;
150+
}
151+
152+
const requests = [];
153+
for (const url of relays) {
154+
for (const filter of filters) {
155+
requests.push({ url, filter });
156+
}
157+
}
158+
159+
subcloser = pool.subscribeMap(requests, {
160+
...params,
161+
oneose: () => {
162+
subcloser?.close('closed automatically on eose');
139163
},
140164
});
141165
});
142166
}
143167

144-
// After a query round, patch pool relays that failed to connect so they
145-
// throw immediately on subsequent steps instead of waiting for another
146-
// full connectionTimeout cycle.
147-
function skipFailedRelays(pool) {
148-
for (const [url, relay] of pool.relays) {
149-
if (!relay.connected && !relay.connectionPromise) {
150-
console.warn(`[seed] skipping failed relay in future queries: ${url}`);
151-
relay.connect = async () => {
152-
throw new Error(`previously failed to connect to ${url}`);
153-
};
154-
}
155-
}
156-
}
157-
158168
async function main() {
159169
const websocketImplementation = await ensureWebSocketImpl();
160170
// nostr-tools' SimplePool reads a module-level WebSocket implementation.
@@ -214,7 +224,6 @@ async function main() {
214224
{ maxWaitMs: registrationWaitMs }
215225
);
216226
if (registrationCloses.length) console.warn('[seed] registrations closes:', registrationCloses);
217-
skipFailedRelays(pool);
218227

219228
const registrationsByPubkey = new Map();
220229
for (const ev of registrationRaw) {
@@ -240,13 +249,14 @@ async function main() {
240249
const metaLimitMax = envNumber('SEED_META_LIMIT_MAX', 5000);
241250
const authorChunks = chunk(monitorPubkeys, envNumber('SEED_META_AUTHORS_PER_REQ', 50));
242251

243-
const monitorMetaResults = await Promise.all(
244-
authorChunks.map(authors => {
245-
const limit = Math.min(metaLimitMax, Math.max(50, authors.length * metaLimitMultiplier));
246-
return queryMany(pool, userMetaRelays, [{ kinds: [0, 10002], authors, limit }], { maxWaitMs });
247-
})
248-
);
249-
for (const { events, closes } of monitorMetaResults) {
252+
for (const authors of authorChunks) {
253+
const limit = Math.min(metaLimitMax, Math.max(50, authors.length * metaLimitMultiplier));
254+
const { events, closes } = await queryMany(
255+
pool,
256+
userMetaRelays,
257+
[{ kinds: [0, 10002], authors, limit }],
258+
{ maxWaitMs }
259+
);
250260
if (closes.length) console.warn('[seed] monitor-meta closes:', closes);
251261
for (const ev of events) {
252262
if (ev?.kind !== 0 && ev?.kind !== 10002) continue;
@@ -257,19 +267,19 @@ async function main() {
257267

258268
const monitorMeta = Array.from(monitorMetaByKey.values());
259269
console.log('[seed] monitor meta', monitorMeta.length);
260-
skipFailedRelays(pool);
261270

262271
// ---------------------------------------------------------------------------
263272
// 2b) Monitor blocklists (kind 10006)
264273
// ---------------------------------------------------------------------------
265274
const monitorBlocklistsByKey = new Map();
266-
const blocklistResults = await Promise.all(
267-
authorChunks.map(authors => {
268-
const limit = Math.min(metaLimitMax, Math.max(50, authors.length * metaLimitMultiplier));
269-
return queryMany(pool, [...userMetaRelays, ...nip66Relays], [{ kinds: [10006], authors, limit }], { maxWaitMs });
270-
})
271-
);
272-
for (const { events, closes } of blocklistResults) {
275+
for (const authors of authorChunks) {
276+
const limit = Math.min(metaLimitMax, Math.max(50, authors.length * metaLimitMultiplier));
277+
const { events, closes } = await queryMany(
278+
pool,
279+
[...userMetaRelays, ...nip66Relays],
280+
[{ kinds: [10006], authors, limit }],
281+
{ maxWaitMs }
282+
);
273283
if (closes.length) console.warn('[seed] monitor-blocklists closes:', closes);
274284
for (const ev of events) {
275285
if (ev?.kind !== 10006) continue;
@@ -293,9 +303,6 @@ async function main() {
293303
}
294304
console.log('[seed] blocked relay URLs from blocklists:', blockedRelayUrls.size);
295305

296-
// Mark relays that failed to connect so they're skipped in subsequent steps
297-
skipFailedRelays(pool);
298-
299306
// Bootstrap logic adds monitors' own relay lists to the nip66 relay pool.
300307
// This improves coverage for check events that may not land on the defaults.
301308
const maxExtraRelays = envNumber('SEED_MAX_EXTRA_NIP66_RELAYS', 50);
@@ -340,12 +347,8 @@ async function main() {
340347
});
341348
}
342349

343-
const activityResults = await Promise.all(
344-
chunk(activityFilters, filtersPerReq).map(filters =>
345-
queryMany(pool, nip66Relays, filters, { maxWaitMs: activityWaitMs })
346-
)
347-
);
348-
for (const { events, closes } of activityResults) {
350+
for (const filters of chunk(activityFilters, filtersPerReq)) {
351+
const { events, closes } = await queryMany(pool, nip66Relays, filters, { maxWaitMs: activityWaitMs });
349352
if (closes.length) console.warn('[seed] activity closes:', closes);
350353
for (const ev of events) {
351354
if (ev?.kind !== 30166) continue;
@@ -355,7 +358,6 @@ async function main() {
355358
if (created > prev) activeLastSeen.set(ev.pubkey, created);
356359
}
357360
}
358-
skipFailedRelays(pool);
359361

360362
// Sort by most recently active, but include ALL registered monitors up to cap
361363
// This ensures we get data from all monitors, not just the most active
@@ -394,12 +396,9 @@ async function main() {
394396
checkFilters.push(filter);
395397
}
396398

397-
const checkResults = await Promise.all(
398-
chunk(checkFilters, filtersPerReq).map(filters =>
399-
queryMany(pool, nip66Relays, filters, { maxWaitMs })
400-
)
401-
);
402-
for (const { events, closes } of checkResults) {
399+
for (const filters of chunk(checkFilters, filtersPerReq)) {
400+
if (checksByKey.size >= maxCheckEvents) break;
401+
const { events, closes } = await queryMany(pool, nip66Relays, filters, { maxWaitMs });
403402
if (closes.length) console.warn('[seed] checks closes:', closes);
404403
for (const ev of events) {
405404
if (ev?.kind !== 30166) continue;
@@ -413,7 +412,6 @@ async function main() {
413412
upsertNewest(checksByKey, key, ev);
414413
if (checksByKey.size >= maxCheckEvents) break;
415414
}
416-
if (checksByKey.size >= maxCheckEvents) break;
417415
}
418416

419417
const checks = Array.from(checksByKey.values()).sort(
@@ -442,7 +440,29 @@ async function main() {
442440

443441
console.log('[seed] operator pubkeys (capped)', operatorPubkeys.length);
444442

445-
// Prepare NIP-11 relay URLs (needed for step 6, can extract now)
443+
const operatorMetaByKey = new Map();
444+
for (const authors of chunk(operatorPubkeys, envNumber('SEED_OPERATOR_META_AUTHORS_PER_REQ', 50))) {
445+
const limit = Math.min(metaLimitMax, Math.max(50, authors.length * metaLimitMultiplier));
446+
const { events, closes } = await queryMany(
447+
pool,
448+
userMetaRelays,
449+
[{ kinds: [0, 10002], authors, limit }],
450+
{ maxWaitMs }
451+
);
452+
if (closes.length) console.warn('[seed] operator-meta closes:', closes);
453+
for (const ev of events) {
454+
if (ev?.kind !== 0 && ev?.kind !== 10002) continue;
455+
if (typeof ev?.pubkey !== 'string') continue;
456+
upsertNewest(operatorMetaByKey, `${ev.pubkey}:${ev.kind}`, ev);
457+
}
458+
}
459+
460+
const operatorMeta = Array.from(operatorMetaByKey.values());
461+
console.log('[seed] operator meta', operatorMeta.length);
462+
463+
// ---------------------------------------------------------------------------
464+
// 6) Fetch NIP-11 relay info documents
465+
// ---------------------------------------------------------------------------
446466
const relayUrls = new Set();
447467
for (const ev of checks) {
448468
const d = dTagValue(ev);
@@ -471,38 +491,21 @@ async function main() {
471491
}
472492
}
473493

474-
// ---------------------------------------------------------------------------
475-
// 5 + 6) Operator meta and NIP-11 fetches in parallel
476-
// ---------------------------------------------------------------------------
477-
skipFailedRelays(pool);
478-
const opMetaAuthorsPerReq = envNumber('SEED_OPERATOR_META_AUTHORS_PER_REQ', 50);
479-
480-
const [opMetaResults, nip11FetchResults] = await Promise.all([
481-
// Step 5: operator meta
482-
Promise.all(
483-
chunk(operatorPubkeys, opMetaAuthorsPerReq).map(authors => {
484-
const limit = Math.min(metaLimitMax, Math.max(50, authors.length * metaLimitMultiplier));
485-
return queryMany(pool, userMetaRelays, [{ kinds: [0, 10002], authors, limit }], { maxWaitMs });
486-
})
487-
),
488-
// Step 6: NIP-11 (all URLs concurrently)
489-
Promise.all(relayUrlsArray.map(fetchNip11)),
490-
]);
494+
const nip11Results = [];
495+
const nip11Batches = chunk(relayUrlsArray, nip11Concurrency);
496+
let nip11Progress = 0;
491497

492-
const operatorMetaByKey = new Map();
493-
for (const { events, closes } of opMetaResults) {
494-
if (closes.length) console.warn('[seed] operator-meta closes:', closes);
495-
for (const ev of events) {
496-
if (ev?.kind !== 0 && ev?.kind !== 10002) continue;
497-
if (typeof ev?.pubkey !== 'string') continue;
498-
upsertNewest(operatorMetaByKey, `${ev.pubkey}:${ev.kind}`, ev);
498+
for (const batch of nip11Batches) {
499+
const results = await Promise.all(batch.map(fetchNip11));
500+
for (const result of results) {
501+
if (result) nip11Results.push(result);
502+
}
503+
nip11Progress += batch.length;
504+
if (nip11Progress % 100 === 0 || nip11Progress === relayUrlsArray.length) {
505+
console.log(`[seed] NIP-11 progress: ${nip11Progress}/${relayUrlsArray.length} (${nip11Results.length} successful)`);
499506
}
500507
}
501508

502-
const operatorMeta = Array.from(operatorMetaByKey.values());
503-
console.log('[seed] operator meta', operatorMeta.length);
504-
505-
const nip11Results = nip11FetchResults.filter(Boolean);
506509
console.log('[seed] NIP-11s fetched', nip11Results.length);
507510

508511
// ---------------------------------------------------------------------------
@@ -595,6 +598,6 @@ scriptTimer.unref();
595598
main()
596599
.catch((err) => {
597600
console.error('[seed] failed', err);
598-
process.exitCode = 1;
601+
process.exit(1);
599602
})
600603
.finally(() => clearTimeout(scriptTimer));

apps/gui/src/lib/utils/bootstrap-render-state.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
33
import { getBootstrapRenderState } from './bootstrap-render-state';
44

55
describe('getBootstrapRenderState', () => {
6-
it('keeps first-run users in the bootstrap UI when seed finishes without data', () => {
6+
it('keeps first-run users out of the app when seed finishes without data', () => {
77
const state = getBootstrapRenderState({
88
isReady: true,
99
hasActualData: false,
@@ -16,10 +16,26 @@ describe('getBootstrapRenderState', () => {
1616
expect(state.needsSeedBootstrap).toBe(false);
1717
expect(state.loadedEnough).toBe(false);
1818
expect(state.showContent).toBe(false);
19-
expect(state.showBootstrapLoading).toBe(true);
19+
expect(state.showBootstrapLoading).toBe(false);
20+
expect(state.showFollowerWaiting).toBe(false);
2021
expect(state.navDisabled).toBe(true);
2122
});
2223

24+
it('shows the seed bootstrap checklist only while a leader needs seed bootstrap', () => {
25+
const state = getBootstrapRenderState({
26+
isReady: false,
27+
hasActualData: false,
28+
isBootstrapped: false,
29+
isSeeded: false,
30+
seedBootStatus: 'in_progress',
31+
tabState: 'leader'
32+
});
33+
34+
expect(state.needsSeedBootstrap).toBe(true);
35+
expect(state.showBootstrapLoading).toBe(true);
36+
expect(state.showContent).toBe(false);
37+
});
38+
2339
it('shows content after first-run bootstrap has actual relay-check data', () => {
2440
const state = getBootstrapRenderState({
2541
isReady: true,
@@ -48,16 +64,16 @@ describe('getBootstrapRenderState', () => {
4864

4965
expect(state.loadedEnough).toBe(true);
5066
expect(state.showContent).toBe(false);
51-
expect(state.showBootstrapLoading).toBe(true);
67+
expect(state.showBootstrapLoading).toBe(false);
5268
});
5369

54-
it('keeps follower tabs on the follower wait screen while seed import is active', () => {
70+
it('keeps follower tabs on the follower wait screen until content is ready', () => {
5571
const state = getBootstrapRenderState({
5672
isReady: true,
5773
hasActualData: false,
5874
isBootstrapped: false,
5975
isSeeded: false,
60-
seedBootStatus: 'in_progress',
76+
seedBootStatus: 'complete',
6177
tabState: 'follower'
6278
});
6379

apps/gui/src/lib/utils/bootstrap-render-state.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ export function getBootstrapRenderState(input: BootstrapRenderStateInput): Boots
2929
const loadedEnough = input.hasActualData && (!isFreshState || seedReady);
3030
const needsSeedBootstrap = isFreshState && !seedReady;
3131
const showContent = input.isReady && loadedEnough && !seedInProgress;
32-
const showFollowerWaiting = !showContent && input.tabState === 'follower' && seedInProgress;
32+
const showFollowerWaiting = !showContent && input.tabState === 'follower';
3333

3434
return {
3535
seedInProgress,
3636
seedReady,
3737
isFreshState,
3838
loadedEnough,
3939
needsSeedBootstrap,
40-
showBootstrapLoading: !showContent && !showFollowerWaiting,
40+
showBootstrapLoading: needsSeedBootstrap && input.tabState === 'leader',
4141
showFollowerWaiting,
4242
showContent,
4343
navDisabled: !loadedEnough || needsSeedBootstrap || seedInProgress

apps/gui/src/routes/+layout.svelte

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,6 @@
259259
activityManager?.destroy?.();
260260
});
261261
262-
// --------------------------------------------------------------------------------
263262
// Render gating
264263
// --------------------------------------------------------------------------------
265264
$: hasActualData = $relayCheckAggregates?.length > 0;
@@ -279,16 +278,20 @@
279278
<div class="text-xs opacity-30">This version of nostr.watch does not support mobile devices.</div>
280279
</div>
281280
{:else}
282-
<HeaderComponent navDisabled={bootstrapRenderState.navDisabled} />
283281
{#if bootstrapRenderState.showBootstrapLoading}
284282
<BootstrapLoading {isReady} />
285283
{:else if bootstrapRenderState.showFollowerWaiting}
286284
<FollowerLoading />
287285
{:else if bootstrapRenderState.showContent}
286+
<HeaderComponent navDisabled={false} />
288287
<MonitorsBanner />
289288
<div id="content-wrapper" class="block flow-root">
290289
<slot />
291290
</div>
291+
{:else}
292+
<div class="flex flex-col items-center justify-center h-screen px-4">
293+
<div class="text-7xl mb-3">booting.</div>
294+
</div>
292295
{/if}
293296
{/if}
294297

0 commit comments

Comments
 (0)