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 2dc373a01..0fc9afebf 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. * Restored the select and explain buttons for queries that run without parameters. * Fixed the error shown when panel content fails to load, which could not @@ -16,6 +20,7 @@ Pending * Stopped the history panel buttons from submitting their form when clicked before the panel script has loaded, which navigated away from the page. + 7.0.0 (2026-06-17) ------------------ 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 da73b5aa6..0b7d8ce13 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1026,6 +1026,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")