diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index 609842209..959922f92 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -292,20 +292,24 @@ 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) { + console.error( + `"${error.name}" occurred within django-debug-toolbar: ${error.message}`, + error + ); } - }); - } - + } + }, 200); // Patch XHR / traditional AJAX requests const origOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (...args) { @@ -333,15 +337,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..2a49c3c35 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 conditions in the history panel for rapid + fetch requests. 6.3.0 (2026-04-01) ------------------ 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 diff --git a/tests/js/utils.test.js b/tests/js/utils.test.js index 1bbc21b70..ff458a5eb 100644 --- a/tests/js/utils.test.js +++ b/tests/js/utils.test.js @@ -282,25 +282,40 @@ 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"); - expect(results).toEqual(["third", "third", "third"]); - vi.useRealTimers(); + }); + 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).toHaveBeenCalledWith("third"); }); }); });