Fix sliding window silently stalling under multi-filter URL hashes
Restoring several filters at once from the URL hash (client + type) fired several concurrent loadOlder()/loadNewer()/ensureScrollable() chains. They raced on the loadingWindow guard, which silently dropped a call arriving mid-flight instead of queuing it -- indistinguishable from "nothing more to load", so the sliding window gave up expanding after one round even when the table was still far from scrollable, with no scroll events left to ever retry it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -409,18 +409,11 @@
|
||||
// Fenêtre glissante.
|
||||
windowStart: null,
|
||||
windowEnd: null,
|
||||
// Drive the sentinel-row "Chargement…" text only -- actual
|
||||
// serialization is the `_windowOpChain` queue in
|
||||
// _queueWindowOp(), not these (see loadOlder()/loadNewer()).
|
||||
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,
|
||||
@@ -1083,14 +1076,49 @@
|
||||
this.error = err.message;
|
||||
}
|
||||
},
|
||||
async loadOlder() {
|
||||
// Public entry points queue onto a shared chain (see _queueWindowOp)
|
||||
// instead of running immediately -- see that method for why a
|
||||
// "drop if busy" guard used to sit here and why it had to go.
|
||||
loadOlder() {
|
||||
return this._queueWindowOp(() => this._loadOlderNow());
|
||||
},
|
||||
loadNewer() {
|
||||
return this._queueWindowOp(() => this._loadNewerNow());
|
||||
},
|
||||
// Both loadOlder()/loadNewer() 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 --
|
||||
// interleaving them races on that shared state (observed earlier
|
||||
// this session as `rows` transiently emptying out). That used to be
|
||||
// guarded by a `loadingWindow` boolean that made a call arriving
|
||||
// while another was in flight a silent no-op -- but a no-op is
|
||||
// indistinguishable from "there's nothing more to load", which is
|
||||
// exactly the signal ensureScrollable() uses to stop recursing. In
|
||||
// practice that meant several filters restored from the URL hash at
|
||||
// once (mounted() sets filterCompte/filterClient/filterType as
|
||||
// three separate reactive writes, each firing its own watcher) could
|
||||
// each kick off loadOlder()/loadNewer(), have all but one silently
|
||||
// dropped by the guard, and have ensureScrollable() conclude "window
|
||||
// didn't change" and give up after a single round -- even though the
|
||||
// table was nowhere near scrollable yet, and with nothing left to
|
||||
// ever retry (no scrollbar, no more scroll events). Queuing instead
|
||||
// of dropping means every call still eventually runs, in order, once
|
||||
// whatever's ahead of it in the queue finishes.
|
||||
_queueWindowOp(fn) {
|
||||
const run = () => fn().catch((err) => { this.error = err.message; });
|
||||
this._windowOpChain = (this._windowOpChain || Promise.resolve()).then(run, run);
|
||||
return this._windowOpChain;
|
||||
},
|
||||
async _loadOlderNow() {
|
||||
// Bypassed while a specific "Année" filter is active -- that mode
|
||||
// loads exactly one year and nothing else (see enterYearMode).
|
||||
// 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;
|
||||
// an empty request on every subsequent scroll/layout tick. Checked
|
||||
// here (at actual run time, not call time) since by the time this
|
||||
// reaches the front of the queue, windowStart may have moved.
|
||||
if (!this.windowStart || this.filterYear || this.windowStart <= MIN_LOADABLE_DATE) return;
|
||||
this.loadingOlder = true;
|
||||
try {
|
||||
let newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
|
||||
@@ -1121,14 +1149,12 @@
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loadingOlder = false;
|
||||
this.loadingWindow = false;
|
||||
}
|
||||
},
|
||||
async loadNewer() {
|
||||
// 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;
|
||||
async _loadNewerNow() {
|
||||
// Same MIN_LOADABLE_DATE reasoning as _loadOlderNow(), mirrored at
|
||||
// the future end.
|
||||
if (!this.windowEnd || this.filterYear || this.windowEnd >= MAX_LOADABLE_DATE) return;
|
||||
this.loadingNewer = true;
|
||||
try {
|
||||
let newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
|
||||
@@ -1159,7 +1185,6 @@
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.loadingNewer = false;
|
||||
this.loadingWindow = false;
|
||||
}
|
||||
},
|
||||
// Scroll-position based rather than IntersectionObserver: one
|
||||
@@ -1206,7 +1231,38 @@
|
||||
// 2025-02-28, because setMonth(1) overflows February), so even with
|
||||
// the widened cap above, "no change since last round" isn't a
|
||||
// guaranteed way to detect "there's genuinely nothing more to find".
|
||||
async ensureScrollable(round = 0) {
|
||||
//
|
||||
// Public entry point is serialized: mounted() restoring several
|
||||
// filters from the URL hash (filterCompte/filterClient/filterType)
|
||||
// assigns each as its own reactive property write, so each fires
|
||||
// its own watcher and its own independent ensureScrollable(0) call
|
||||
// in the very same tick. Without serializing, those chains race on
|
||||
// loadOlder()/loadNewer()'s shared `loadingWindow` guard -- a call
|
||||
// that lands while another is already in flight just no-ops
|
||||
// immediately, so a chain can see "window didn't change" and give
|
||||
// up after one round even though the table is nowhere near
|
||||
// scrollable yet, and nothing is left afterward to ever retry
|
||||
// (there's no scrollbar to generate the scroll events checkEdges()
|
||||
// would otherwise rely on). Queuing instead of racing means the
|
||||
// first call runs its full recursive loop uninterrupted, and any
|
||||
// calls that arrived while it was busy collapse into a single
|
||||
// follow-up run once it's done (covering the final filter state,
|
||||
// cheap/no-op if that first run already converged).
|
||||
ensureScrollable() {
|
||||
if (this._ensureScrollableRunning) {
|
||||
this._ensureScrollablePending = true;
|
||||
return this._ensureScrollableRunning;
|
||||
}
|
||||
this._ensureScrollableRunning = this._ensureScrollableLoop(0).finally(() => {
|
||||
this._ensureScrollableRunning = null;
|
||||
if (this._ensureScrollablePending) {
|
||||
this._ensureScrollablePending = false;
|
||||
this.ensureScrollable();
|
||||
}
|
||||
});
|
||||
return this._ensureScrollableRunning;
|
||||
},
|
||||
async _ensureScrollableLoop(round) {
|
||||
if (this.filterYear || round >= MAX_ENSURE_SCROLLABLE_ROUNDS) return;
|
||||
const wrap = this.$refs.tableWrap;
|
||||
if (!wrap) return;
|
||||
@@ -1217,7 +1273,7 @@
|
||||
await this.loadOlder();
|
||||
await this.loadNewer();
|
||||
if (this.windowStart === beforeStart && this.windowEnd === beforeEnd) return;
|
||||
await this.ensureScrollable(round + 1);
|
||||
await this._ensureScrollableLoop(round + 1);
|
||||
},
|
||||
// Which year is "at the top" of the visible area right now (just
|
||||
// under the sticky thead) -- drives the totals footer. Works
|
||||
|
||||
Reference in New Issue
Block a user