From a358a86a05611451c77c0f7a844ad31a9ab13dd8 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Sun, 19 Apr 2026 11:57:28 +0200 Subject: [PATCH 1/5] 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 | 31 ++++++++----------- .../static/debug_toolbar/js/utils.js | 27 ++++++++-------- docs/changes.rst | 2 ++ tests/js/utils.test.js | 30 +++++++++++++----- 4 files changed, 51 insertions(+), 39 deletions(-) diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index 609842209..1a9bc12d6 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -292,20 +292,23 @@ const djdt = { window.removeEventListener("resize", djdt.ensureHandleVisibility); }, updateOnAjax() { - const sidebarUrl = - document.getElementById("djDebug").dataset.sidebarUrl; - const slowjax = debounce(ajax, 200); + const handleAjaxResponse = debounce(async (requestId) => { + const sidebarUrl = + document.getElementById("djDebug").dataset.sidebarUrl; - function handleAjaxResponse(requestId) { const encodedRequestId = encodeURIComponent(requestId); const dest = `${sidebarUrl}?request_id=${encodedRequestId}`; - slowjax(dest).then((data) => { - if (djdt.needUpdateOnFetch) { + if (djdt.needUpdateOnFetch) { + try { + const data = await ajax(dest); replaceToolbarState(encodedRequestId, data); + } catch (error) { + throw new Error( + `"${error.name}" occurred within django-debug-toolbar: ${error.message}` + ); } - }); - } - + } + }, 200); // Patch XHR / traditional AJAX requests const origOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (...args) { @@ -333,15 +336,7 @@ const djdt = { const promise = origFetch.apply(this, args); return promise.then((response) => { if (response.headers.get("djdt-request-id") !== null) { - try { - handleAjaxResponse( - response.headers.get("djdt-request-id") - ); - } catch (err) { - throw new Error( - `"${err.name}" occurred within django-debug-toolbar: ${err.message}` - ); - } + handleAjaxResponse(response.headers.get("djdt-request-id")); } return response; }); 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/docs/changes.rst b/docs/changes.rst index e5726591e..d0af0a76a 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,8 @@ Change log Pending ------- * Prevent check from failing when ``ROOT_URLCONF`` is not defined. +* Prevent debounce race condition in the history panel for rapid + fetch requests. 6.3.0 (2026-04-01) ------------------ 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(); }); }); }); From 3147022a6b7ed66c453ec99c829d951aa1c13983 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Tue, 21 Apr 2026 14:50:27 +0200 Subject: [PATCH 2/5] Update debug_toolbar/static/debug_toolbar/js/toolbar.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- debug_toolbar/static/debug_toolbar/js/toolbar.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index 1a9bc12d6..959922f92 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -303,8 +303,9 @@ const djdt = { const data = await ajax(dest); replaceToolbarState(encodedRequestId, data); } catch (error) { - throw new Error( - `"${error.name}" occurred within django-debug-toolbar: ${error.message}` + console.error( + `"${error.name}" occurred within django-debug-toolbar: ${error.message}`, + error ); } } From 89339bc7cd5c712eb4a016a756d57900066a180b Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Tue, 21 Apr 2026 14:55:42 +0200 Subject: [PATCH 3/5] Fix typo --- docs/changes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index d0af0a76a..2a49c3c35 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,7 +4,7 @@ Change log Pending ------- * Prevent check from failing when ``ROOT_URLCONF`` is not defined. -* Prevent debounce race condition in the history panel for rapid +* Prevent debounce race conditions in the history panel for rapid fetch requests. 6.3.0 (2026-04-01) From 679bd184d862edb1c7ff4efa71faa33d397e4fa3 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Tue, 21 Apr 2026 15:21:41 +0200 Subject: [PATCH 4/5] Add debounce to wordlist --- docs/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 9396fea41..118ee98f5 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -17,6 +17,7 @@ biome checkbox contrib csp +debounce deduplicated dicts django From 1d5a7e22385c7b3ce0ede5424582e0b2c87dd6c4 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Tue, 21 Apr 2026 15:25:14 +0200 Subject: [PATCH 5/5] Drop redundant assertion --- tests/js/utils.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/js/utils.test.js b/tests/js/utils.test.js index b3d96d05c..ff458a5eb 100644 --- a/tests/js/utils.test.js +++ b/tests/js/utils.test.js @@ -315,7 +315,6 @@ describe("utils.js", () => { vi.advanceTimersByTime(100); - expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledWith("third"); }); });