From 818fe16254c8125684db3a7124cb53b1b74eabbe Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:41:17 -0400 Subject: [PATCH] fix(feed): fall back to default relays in subscribeVideos on cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subscribeVideos called pool.sub(getHealthyRelays(this.relays)) with no empty-relay fallback, unlike the fetch paths in the same file. On a cold first load the 5s relay connect probe can time out, marking every relay unreachable; init() only warns and schedules a background reconnect (it does not throw), so boot proceeds and the feed subscribes to ZERO relays. The background reconnect later succeeds but nothing re-issues the subscription, so the feed stays empty until the user refreshes (when the SWR cache renders) — the reported "notes don't load on first visit / need a refresh" symptom. Fall back to DEFAULT_RELAY_URLS when no relays are healthy, mirroring the established pattern. Because the fallback bypasses the unreachable cooldown, the subscription retries those relays directly and self-heals instead of staying dead until refresh. Adds tests/nostr/subscribeVideosRelayFallback.test.mjs: empty healthy set => default relays (never zero), populated healthy set => uses those relays. Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 12 ++- .../subscribeVideosRelayFallback.test.mjs | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/nostr/subscribeVideosRelayFallback.test.mjs diff --git a/js/nostr/client.js b/js/nostr/client.js index 34948981..ec0473e0 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -3321,7 +3321,17 @@ export class NostrClient { devLogger.log("[subscribeVideos] Subscribing with filter:", filter); - const sub = this.pool.sub(this.getHealthyRelays(this.relays), [filter]); + // Mirror the empty-relay fallback used by the fetch paths above: if no relays + // are currently healthy (e.g. the initial 5s connect probe timed out on a + // cold first load), fall back to the default relay set instead of subscribing + // to ZERO relays. A zero-relay subscription silently never delivers events + // and never self-heals when relays reconnect in the background, which forces + // the user to refresh the page before the feed loads anything. + const healthyRelays = sanitizeRelayList(this.getHealthyRelays(this.relays)); + const relaysToUse = healthyRelays.length + ? healthyRelays + : Array.from(DEFAULT_RELAY_URLS); + const sub = this.pool.sub(relaysToUse, [filter]); // BUFFERING STATE // We collect events here instead of processing them instantly to avoid diff --git a/tests/nostr/subscribeVideosRelayFallback.test.mjs b/tests/nostr/subscribeVideosRelayFallback.test.mjs new file mode 100644 index 00000000..3d87a2ed --- /dev/null +++ b/tests/nostr/subscribeVideosRelayFallback.test.mjs @@ -0,0 +1,80 @@ +// Regression test for the first-load feed race. +// +// Scenario (SCN-subscribe-videos-empty-relay-fallback): +// Given the initial relay connect probe timed out on a cold first load, so no +// relays are currently marked healthy, +// When the feed subscription is created via subscribeVideos, +// Then it must subscribe to the default relay set (not an empty relay list), +// because a zero-relay subscription silently never delivers events and never +// self-heals when relays reconnect — forcing the user to refresh the page. +// +// Observable outcome asserted at the boundary: the relay list passed to +// pool.sub(). + +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { NostrClient } from "../../js/nostr/client.js"; + +if (!globalThis.WebSocket) { + globalThis.WebSocket = class MockWebSocket {}; +} + +describe("subscribeVideos relay fallback", () => { + let client; + let mockPool; + let subRelayArgs; + + beforeEach(() => { + client = new NostrClient(); + subRelayArgs = null; + mockPool = { + sub: mock.fn((relays) => { + subRelayArgs = relays; + return { on: mock.fn(), unsub: mock.fn() }; + }), + }; + client.pool = mockPool; + client.isInitialized = true; + client.relays = ["wss://relay.example.com", "wss://relay2.example.com"]; + }); + + afterEach(() => { + mock.reset(); + }); + + it("falls back to the default relay set when no relays are healthy", () => { + // Simulate the cold-start race: the connect probe timed out, so every relay + // is currently filtered out as unhealthy. + client.getHealthyRelays = () => []; + + client.subscribeVideos(() => {}); + + assert.equal(mockPool.sub.mock.callCount(), 1); + assert.ok(Array.isArray(subRelayArgs), "pool.sub must receive a relay array"); + assert.ok( + subRelayArgs.length > 0, + "must never subscribe to ZERO relays when healthy relays are empty", + ); + for (const url of subRelayArgs) { + assert.match( + url, + /^wss?:\/\//, + `fallback relay should be a websocket URL, got ${url}`, + ); + } + }); + + it("uses the healthy relays when they are available", () => { + const healthy = ["wss://relay.example.com"]; + client.getHealthyRelays = () => healthy; + + client.subscribeVideos(() => {}); + + assert.equal(mockPool.sub.mock.callCount(), 1); + assert.deepEqual( + subRelayArgs, + healthy, + "should subscribe using the healthy relays, not the default fallback", + ); + }); +});