From 88d8cda2870504340e9eaf9bd832d4d89205718a Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Sun, 19 Apr 2026 11:57:28 +0200 Subject: [PATCH] Resolve debounce race condition debounce didn't cancel the execution but only deplayed the resolution. It then resolved multiple times with the same values casing the resulting code to be called multiple times in direct succession on multiple threads. This can not only resolve into multipe DOM updates but also to race conditions. --- .../static/debug_toolbar/js/toolbar.js | 14 ++++----- .../static/debug_toolbar/js/utils.js | 27 ++++++++--------- tests/js/utils.test.js | 30 ++++++++++++++----- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index 609842209..1c4e1639e 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -294,17 +294,15 @@ const djdt = { updateOnAjax() { const sidebarUrl = document.getElementById("djDebug").dataset.sidebarUrl; - const slowjax = debounce(ajax, 200); - function handleAjaxResponse(requestId) { + const handleAjaxResponse = debounce(async (requestId) => { const encodedRequestId = encodeURIComponent(requestId); const dest = `${sidebarUrl}?request_id=${encodedRequestId}`; - slowjax(dest).then((data) => { - if (djdt.needUpdateOnFetch) { - replaceToolbarState(encodedRequestId, data); - } - }); - } + const data = await ajax(dest); + if (djdt.needUpdateOnFetch) { + replaceToolbarState(encodedRequestId, data); + } + }, 200); // Patch XHR / traditional AJAX requests const origOpen = XMLHttpRequest.prototype.open; diff --git a/debug_toolbar/static/debug_toolbar/js/utils.js b/debug_toolbar/static/debug_toolbar/js/utils.js index 9c30e906e..96cd3be8d 100644 --- a/debug_toolbar/static/debug_toolbar/js/utils.js +++ b/debug_toolbar/static/debug_toolbar/js/utils.js @@ -123,22 +123,21 @@ function replaceToolbarState(newRequestId, data) { } } -function debounce(func, delay) { - let timer = null; - let resolves = []; - +/** + * Return function that delays invoking `func` until after `timeout` elapsed. + * + * Previous calls will be dismissed if the timeout hasn't elapsed. + * + * @param {Function} func - Function to be executed. + * @param {number} timeout - Time to wait before executing function in milliseconds. + * @returns {Function} - Debounced function. + */ +export function debounce(func, timeout) { + let timer; return (...args) => { clearTimeout(timer); - timer = setTimeout(() => { - const result = func(...args); - for (const r of resolves) { - r(result); - } - resolves = []; - }, delay); - - return new Promise((r) => resolves.push(r)); + timer = setTimeout(() => Promise.try(func, ...args), timeout); }; } -export { $$, ajax, ajaxForm, debounce, replaceToolbarState }; +export { $$, ajax, ajaxForm, replaceToolbarState }; diff --git a/tests/js/utils.test.js b/tests/js/utils.test.js index 1bbc21b70..b3d96d05c 100644 --- a/tests/js/utils.test.js +++ b/tests/js/utils.test.js @@ -282,25 +282,41 @@ describe("utils.js", () => { }); describe("debounce", () => { - it("debounces function calls", async () => { + beforeEach(() => { vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it("debounces sync function calls", async () => { const fn = vi.fn((val) => val); const debounced = debounce(fn, 100); - const promise1 = debounced("first"); - const promise2 = debounced("second"); + debounced("first"); + debounced("second"); vi.advanceTimersByTime(50); - const promise3 = debounced("third"); + debounced("third"); vi.advanceTimersByTime(100); - const results = await Promise.all([promise1, promise2, promise3]); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("third"); + }); + it("debounces async function calls", async () => { + const fn = vi.fn(async (val) => val); + const debounced = debounce(fn, 100); + + debounced("first"); + debounced("second"); + + vi.advanceTimersByTime(50); + debounced("third"); + + vi.advanceTimersByTime(100); expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledWith("third"); - expect(results).toEqual(["third", "third", "third"]); - vi.useRealTimers(); }); }); });