fix(feed): fall back to default relays in subscribeVideos on cold start

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-12 09:41:17 -04:00
1 parent 8850eb962a
commit 818fe16254
2 files changed
+91 -1

No files matched your search

+11 -1
View File
@@ -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
@@ -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",
);
});
});