From d905c9dc1bbdbdea82608ab754fbe48157856cd3 Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Thu, 24 Sep 2026 03:13:06 +0000 Subject: [PATCH 1/3] Render a feed as one unbroken stretch back from its anchor --- e2e/USER_STORIES.md | 3 ++ e2e/specs/notifications.spec.ts | 55 +++++++++++++++++++++++++++++++++ src/app/feeds.ts | 24 +++++++------- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index af65240f..e0deb03d 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1715,6 +1715,9 @@ Acceptance: drawn underneath it, and never appears as an item of its own. - Every note carries its reply count, including the ones with no replies. - Scrolling to the end of the feed loads more rather than asking her to. +- The feed is one unbroken stretch back from now. A relay that answers a page + from further back than the rest have been asked about waits for the feed to + reach it, so nothing lands below the end of the list. - The section fills from the relays that answer. One that takes the connection and then says nothing does not hold it empty. diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index dfc0cdf2..e376bf87 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -507,6 +507,61 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => expect(fromSpace).toEqual([]) }) +test("US-117 read a network feed whose relays answer from different depths", async ({seed, as}) => { + // What each relay is asked for one page at a time. A relay that fills its page has only covered + // as far back as the page reaches, so a busy one is what decides how far the feed has got. + const pageSize = 100 + const recent = "the harbourmaster moved the moorings again" + const deep = "the old crane was scrapped in the spring" + + await seed(({relay, open, user, at}) => { + const space = relay("space") + const indexer = open("indexer") + const outbox = open("outbox") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + indexer.relayList(user.alice, { + read: [space.url, indexer.url], + write: [space.url, indexer.url, outbox.url], + }) + indexer.follows(user.alice, [user.bob]) + + // Bob writes to both, so the feed asks both. His busy relay holds more than one page of the + // last couple of hours; his quiet one holds a single note from a week ago. + indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url, indexer.url]}) + outbox.profile(user.bob, {name: "Bob Barker"}) + + outbox.note(user.bob, recent, at(1, MINUTE)) + + // More than one page, so the page comes back full however the relay rounds it + for (let minute = 2; minute <= pageSize + 10; minute++) { + outbox.note(user.bob, `mooring report ${minute}`, at(minute, MINUTE)) + } + + indexer.note(user.bob, deep, at(7, DAY)) + }) + + const page = await as(users.alice, "/home") + + const network = page + .locator("section") + .filter({has: page.getByRole("heading", {name: "Network"})}) + + await expect(network.getByText(recent)).toBeVisible() + + // The quiet relay answered from a week back, which is days below where the busy one has been + // asked about. Drawing it would put a note under the end of the list with the whole week + // between still missing. + await expect(network.getByText(deep)).toHaveCount(0) + + // It is held rather than dropped, so the feed hands it over once it has paged back that far. + await network.locator(".card").last().scrollIntoViewIfNeeded() + + await expect(network.getByText(deep)).toBeVisible() +}) + test("US-117 read the network feed when one relay never answers", async ({seed, as}) => { // Nothing serves this url, and nothing needs to: the fault is a relay that takes the socket and // then says nothing, which is all the spec asks of it. diff --git a/src/app/feeds.ts b/src/app/feeds.ts index c088ff9e..fb34e557 100644 --- a/src/app/feeds.ts +++ b/src/app/feeds.ts @@ -530,8 +530,8 @@ export const makeFeed = ({ } } - // What arrives from elsewhere in the app, which is only rendered as far back as the feed has - // got to on its own + // The one door into the feed, so that what it renders is one unbroken stretch back from the + // anchor: anything older than the feed has reached waits there until the window gets to it. const addEvents = (newEvents: TrustedEvent[]) => { const ready: TrustedEvent[] = [] @@ -644,7 +644,11 @@ export const makeFeed = ({ oldest = edge === undefined ? since : Math.min(edge, until - 1) } - insertEvents(found) + // A span reaches further back than it covers. A relay holding few of these authors answers + // a page from days deeper than a busy one does, and rendering that stretch leaves a list + // whose bottom is a scatter the window then fills in above — so it waits for the window + // like anything else arriving early. + addEvents(found) reach(oldest) return {found: found.length, complete, exhausted: false} @@ -666,20 +670,14 @@ export const makeFeed = ({ newest = until } - insertEvents(found) + addEvents(found) return {found: found.length, complete, exhausted: false} } - // What the repository already holds for these relays is in hand and goes in as one insert, - // which takes the window back with it rather than leaving the rest of that stretch behind - const cached = relays.flatMap(url => Array.from(getEventsForUrl(url, filters))) - - for (const event of cached) { - reached = Math.min(reached, event.created_at) - } - - insertEvents(cached) + // What the repository already holds is a scatter rather than a stretch — an earlier visit's + // pages, a reply pulled in as context from weeks back — so it waits for the window too. + addEvents(relays.flatMap(url => Array.from(getEventsForUrl(url, filters)))) return { events, From eb9323a88bff8a8c0671ff3d1cb3d942b07e6b81 Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Thu, 24 Sep 2026 15:31:51 +0000 Subject: [PATCH 2/3] Wait for most of a span's relays, with a three second ceiling --- e2e/USER_STORIES.md | 5 ++--- e2e/specs/notifications.spec.ts | 11 +++-------- src/app/feeds.ts | 28 +++++++++++++--------------- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index e0deb03d..a529a1f1 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1715,9 +1715,8 @@ Acceptance: drawn underneath it, and never appears as an item of its own. - Every note carries its reply count, including the ones with no replies. - Scrolling to the end of the feed loads more rather than asking her to. -- The feed is one unbroken stretch back from now. A relay that answers a page - from further back than the rest have been asked about waits for the feed to - reach it, so nothing lands below the end of the list. +- The feed is one unbroken stretch back from now, so nothing appears below the + end of the list. - The section fills from the relays that answer. One that takes the connection and then says nothing does not hold it empty. diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index e376bf87..aa4822ce 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -508,8 +508,7 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => }) test("US-117 read a network feed whose relays answer from different depths", async ({seed, as}) => { - // What each relay is asked for one page at a time. A relay that fills its page has only covered - // as far back as the page reaches, so a busy one is what decides how far the feed has got. + // A relay that fills its page has covered only as far back as the page reaches. const pageSize = 100 const recent = "the harbourmaster moved the moorings again" const deep = "the old crane was scrapped in the spring" @@ -528,8 +527,7 @@ test("US-117 read a network feed whose relays answer from different depths", asy }) indexer.follows(user.alice, [user.bob]) - // Bob writes to both, so the feed asks both. His busy relay holds more than one page of the - // last couple of hours; his quiet one holds a single note from a week ago. + // Bob's busy relay holds a page of the last two hours, his quiet one a week-old note. indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url, indexer.url]}) outbox.profile(user.bob, {name: "Bob Barker"}) @@ -551,12 +549,9 @@ test("US-117 read a network feed whose relays answer from different depths", asy await expect(network.getByText(recent)).toBeVisible() - // The quiet relay answered from a week back, which is days below where the busy one has been - // asked about. Drawing it would put a note under the end of the list with the whole week - // between still missing. + // A week back is days below where the busy relay has been asked about, so it is held. await expect(network.getByText(deep)).toHaveCount(0) - // It is held rather than dropped, so the feed hands it over once it has paged back that far. await network.locator(".card").last().scrollIntoViewIfNeeded() await expect(network.getByText(deep)).toBeVisible() diff --git a/src/app/feeds.ts b/src/app/feeds.ts index fb34e557..dd6261e2 100644 --- a/src/app/feeds.ts +++ b/src/app/feeds.ts @@ -375,11 +375,15 @@ export type FeedLoadState = // nothing in it, and the two have to move the window differently. export type FeedSpan = {found: number; complete: boolean; exhausted: boolean} -// The share of the relays a span asked that have to answer before it stops waiting on the rest. A -// request otherwise waits on every one of them, and a relay that accepts a socket and then says -// nothing neither answers nor drops. A span is also what releases the events it found, so one -// silent relay leaves the feed empty rather than slow. -const SPAN_THRESHOLD = 0.5 +// How far a span covered is set by its least generous relay, and quiet relays answer first. +const SPAN_THRESHOLD = 0.8 + +// A relay that accepts a socket and then says nothing neither answers nor drops. +const SPAN_TIMEOUT = 3000 + +// Aborting a request resolves it with what arrived, the same way closing it does. +const spanSignal = (signal: AbortSignal) => + AbortSignal.any([signal, AbortSignal.timeout(SPAN_TIMEOUT)]) // Empty spans to walk per trigger. Enough to cross a gap; not enough to reach the end of the // history on a single request. @@ -530,8 +534,7 @@ export const makeFeed = ({ } } - // The one door into the feed, so that what it renders is one unbroken stretch back from the - // anchor: anything older than the feed has reached waits there until the window gets to it. + // The one door into the feed, and anything older than it has reached waits for the window. const addEvents = (newEvents: TrustedEvent[]) => { const ready: TrustedEvent[] = [] @@ -599,7 +602,7 @@ export const makeFeed = ({ const found = await network.get().request({ relays, autoClose: true, - signal: controller.signal, + signal: spanSignal(controller.signal), threshold: SPAN_THRESHOLD, filters: filters.map(filter => ({...filter, ...extension})), onEvent: countEvent, @@ -644,10 +647,7 @@ export const makeFeed = ({ oldest = edge === undefined ? since : Math.min(edge, until - 1) } - // A span reaches further back than it covers. A relay holding few of these authors answers - // a page from days deeper than a busy one does, and rendering that stretch leaves a list - // whose bottom is a scatter the window then fills in above — so it waits for the window - // like anything else arriving early. + // A span reaches further back than it covers, so its deepest events wait for the window. addEvents(found) reach(oldest) @@ -675,8 +675,6 @@ export const makeFeed = ({ return {found: found.length, complete, exhausted: false} } - // What the repository already holds is a scatter rather than a stretch — an earlier visit's - // pages, a reply pulled in as context from weeks back — so it waits for the window too. addEvents(relays.flatMap(url => Array.from(getEventsForUrl(url, filters)))) return { @@ -773,7 +771,7 @@ export const makeCalendarFeed = ({ const found = await network.get().request({ relays, autoClose: true, - signal: controller.signal, + signal: spanSignal(controller.signal), threshold: SPAN_THRESHOLD, filters: [{kinds: [EVENT_TIME], "#D": daysBetween(since, until).map(String)}], onEose: () => { From d32f96f976214414f41fee1cec59b10b196a3944 Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Thu, 24 Sep 2026 17:29:12 +0000 Subject: [PATCH 3/3] Cut the metaphors out of the comments this branch added --- e2e/specs/notifications.spec.ts | 8 ++++---- src/app/feeds.ts | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index aa4822ce..239d8801 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -508,7 +508,7 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => }) test("US-117 read a network feed whose relays answer from different depths", async ({seed, as}) => { - // A relay that fills its page has covered only as far back as the page reaches. + // Mirrors PAGE_SIZE: a full page covers only as far back as it reaches. const pageSize = 100 const recent = "the harbourmaster moved the moorings again" const deep = "the old crane was scrapped in the spring" @@ -527,13 +527,13 @@ test("US-117 read a network feed whose relays answer from different depths", asy }) indexer.follows(user.alice, [user.bob]) - // Bob's busy relay holds a page of the last two hours, his quiet one a week-old note. + // Bob's outbox holds a full page of recent notes, the indexer one week-old note. indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url, indexer.url]}) outbox.profile(user.bob, {name: "Bob Barker"}) outbox.note(user.bob, recent, at(1, MINUTE)) - // More than one page, so the page comes back full however the relay rounds it + // More than pageSize so the page comes back full. for (let minute = 2; minute <= pageSize + 10; minute++) { outbox.note(user.bob, `mooring report ${minute}`, at(minute, MINUTE)) } @@ -549,7 +549,7 @@ test("US-117 read a network feed whose relays answer from different depths", asy await expect(network.getByText(recent)).toBeVisible() - // A week back is days below where the busy relay has been asked about, so it is held. + // Below the stretch the outbox covered, so it is held until the scroll. await expect(network.getByText(deep)).toHaveCount(0) await network.locator(".card").last().scrollIntoViewIfNeeded() diff --git a/src/app/feeds.ts b/src/app/feeds.ts index dd6261e2..ac28d8c5 100644 --- a/src/app/feeds.ts +++ b/src/app/feeds.ts @@ -375,13 +375,13 @@ export type FeedLoadState = // nothing in it, and the two have to move the window differently. export type FeedSpan = {found: number; complete: boolean; exhausted: boolean} -// How far a span covered is set by its least generous relay, and quiet relays answer first. +// Relatively high because quiet relays eose first. const SPAN_THRESHOLD = 0.8 -// A relay that accepts a socket and then says nothing neither answers nor drops. +// Short because a relay can accept a socket and never eose. const SPAN_TIMEOUT = 3000 -// Aborting a request resolves it with what arrived, the same way closing it does. +// Abort resolves a request with what arrived. const spanSignal = (signal: AbortSignal) => AbortSignal.any([signal, AbortSignal.timeout(SPAN_TIMEOUT)]) @@ -534,7 +534,6 @@ export const makeFeed = ({ } } - // The one door into the feed, and anything older than it has reached waits for the window. const addEvents = (newEvents: TrustedEvent[]) => { const ready: TrustedEvent[] = [] @@ -647,7 +646,7 @@ export const makeFeed = ({ oldest = edge === undefined ? since : Math.min(edge, until - 1) } - // A span reaches further back than it covers, so its deepest events wait for the window. + // addEvents, not insertEvents: found can reach below oldest. addEvents(found) reach(oldest)