From 3f8b28e015f61c3635a1b0a9531e788990e24da6 Mon Sep 17 00:00:00 2001 From: Will <20273409+novucs@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:02:49 +0100 Subject: [PATCH 1/2] Defer the toolbar handle position calculation to an animation frame ensureHandleVisibility() reads handle.offsetWidth during init(), directly after $$.show(handle) takes the handle from display:none. Layout is dirty at that point, so the read forces a synchronous document-wide style and layout flush after the page has already painted. On projects with a large stylesheet this is slow enough to be visible as a flash of unstyled content in Firefox. It only affects a collapsed toolbar, since showToolbar() does not call ensureHandleVisibility(). Deferring the body to requestAnimationFrame moves the read past the paint. Behaviour is otherwise unchanged, and the resize listener benefits too, as repeated events now coalesce to at most one reflow per frame. --- .../static/debug_toolbar/js/toolbar.js | 18 ++++++++++-------- docs/changes.rst | 4 ++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index d9c34c422..b62a4422f 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -247,14 +247,16 @@ const djdt = { } }, ensureHandleVisibility() { - const djDebug = getDebugElement(); - const handle = djDebug.querySelector("#djDebugToolbarHandle"); - // set handle position - const handleTop = Math.min( - localStorage.getItem("djdt.top") || 265, - globalThis.innerHeight - handle.offsetWidth - ); - handle.style.top = `${handleTop}px`; + requestAnimationFrame(() => { + const djDebug = getDebugElement(); + const handle = djDebug.querySelector("#djDebugToolbarHandle"); + // set handle position + const handleTop = Math.min( + localStorage.getItem("djdt.top") || 265, + globalThis.innerHeight - handle.offsetWidth + ); + handle.style.top = `${handleTop}px`; + }); }, hideToolbar() { const djDebug = getDebugElement(); diff --git a/docs/changes.rst b/docs/changes.rst index 6c7c6f2b5..9ee104c6f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -9,6 +9,10 @@ Pending older versions of Django, the panel explains that upgrading is required. * Fixed the Django version check in the SQL panel test suite for Django's boolean parameter handling. +* Deferred the toolbar handle's position calculation to an animation frame, + so it no longer forces a synchronous layout during initialization. On + pages with a large amount of CSS this could briefly show the page before + its styles were applied. 7.0.0 (2026-06-17) ------------------ From 6378f613bc02f1bd2835a4e1542b9997cfd4c52e Mon Sep 17 00:00:00 2001 From: Will <20273409+novucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:10:39 +0100 Subject: [PATCH 2/2] Add a regression test for the handle position reflow The fixture serves its stylesheet from a deliberately slow view and records, from an animation frame registered ahead of the link, whether that sheet had applied. Firefox withholds animation frames while a stylesheet is pending, so a first frame that sees unstyled content means something forced layout and lifted rendering suppression early. The toolbar has to be collapsed for hideToolbar() to reach ensureHandleVisibility(), and localStorage is cleared around the test because djdt.show takes precedence over SHOW_COLLAPSED and persists across tests in the shared driver. Reverting the requestAnimationFrame wrapper fails the test 10 times out of 10, and restoring it passes 10 out of 10. The check is specific to Firefox: Chrome does not lift render blocking on a forced layout, so the unpatched code does not flash there. --- .../templates/flash_of_unstyled_content.html | 14 +++++++++++ tests/test_integration.py | 23 +++++++++++++++++++ tests/urls.py | 9 ++++++++ tests/views.py | 14 ++++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/templates/flash_of_unstyled_content.html diff --git a/tests/templates/flash_of_unstyled_content.html b/tests/templates/flash_of_unstyled_content.html new file mode 100644 index 000000000..3f7505782 --- /dev/null +++ b/tests/templates/flash_of_unstyled_content.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block head %} + + +{% endblock head %} diff --git a/tests/test_integration.py b/tests/test_integration.py index 6a7dbf502..32e41e699 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -991,6 +991,29 @@ def test_theme_toggle(self): self.assertEqual(toolbar.get_attribute("data-user-theme"), "auto") self.assertEqual(toolbar.get_attribute("data-theme"), "light") + def reset_stored_toolbar_state(self): + self.get("/regular/basic/") + self.selenium.execute_script("localStorage.clear()") + + @override_settings(DEBUG_TOOLBAR_CONFIG={"SHOW_COLLAPSED": True}) + def test_collapsed_toolbar_does_not_flash_unstyled_content(self): + """Firefox withholds animation frames until pending stylesheets load, so + a first frame that sees unstyled content means the toolbar forced layout + early and let an unstyled paint through.""" + self.reset_stored_toolbar_state() + self.addCleanup(self.reset_stored_toolbar_state) + + self.get("/flash_of_unstyled_content/") + self.wait.until( + lambda selenium: selenium.execute_script( + "return window.djdtStyledOnFirstFrame !== null" + ) + ) + + self.assertTrue( + self.selenium.execute_script("return window.djdtStyledOnFirstFrame") + ) + def test_async_sql_action(self): self.get("/async_execute_sql/") self.selenium.find_element(By.ID, "SQLPanel") diff --git a/tests/urls.py b/tests/urls.py index 469cc4c48..8671e6718 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -30,6 +30,15 @@ path("json_view/", views.json_view), path("redirect/", views.redirect_view), path("ajax/", views.ajax_view), + path( + "flash_of_unstyled_content/", + views.flash_of_unstyled_content_view, + ), + path( + "delayed_stylesheet.css", + views.delayed_stylesheet_view, + name="delayed-stylesheet", + ), path("login_without_redirect/", LoginView.as_view(redirect_field_name=None)), path("csp_view/", views.csp_view), path("admin/", admin.site.urls), diff --git a/tests/views.py b/tests/views.py index cf3c26e4e..e9e562b7c 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,9 +1,10 @@ import asyncio +import time from asgiref.sync import sync_to_async from django.contrib.auth.models import User from django.core.cache import cache -from django.http import HttpResponseRedirect, JsonResponse +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import render from django.template.response import TemplateResponse from django.views.decorators.cache import cache_page @@ -135,3 +136,14 @@ def redirect_view(request): def ajax_view(request): return render(request, "ajax/ajax.html") + + +def flash_of_unstyled_content_view(request): + return render(request, "flash_of_unstyled_content.html") + + +def delayed_stylesheet_view(request): + # Holds the sheet pending long enough for the toolbar's async script to run first. + time.sleep(0.1) + css = "html { --delayed-stylesheet: applied; }" + return HttpResponse(css, content_type="text/css")