diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index a51f678..6783188 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -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