Fix runaway scroll-loading loop on the grand livre

Two compounding issues in the sliding-window edge loading:

- loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward
  even when a fetch came back empty (e.g. extending into future dates
  with no data yet). Since nothing about the table's height or scroll
  position changes when 0 rows come back, checkEdges() stayed
  satisfied and the very next scroll/layout tick fired the same load
  again, drifting the window further out forever. Now bounded by
  MIN_LOADABLE_DATE/MAX_LOADABLE_DATE.
- loadOlder() and loadNewer() had independent in-flight guards and
  could run concurrently, racing on the same rows/windowStart/windowEnd
  state -- a scroll-position compensation write inside one (from
  trimming the far end) fires a real scroll event that can kick off
  the other direction mid-flight. Now serialized behind a shared
  loadingWindow lock.
This commit is contained in:
2026-09-04 21:12:20 +02:00
parent 9e489b48e6
commit 831c0a0e62
@@ -40,6 +40,18 @@
const EXTEND_MONTHS = 6; // increment per scroll-triggered load
const MAX_LOADED_MONTHS = 30; // trim the far end once the window exceeds this
// Absolute bounds on how far the window can extend. Without these,
// loadOlder()/loadNewer() kept pushing windowStart/windowEnd outward
// even when a fetch came back empty (e.g. extending into future dates
// with no data yet) -- since nothing about the table's height or
// scroll position changes when 0 rows come back, checkEdges() stayed
// satisfied and the next scroll/layout tick fired the exact same load
// again, drifting further out forever with no way to stop. Comfortably
// before the earliest migrated year (2021) and well past any plausible
// future-dated entry.
const MIN_LOADABLE_DATE = '2020-06-01';
const MAX_LOADABLE_DATE = addMonths(todayStr(), 24);
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
@@ -183,6 +195,16 @@
windowEnd: null,
loadingOlder: false,
loadingNewer: false,
// True while EITHER loadOlder() or loadNewer() is running -- they
// both mutate `rows`/windowStart/windowEnd, and a scroll-position
// compensation write inside one (from trimming the far end) fires
// a real 'scroll' event that can kick off the other direction
// while the first is still in flight. Without a shared lock, the
// two interleave and race on the same state -- observed as `rows`
// transiently emptying out (table blinks away) before settling.
// loadingOlder/loadingNewer stay separate for the sentinel-row
// text; this gates actual execution.
loadingWindow: false,
// Footer : totaux de l'année actuellement visible à l'écran (pas
// de la fenêtre chargée), voir detectCurrentYear().
currentYear: null,
@@ -434,10 +456,15 @@
async loadOlder() {
// Bypassed while a specific "Année" filter is active -- that mode
// loads exactly one year and nothing else (see enterYearMode).
if (this.loadingOlder || !this.windowStart || this.filterYear) return;
// Also bypassed once the window already reaches MIN_LOADABLE_DATE --
// nothing older to fetch, so skip straight out instead of re-firing
// an empty request on every subsequent scroll/layout tick.
if (this.loadingWindow || !this.windowStart || this.filterYear || this.windowStart <= MIN_LOADABLE_DATE) return;
this.loadingWindow = true;
this.loadingOlder = true;
try {
const newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
let newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
if (newStart < MIN_LOADABLE_DATE) newStart = MIN_LOADABLE_DATE;
const { data, includedMap } = await fetchLignes(rangeUrl(newStart, this.windowStart));
const newRows = buildRows(data, includedMap);
this.windowStart = newStart;
@@ -464,13 +491,18 @@
this.error = err.message;
} finally {
this.loadingOlder = false;
this.loadingWindow = false;
}
},
async loadNewer() {
if (this.loadingNewer || !this.windowEnd || this.filterYear) return;
// Same MIN_LOADABLE_DATE reasoning as loadOlder(), mirrored at the
// future end.
if (this.loadingWindow || !this.windowEnd || this.filterYear || this.windowEnd >= MAX_LOADABLE_DATE) return;
this.loadingWindow = true;
this.loadingNewer = true;
try {
const newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
if (newEnd > MAX_LOADABLE_DATE) newEnd = MAX_LOADABLE_DATE;
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowEnd, newEnd));
const newRows = buildRows(data, includedMap);
this.windowEnd = newEnd;
@@ -497,6 +529,7 @@
this.error = err.message;
} finally {
this.loadingNewer = false;
this.loadingWindow = false;
}
},
// Scroll-position based rather than IntersectionObserver: one