From f8fad78d0cb5366f02e8976c4fca848425038db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 11 May 2026 14:08:15 +0400 Subject: [PATCH] cookie-banner: keep flush entries until delivery is confirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flushSync() previously deleted pending entries unconditionally once a transport accepted the payload. That dropped data when the page was restored from bfcache after visibilitychange:hidden, and it allowed concurrent flushes to take overlapping batches. Now items are only removed from `pending` once the transport confirms delivery: synchronously for sendBeacon, in the keepalive fetch's `.then` on an `ok` response. Failed fetches leave entries queued so the next flush can retry, and a `flushing` guard prevents overlapping in-flight batches. Signed-off-by: Émile Ré --- .../src/detectors/report-queue.ts | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/cookie-banner/src/detectors/report-queue.ts b/packages/cookie-banner/src/detectors/report-queue.ts index 248d28d51..573f41681 100644 --- a/packages/cookie-banner/src/detectors/report-queue.ts +++ b/packages/cookie-banner/src/detectors/report-queue.ts @@ -238,23 +238,38 @@ export class ReportQueue { // and accept losing the tail on pages with > 100 pending items at // unload time -- still strictly better than the previous behaviour, // which lost the entire last debounce window. + // + // Items are only removed from `pending` once the transport + // confirms delivery: sendBeacon synchronously returns true when + // the browser has accepted ownership of the request, and the + // keepalive fetch removes items in its `.then` on an `ok` + // response. On async fetch failure we leave the entries queued so + // they can be retried by the next flush -- this matters when + // `visibilitychange:hidden` fires but the page is restored from + // bfcache rather than truly unloading. private flushSync(): void { + if (this.flushing) return; if (this.pending.size === 0) return; const { keys, body } = this.takeBatch(); const payload = JSON.stringify(body); - let sent = false; if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") { const blob = new Blob([payload], { type: "application/json" }); + let queued = false; try { - sent = navigator.sendBeacon(this.reportUrl.toString(), blob); + queued = navigator.sendBeacon(this.reportUrl.toString(), blob); } catch { - sent = false; + queued = false; + } + if (queued) { + for (const key of keys) this.pending.delete(key); + return; } } - if (!sent && typeof fetch === "function") { + if (typeof fetch === "function") { + this.flushing = true; try { void fetch(this.reportUrl.toString(), { method: "POST", @@ -267,17 +282,27 @@ export class ReportQueue { "X-SDK-Version": __SDK_VERSION__, }, body: payload, - }).catch(() => { - // fire-and-forget: the page is unloading, no retry possible. - }); - sent = true; + }) + .then((res) => { + if (res.ok) { + for (const key of keys) this.pending.delete(key); + } + }) + .catch(() => { + // Leave entries in `pending`; if the page is restored + // from bfcache the next flush retries them, and on a + // true unload the queue itself is discarded with the + // page. + }) + .finally(() => { + this.flushing = false; + if (!this.stopped && this.pending.size > 0) { + this.scheduleFlush(); + } + }); } catch { - sent = false; + this.flushing = false; } } - - if (sent) { - for (const key of keys) this.pending.delete(key); - } } }