fix(news): keep the feed cursor in step with the list and drop Local reads for an area left

Co-Authored-By: Claude Fable 5.2 <[email protected]>
This commit is contained in:
Mazeyar Moeini FeizabadiandClaude Fable 5.2 committed 2026-11-10 21:26:48 +01:00
1 parent 7cd2af46e4
commit fd9373c09c
4 files changed
+142 -6

No files matched your search

+15 -6
View File
@@ -190,6 +190,7 @@ export function createNewsLayer({
request = null,
feedRequest = null,
inViewRequest = null,
inViewRequestKey = null,
areaRequest = null,
settleTimer = null,
pulseTimer = null,
@@ -958,7 +959,9 @@ export function createNewsLayer({
// clears it, not only the next refresh.
needs = { ...needs, conflicts: null };
feed = {
...mergeFeedPage(feed, page, mode, now(), undefined, windowMs),
// Kept for the server's whole window (the shown one filters the
// view), so the rows between the head and the cursor are never lost.
...mergeFeedPage(feed, page, mode, now(), undefined, maxWindowMs),
loading: false,
error: null,
};
@@ -1039,18 +1042,24 @@ export function createNewsLayer({
// The section changed, or the layer went off, while the area was read.
if (!local() || !enabled) return;
const readKey = inView.area?.key ?? '';
if (
!force &&
inView.readKey === readKey &&
(inView.minor || inViewRequest)
) {
const held = inView.readKey === readKey && inView.minor;
if (!force && (inViewRequest ? inViewRequestKey === readKey : held)) {
render();
notify();
return;
}
// A read still out for another area must not land over this one.
inViewRequest?.abort();
if (!force && held) {
inViewRequest = null;
inView = { ...inView, loading: false };
render();
notify();
return;
}
const controller = new AbortController();
inViewRequest = controller;
inViewRequestKey = readKey;
inView = { ...inView, loading: true };
notify();
try {
+104
View File
@@ -852,3 +852,107 @@ test('a failed read keeps the layer on with an error; disable clears the overlay
assert.deepEqual(entries.at(-1), []);
assert.deepEqual(layer.getRowControls().feed.items, []);
});
test('narrowing the window and widening it again loses no report already read', async (t) => {
const { layer } = fixture(t, {
source: {
// Two pages: the head page, and an older one behind its cursor.
getFeed: async ({ before } = {}) =>
before
? {
reports: [
report('earlier', { publishedAt: '2026-10-28T05:00:00.000Z' }),
],
nextBefore: null,
}
: {
reports: [
report('recent', { publishedAt: '2026-10-28T09:30:00.000Z' }),
],
nextBefore: '2026-10-28T09:30:00.000Z',
},
},
});
const shown = () => layer.getRowControls().feed.items.map((item) => item.id);
layer.enable();
await layer.update();
for (let i = 0; i < 3; i++) await settle();
assert.deepEqual(shown(), ['recent', 'earlier']);
layer.setParams({ filters: { hours: 1 } });
for (let i = 0; i < 3; i++) await settle();
assert.deepEqual(shown(), ['recent']);
layer.setParams({ filters: { hours: 24 } });
for (let i = 0; i < 3; i++) await settle();
assert.deepEqual(shown(), ['recent', 'earlier']);
});
test("a slower Local read for the area just left never replaces the current area's reports", async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let centre = { lat: 49, lon: 36 };
const pending = [];
const russia = {
area: { key: 'RU', name: 'Russia', kind: 'country', country: 'RU' },
polygons: [
{
outer: [
[38, 48],
[40, 48],
[40, 50.5],
[38, 50.5],
],
holes: [],
},
],
borders: [],
disputed: [],
};
const { layer, camera } = fixture(t, {
height: 1_000_000,
centre: () => centre,
source: {
getAreaOutline: async (point) => (point.lon < 37.5 ? ukraine : russia),
getFeed: (query) => {
if (!(query.relevance === '' && query.limit === 500))
return Promise.resolve({ reports: [], nextBefore: null });
if (query.at.lon < 37.5)
return Promise.resolve({
reports: [report('kharkiv', { severity: 1 })],
nextBefore: null,
});
// Russia's read is slow.
return new Promise((resolve) => pending.push(resolve));
},
},
});
const look = async (at) => {
centre = at;
camera.moveEnd.raiseEvent();
t.mock.timers.tick(800);
await flush();
};
const shown = () =>
layer.getRowControls().feed.inView.items.map((item) => item.id);
layer.enable();
await layer.update();
await flush();
layer.setParams({ filters: { section: 'local' } });
await flush();
assert.deepEqual(shown(), ['kharkiv']);
await look({ lat: 49, lon: 39 });
assert.equal(pending.length, 1);
await look({ lat: 49, lon: 36 });
pending[0]({
reports: [
report('belgorod', {
severity: 1,
pins: [{ lat: 49, lon: 39, place: 'Rossosh', mappable: true }],
}),
],
nextBefore: null,
});
await flush();
const view = layer.getRowControls().feed;
assert.equal(view.inView.area, 'Ukraine');
assert.equal(view.inView.loading, false);
assert.deepEqual(shown(), ['kharkiv']);
});
+6
View File
@@ -617,6 +617,8 @@ export function reportMarkerPosition(report, pin = 0) {
* only the window and at most `max` rows (a long session does not grow
* the list). `mode` 'head' refreshes the top and keeps the older cursor;
* 'append' adds an older page and takes its cursor; 'replace' starts over.
* A head page that has older pages behind it and shares no row with the list
* left a gap (more than a page arrived since), so it starts over too.
*/
export function mergeFeedPage(
feed,
@@ -627,6 +629,10 @@ export function mergeFeedPage(
windowMs = NEWS_WINDOW_MS,
) {
const byId = new Map();
if (mode === 'head' && page.nextBefore && feed.items.length) {
const held = new Set(feed.items.map((item) => item.id));
if (!page.reports.some((item) => held.has(item.id))) mode = 'replace';
}
const items =
mode === 'replace'
? page.reports
+17
View File
@@ -318,6 +318,23 @@ test('feed pages merge newest first without duplicates', () => {
);
});
test('a head page that does not reach the rows held starts the list over', () => {
const held = report('h', '2026-10-28T06:00:00Z');
const fresh = report('n', '2026-10-28T09:30:00Z');
// More than a page arrived since the last refresh (a suspended tab): the
// reports between this page and the rows held are behind its cursor.
const feed = mergeFeedPage(
{ items: [held], nextBefore: 'OLD' },
{ reports: [fresh], nextBefore: 'NEW' },
'head',
NOW,
);
assert.deepEqual(
[feed.items.map((r) => r.id), feed.nextBefore],
[['n'], 'NEW'],
);
});
test('the feed list keeps only the last 24 hours and at most its cap', () => {
const fresh = report('f', '2026-10-28T09:00:00Z');
const stale = report('s', '2026-10-27T09:00:00Z');