diff --git a/src/layers/news/index.js b/src/layers/news/index.js index 1696dfd41..7992884ce 100644 --- a/src/layers/news/index.js +++ b/src/layers/news/index.js @@ -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 { diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs index 6a7a4b351..3d9b1db8f 100644 --- a/src/layers/news/layer.test.mjs +++ b/src/layers/news/layer.test.mjs @@ -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']); +}); diff --git a/src/layers/news/model.js b/src/layers/news/model.js index 62c9d360a..22bcf41aa 100644 --- a/src/layers/news/model.js +++ b/src/layers/news/model.js @@ -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 diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs index c377be6fb..899c1f3c1 100644 --- a/src/layers/news/model.test.mjs +++ b/src/layers/news/model.test.mjs @@ -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');