From 6c6c74fa236d316e8b3f291f3b87b92a56b42c6b Mon Sep 17 00:00:00 2001
From: Jakub Duchek
Date: Sat, 18 Jul 2026 21:16:04 +0000
Subject: [PATCH 1/2] added lazy panel loading support (#530)
Panels registered with lazy: true defer getPanel() until after the Bar is rendered. Their content is stored in the session and fetched when the panel is first opened.
---
examples/lazy-panels.php | 143 +++++++++++++++++++++
src/Tracy/Bar/Bar.php | 77 ++++++++++-
src/Tracy/Bar/assets/bar.js | 53 +++++++-
src/Tracy/Bar/assets/bar.latte | 2 +-
src/Tracy/Bar/assets/panels.latte | 2 +-
src/Tracy/Bar/dist/bar.phtml | 6 +-
src/Tracy/Bar/dist/panels.phtml | 6 +-
src/Tracy/Debugger/DeferredContent.php | 18 ++-
src/Tracy/Debugger/DevelopmentStrategy.php | 1 +
9 files changed, 293 insertions(+), 15 deletions(-)
create mode 100644 examples/lazy-panels.php
diff --git a/examples/lazy-panels.php b/examples/lazy-panels.php
new file mode 100644
index 000000000..8d0f8398e
--- /dev/null
+++ b/examples/lazy-panels.php
@@ -0,0 +1,143 @@
+⚡ Normal';
+ }
+
+ public function getPanel(): string
+ {
+ return 'Normal Panel '
+ . ''
+ . '
This panel was rendered during the request (eager).
'
+ . '
Time: ' . date('H:i:s') . '
'
+ . '
';
+ }
+}
+
+
+/**
+ * Example: A "heavy" panel that simulates expensive computation.
+ * When registered with lazy: true, getPanel() is NOT called during the request.
+ * Instead, it is rendered in the shutdown function and served via AJAX on click.
+ */
+class HeavyPanel implements IBarPanel
+{
+ public function getTab(): string
+ {
+ return '🐢 Heavy ';
+ }
+
+ public function getPanel(): string
+ {
+ // Simulate expensive operation (e.g., database profiling, API calls)
+ usleep(500_000); // 500ms delay
+
+ return 'Heavy Panel (lazy loaded) '
+ . ''
+ . '
This panel was rendered after the response (lazy).
'
+ . '
It simulates a 500ms expensive computation.
'
+ . '
Time: ' . date('H:i:s') . '
'
+ . '
Key Value '
+ . 'PHP Version ' . PHP_VERSION . ' '
+ . 'Memory Peak ' . number_format(memory_get_peak_usage() / 1024 / 1024, 2) . ' MB '
+ . 'Extensions ' . count(get_loaded_extensions()) . ' loaded '
+ . '
'
+ . '
';
+ }
+}
+
+
+/**
+ * Example: Another lazy panel showing database-like profiling info.
+ */
+class DatabasePanel implements IBarPanel
+{
+ public function getTab(): string
+ {
+ return '🗄️ DB ';
+ }
+
+ public function getPanel(): string
+ {
+ usleep(300_000); // 300ms delay
+
+ $queries = [
+ ['SELECT * FROM users WHERE id = 1', '0.5ms'],
+ ['SELECT * FROM posts WHERE user_id = 1 ORDER BY created_at DESC LIMIT 10', '2.1ms'],
+ ['UPDATE users SET last_login = NOW() WHERE id = 1', '0.3ms'],
+ ];
+
+ $html = 'Database Panel (lazy loaded) '
+ . ''
+ . '
Simulated database queries — rendered lazily after the response was sent.
'
+ . '
# Query Time ';
+
+ foreach ($queries as $i => [$query, $time]) {
+ $html .= '' . ($i + 1) . ' ' . htmlspecialchars($query) . '' . $time . ' ';
+ }
+
+ $html .= '
';
+ return $html;
+ }
+}
+
+
+// Register panels:
+// Normal panel (eager) — rendered during the request
+Debugger::getBar()->addPanel(new NormalPanel, 'example-normal');
+
+// Heavy panel — lazy: true means getPanel() is deferred to shutdown function
+Debugger::getBar()->addPanel(new HeavyPanel, 'example-heavy', lazy: true);
+
+// Database panel — also lazy
+Debugger::getBar()->addPanel(new DatabasePanel, 'example-database', lazy: true);
+
+?>
+
+
+Tracy: Lazy Panel Loading Demo
+
+How it works
+This demo shows the lazy: true parameter for Debugger::getBar()->addPanel().
+
+
+ ⚡ Normal — A regular panel. Its getPanel() is called during the request.
+ 🐢 Heavy — A lazy panel simulating a 500ms expensive operation. Content loads on click.
+ 🗄️ DB — A lazy panel simulating database query profiling. Content loads on click.
+
+
+Usage
+// Register a lazy panel — getPanel() is NOT called during the request
+Debugger::getBar()->addPanel(new MyExpensivePanel, 'my-panel', lazy: true);
+
+
+Lazy panels have their getTab() called normally (so the tab is always visible),
+but getPanel() is deferred to a shutdown function. The content is stored in the session
+and fetched via AJAX when you click or hover over the panel tab.
+
+This is useful for panels that perform expensive operations like database profiling,
+API call logging, or heavy data analysis — they won't slow down your page response time.
+
+For security reasons, Tracy is visible only on localhost. Look into the source code to see how to enable Tracy.
';
+}
diff --git a/src/Tracy/Bar/Bar.php b/src/Tracy/Bar/Bar.php
index fcadb2362..cf0b226e1 100644
--- a/src/Tracy/Bar/Bar.php
+++ b/src/Tracy/Bar/Bar.php
@@ -17,13 +17,19 @@ class Bar
{
/** @var IBarPanel[] */
private array $panels = [];
+
+ /** @var array panel ID => lazy flag */
+ private array $lazyPanels = [];
private bool $loaderRendered = false;
/**
* Add custom panel.
+ * @param bool $lazy If true, panel content is rendered after the response is sent
+ * and loaded via AJAX when the user opens the tab. Use for panels
+ * whose getPanel() is expensive and not needed on every request.
*/
- public function addPanel(IBarPanel $panel, ?string $id = null): static
+ public function addPanel(IBarPanel $panel, ?string $id = null, bool $lazy = false): static
{
if ($id === null) {
$c = 0;
@@ -33,6 +39,10 @@ public function addPanel(IBarPanel $panel, ?string $id = null): static
}
$this->panels[$id] = $panel;
+ if ($lazy) {
+ $this->lazyPanels[$id] = true;
+ }
+
return $this;
}
@@ -158,9 +168,14 @@ private function renderPanels(string $suffix = ''): array
foreach ($this->panels as $id => $panel) {
$idHtml = preg_replace('#[^a-z0-9]+#i', '-', $id) . $suffix;
+ $lazy = isset($this->lazyPanels[$id]);
try {
$tab = (string) $panel->getTab();
- $panelHtml = $tab ? $panel->getPanel() : null;
+ if ($lazy && $tab) {
+ $panelHtml = null; // deferred: content is rendered later and loaded on demand via AJAX
+ } else {
+ $panelHtml = $tab ? $panel->getPanel() : null;
+ }
} catch (\Throwable $e) {
while (ob_get_level() > $obLevel) { // restore ob-level if broken
@@ -170,10 +185,11 @@ private function renderPanels(string $suffix = ''): array
$idHtml = "error-$idHtml";
$tab = "Error in $id";
$panelHtml = "Error: $id " . nl2br(Helpers::escapeHtml($e)) . '
';
+ $lazy = false;
unset($e);
}
- $panels[] = (object) ['id' => $idHtml, 'tab' => $tab, 'panel' => $panelHtml];
+ $panels[] = (object) ['id' => $idHtml, 'tab' => $tab, 'panel' => $panelHtml, 'lazy' => $lazy];
}
restore_error_handler();
@@ -181,6 +197,61 @@ private function renderPanels(string $suffix = ''): array
}
+ /**
+ * Renders the content of lazy panels and stores it in the session so it can be
+ * fetched on demand via AJAX when the user opens the panel. Runs after render().
+ * @internal
+ */
+ public function renderLazyPanels(DeferredContent $defer): void
+ {
+ if (!$defer->isAvailable()) {
+ return;
+ }
+
+ set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
+ if (error_reporting() & $severity) {
+ throw new \ErrorException($message, 0, $severity, $file, $line);
+ }
+
+ return true;
+ });
+
+ $obLevel = ob_get_level();
+ $icons = '';
+ $lazyItems = &$defer->getItems('lazy-panels');
+
+ foreach ($this->panels as $id => $panel) {
+ if (!isset($this->lazyPanels[$id])) {
+ continue;
+ }
+
+ try {
+ $tab = (string) $panel->getTab();
+ $panelHtml = $tab ? $panel->getPanel() : null;
+ } catch (\Throwable $e) {
+ while (ob_get_level() > $obLevel) {
+ ob_end_clean();
+ }
+
+ $panelHtml = "Error: $id " . nl2br(Helpers::escapeHtml($e)) . '
';
+ unset($e);
+ }
+
+ if ($panelHtml !== null) {
+ $lazyItems[$defer->getRequestId() . '.' . preg_replace('#[^a-z0-9]+#i', '-', $id)] = [
+ 'content' => $panelHtml . "\n" . $icons,
+ 'time' => time(),
+ ];
+ }
+ }
+
+ restore_error_handler();
+ }
+
+
/**
* Captures debug bar as plain text (markdown) for AI agents.
*/
diff --git a/src/Tracy/Bar/assets/bar.js b/src/Tracy/Bar/assets/bar.js
index f7f1dc546..82733e1ec 100644
--- a/src/Tracy/Bar/assets/bar.js
+++ b/src/Tracy/Bar/assets/bar.js
@@ -41,10 +41,16 @@ class Panel {
let elem = this.elem;
this.init = function () {};
- elem.innerHTML = elem.tracyContent = elem.dataset.tracyContent;
- delete elem.dataset.tracyContent;
- Tracy.Dumper.init(Debug.shadow);
- evalScripts(elem);
+
+ if (elem.dataset.tracyLazy && !elem.dataset.tracyContent) {
+ elem.innerHTML = elem.tracyContent = 'Loading… ';
+ this.fetchLazyContent();
+ } else {
+ elem.innerHTML = elem.tracyContent = elem.dataset.tracyContent;
+ delete elem.dataset.tracyContent;
+ Tracy.Dumper.init(Debug.shadow);
+ evalScripts(elem);
+ }
draggable(elem, {
handles: elem.querySelectorAll('h1'),
@@ -97,6 +103,45 @@ class Panel {
}
+ fetchLazyContent() {
+ let elem = this.elem;
+ let panelId = elem.id.replace('tracy-debug-panel-', '');
+ let url = baseUrl + '_tracy_bar=lazy-panel.' + requestId + '.' + panelId + '&XDEBUG_SESSION_STOP=1&v=' + Math.random();
+
+ fetch(url)
+ .then((response) => response.json())
+ .then((data) => {
+ if (data.content) {
+ elem.innerHTML = elem.tracyContent = data.content;
+ delete elem.dataset.tracyLazy;
+ Tracy.Dumper.init(Debug.shadow);
+ evalScripts(elem);
+
+ elem.querySelectorAll('.tracy-icons a').forEach((link) => {
+ link.addEventListener('click', (e) => {
+ if (link.dataset.tracyAction === 'close') {
+ this.toPeek();
+ } else if (link.dataset.tracyAction === 'window') {
+ this.toWindow();
+ }
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ });
+ });
+
+ if (this.is('tracy-panel-persist')) {
+ Tracy.Toggle.persist(elem);
+ }
+ } else {
+ elem.innerHTML = elem.tracyContent = 'Error Lazy panel content is no longer available. It may have expired from the session.
';
+ }
+ })
+ .catch(() => {
+ elem.innerHTML = elem.tracyContent = 'Error Failed to load lazy panel content.
';
+ });
+ }
+
+
is(mode) {
return this.elem.classList.contains(mode);
}
diff --git a/src/Tracy/Bar/assets/bar.latte b/src/Tracy/Bar/assets/bar.latte
index 881b51800..92bab63e7 100644
--- a/src/Tracy/Bar/assets/bar.latte
+++ b/src/Tracy/Bar/assets/bar.latte
@@ -14,7 +14,7 @@
{/switch}
{foreach $panels as $panel}
- {if $panel->panel}{trim($panel->tab)|noescape}
+ {if $panel->panel || ($panel->lazy ?? false)}{trim($panel->tab)|noescape}
{else}{trim($panel->tab)|noescape}
{/if}
{/foreach}
diff --git a/src/Tracy/Bar/assets/panels.latte b/src/Tracy/Bar/assets/panels.latte
index 92b7809c9..e583856de 100644
--- a/src/Tracy/Bar/assets/panels.latte
+++ b/src/Tracy/Bar/assets/panels.latte
@@ -11,7 +11,7 @@
{foreach $panels as $panel}
{do $content = $panel->panel ? $panel->panel . "\n" . $icons : ''}
-
+
{/foreach}
{do Dumper::$liveSnapshot = []}
diff --git a/src/Tracy/Bar/dist/bar.phtml b/src/Tracy/Bar/dist/bar.phtml
index c2f5ba0a2..326a0de59 100644
--- a/src/Tracy/Bar/dist/bar.phtml
+++ b/src/Tracy/Bar/dist/bar.phtml
@@ -27,11 +27,11 @@ echo "\n";
foreach ($panels as $panel) /* pos 16:2 */ {
if ($panel->tab) /* pos 17:7 */ {
echo '
';
- if ($panel->panel) /* pos 17:26 */ {
+ if ($panel->panel || ($panel->lazy ?? false)) /* pos 17:26 */ {
echo '';
- echo trim($panel->tab) /* pos 17:93 */;
+ echo trim($panel->tab) /* pos 17:120 */;
echo '
';
} else /* pos 18:3 */ {
diff --git a/src/Tracy/Bar/dist/panels.phtml b/src/Tracy/Bar/dist/panels.phtml
index 28cd6f283..a8f75b00b 100644
--- a/src/Tracy/Bar/dist/panels.phtml
+++ b/src/Tracy/Bar/dist/panels.phtml
@@ -19,8 +19,10 @@ foreach ($panels as $panel) /* pos 12:2 */ {
echo ($ʟ_tmp = array_filter(['tracy-panel', $type !== 'ajax' ? 'tracy-panel-persist' : null, 'tracy-panel-' . $type])) ? ' class="' . Tracy\Helpers::escapeHtml(implode(' ', $ʟ_tmp)) . '"' : '' /* pos 14:15 */;
echo ' id="tracy-debug-panel-';
echo Tracy\Helpers::escapeHtml($panel->id) /* pos 14:114 */;
- echo '" data-tracy-content=\'';
- echo str_replace(['&', '\''], ['&', '''], $content) /* pos 14:148 */;
+ echo '"';
+ echo ($ʟ_tmp = ($panel->lazy ?? false ? '1' : null)) === null ? '' : ' data-tracy-lazy="' . Tracy\Helpers::escapeHtml($ʟ_tmp) . '"' /* pos 14:146 */;
+ echo ' data-tracy-content=\'';
+ echo str_replace(['&', '\''], ['&', '''], $content) /* pos 14:205 */;
echo '\'>
';
diff --git a/src/Tracy/Debugger/DeferredContent.php b/src/Tracy/Debugger/DeferredContent.php
index 4581374ca..792b8873a 100644
--- a/src/Tracy/Debugger/DeferredContent.php
+++ b/src/Tracy/Debugger/DeferredContent.php
@@ -7,7 +7,8 @@
namespace Tracy;
-use function array_slice, is_string, strlen;
+use function array_slice, is_string, json_encode, strlen;
+use const JSON_INVALID_UTF8_SUBSTITUTE, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE;
/**
@@ -112,6 +113,21 @@ public function sendAssets(): bool
return true;
}
+ if (is_string($asset) && preg_match('#^lazy-panel\.([\w.+-]+)$#', $asset, $m)) {
+ $key = $m[1];
+ header('Content-Type: application/json; charset=UTF-8');
+ header('Cache-Control: no-cache');
+ header_remove('Set-Cookie');
+ $lazyItems = &$this->getItems('lazy-panels');
+ $content = $lazyItems[$key]['content'] ?? null;
+ unset($lazyItems[$key]);
+ $str = json_encode(['content' => $content], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
+ header('Content-Length: ' . strlen($str));
+ echo $str;
+ flush();
+ return true;
+ }
+
if ($this->deferred) {
header('X-Tracy-Ajax: 1'); // session must be already locked
}
diff --git a/src/Tracy/Debugger/DevelopmentStrategy.php b/src/Tracy/Debugger/DevelopmentStrategy.php
index 8f81711fa..8650fa00e 100644
--- a/src/Tracy/Debugger/DevelopmentStrategy.php
+++ b/src/Tracy/Debugger/DevelopmentStrategy.php
@@ -143,5 +143,6 @@ public function renderBar(): void
}
$this->bar->render($this->defer);
+ $this->bar->renderLazyPanels($this->defer);
}
}
From 457a135d3baf45dfb979847991cb3996a8f90eb9 Mon Sep 17 00:00:00 2001
From: Jakub Duchek
Date: Fri, 11 Sep 2026 17:52:15 +0200
Subject: [PATCH 2/2] fixed lazy panel session lookup
---
src/Tracy/Bar/Bar.php | 49 +++----
src/Tracy/Bar/assets/bar.js | 6 +-
src/Tracy/Bar/assets/panels.latte | 2 +-
src/Tracy/Bar/dist/panels.phtml | 4 +-
src/Tracy/Debugger/DeferredContent.php | 16 ++-
tests/Tracy/Bar.lazyPanels.phpt | 172 +++++++++++++++++++++++++
6 files changed, 213 insertions(+), 36 deletions(-)
create mode 100644 tests/Tracy/Bar.lazyPanels.phpt
diff --git a/src/Tracy/Bar/Bar.php b/src/Tracy/Bar/Bar.php
index cf0b226e1..0b0304e4e 100644
--- a/src/Tracy/Bar/Bar.php
+++ b/src/Tracy/Bar/Bar.php
@@ -7,7 +7,7 @@
namespace Tracy;
-use function count;
+use function count, explode;
/**
@@ -20,6 +20,9 @@ class Bar
/** @var array panel ID => lazy flag */
private array $lazyPanels = [];
+
+ /** @var array lazy token => [panel ID, panel] */
+ private array $pendingLazyPanels = [];
private bool $loaderRendered = false;
@@ -41,6 +44,8 @@ public function addPanel(IBarPanel $panel, ?string $id = null, bool $lazy = fals
$this->panels[$id] = $panel;
if ($lazy) {
$this->lazyPanels[$id] = true;
+ } else {
+ unset($this->lazyPanels[$id]);
}
return $this;
@@ -84,7 +89,7 @@ public function render(DeferredContent $defer): void
if ($defer->isDeferred()) {
if ($defer->isAvailable()) {
- $defer->addSetup('Tracy.Debug.loadAjax', $this->renderPartial('ajax', '-ajax:' . $requestId));
+ $defer->addSetup('Tracy.Debug.loadAjax', $this->renderPartial('ajax', $requestId, '-ajax:' . $requestId));
if (Helpers::isAgent()) {
$defer->addSetup('console.log', $this->renderAgent());
}
@@ -92,7 +97,7 @@ public function render(DeferredContent $defer): void
} elseif (Helpers::isRedirect()) {
if ($defer->isAvailable()) {
$redirectQueue[] = [
- 'content' => $this->renderPartial('redirect', '-r' . count($redirectQueue)),
+ 'content' => $this->renderPartial('redirect', $requestId, '-r' . count($redirectQueue)),
'agent' => Helpers::isAgent() ? $this->renderAgent() : null,
'time' => time(),
];
@@ -102,7 +107,7 @@ public function render(DeferredContent $defer): void
Debugger::log(new \LogicException('Tracy cannot display the Bar because the Content-Length header is being sent'), Debugger::EXCEPTION);
}
- $content = $this->renderPartial('main');
+ $content = $this->renderPartial('main', $requestId);
foreach (array_reverse($redirectQueue) as $item) {
$content['bar'] .= $item['content']['bar'];
@@ -137,9 +142,9 @@ public function render(DeferredContent $defer): void
/** @return array{bar: string, panels: string} */
- private function renderPartial(string $type, string $suffix = ''): array
+ private function renderPartial(string $type, string $requestId, string $suffix = ''): array
{
- $panels = $this->renderPanels($suffix);
+ $panels = $this->renderPanels($requestId, $suffix);
return [
'bar' => Helpers::capture(function () use ($type, $panels) {
@@ -153,7 +158,7 @@ private function renderPartial(string $type, string $suffix = ''): array
/** @return list<\stdClass> */
- private function renderPanels(string $suffix = ''): array
+ private function renderPanels(string $requestId, string $suffix = ''): array
{
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
if (error_reporting() & $severity) {
@@ -168,11 +173,13 @@ private function renderPanels(string $suffix = ''): array
foreach ($this->panels as $id => $panel) {
$idHtml = preg_replace('#[^a-z0-9]+#i', '-', $id) . $suffix;
- $lazy = isset($this->lazyPanels[$id]);
+ $lazyToken = null;
try {
$tab = (string) $panel->getTab();
- if ($lazy && $tab) {
+ if (isset($this->lazyPanels[$id]) && $tab) {
$panelHtml = null; // deferred: content is rendered later and loaded on demand via AJAX
+ $lazyToken = $requestId . '.' . preg_replace('#[^a-z0-9]+#i', '-', $id);
+ $this->pendingLazyPanels[$lazyToken] = [$id, $panel];
} else {
$panelHtml = $tab ? $panel->getPanel() : null;
}
@@ -185,11 +192,11 @@ private function renderPanels(string $suffix = ''): array
$idHtml = "error-$idHtml";
$tab = "Error in $id";
$panelHtml = "Error: $id " . nl2br(Helpers::escapeHtml($e)) . '
';
- $lazy = false;
+ $lazyToken = null;
unset($e);
}
- $panels[] = (object) ['id' => $idHtml, 'tab' => $tab, 'panel' => $panelHtml, 'lazy' => $lazy];
+ $panels[] = (object) ['id' => $idHtml, 'tab' => $tab, 'panel' => $panelHtml, 'lazy' => $lazyToken];
}
restore_error_handler();
@@ -204,7 +211,9 @@ private function renderPanels(string $suffix = ''): array
*/
public function renderLazyPanels(DeferredContent $defer): void
{
- if (!$defer->isAvailable()) {
+ $pendingPanels = $this->pendingLazyPanels;
+ $this->pendingLazyPanels = [];
+ if (!$pendingPanels || !$defer->isAvailable()) {
return;
}
@@ -223,14 +232,9 @@ public function renderLazyPanels(DeferredContent $defer): void
. '';
$lazyItems = &$defer->getItems('lazy-panels');
- foreach ($this->panels as $id => $panel) {
- if (!isset($this->lazyPanels[$id])) {
- continue;
- }
-
+ foreach ($pendingPanels as $key => [$id, $panel]) {
try {
- $tab = (string) $panel->getTab();
- $panelHtml = $tab ? $panel->getPanel() : null;
+ $panelHtml = $panel->getPanel();
} catch (\Throwable $e) {
while (ob_get_level() > $obLevel) {
ob_end_clean();
@@ -241,10 +245,9 @@ public function renderLazyPanels(DeferredContent $defer): void
}
if ($panelHtml !== null) {
- $lazyItems[$defer->getRequestId() . '.' . preg_replace('#[^a-z0-9]+#i', '-', $id)] = [
- 'content' => $panelHtml . "\n" . $icons,
- 'time' => time(),
- ];
+ [$requestId, $panelId] = explode('.', $key, 2);
+ $lazyItems[$requestId]['panels'][$panelId] = $panelHtml . "\n" . $icons;
+ $lazyItems[$requestId]['time'] = time();
}
}
diff --git a/src/Tracy/Bar/assets/bar.js b/src/Tracy/Bar/assets/bar.js
index 82733e1ec..18445f076 100644
--- a/src/Tracy/Bar/assets/bar.js
+++ b/src/Tracy/Bar/assets/bar.js
@@ -105,13 +105,13 @@ class Panel {
fetchLazyContent() {
let elem = this.elem;
- let panelId = elem.id.replace('tracy-debug-panel-', '');
- let url = baseUrl + '_tracy_bar=lazy-panel.' + requestId + '.' + panelId + '&XDEBUG_SESSION_STOP=1&v=' + Math.random();
+ let lazyToken = elem.dataset.tracyLazy;
+ let url = baseUrl + '_tracy_bar=lazy-panel.' + encodeURIComponent(lazyToken) + '&XDEBUG_SESSION_STOP=1&v=' + Math.random();
fetch(url)
.then((response) => response.json())
.then((data) => {
- if (data.content) {
+ if (data.content !== null) {
elem.innerHTML = elem.tracyContent = data.content;
delete elem.dataset.tracyLazy;
Tracy.Dumper.init(Debug.shadow);
diff --git a/src/Tracy/Bar/assets/panels.latte b/src/Tracy/Bar/assets/panels.latte
index e583856de..a70094903 100644
--- a/src/Tracy/Bar/assets/panels.latte
+++ b/src/Tracy/Bar/assets/panels.latte
@@ -11,7 +11,7 @@
{foreach $panels as $panel}
{do $content = $panel->panel ? $panel->panel . "\n" . $icons : ''}
-
+
lazy} data-tracy-content='{str_replace(['&', "'"], ['&', '''], $content)|noescape}'>
{/foreach}
{do Dumper::$liveSnapshot = []}
diff --git a/src/Tracy/Bar/dist/panels.phtml b/src/Tracy/Bar/dist/panels.phtml
index a8f75b00b..2aa9fa5fb 100644
--- a/src/Tracy/Bar/dist/panels.phtml
+++ b/src/Tracy/Bar/dist/panels.phtml
@@ -20,9 +20,9 @@ foreach ($panels as $panel) /* pos 12:2 */ {
echo ' id="tracy-debug-panel-';
echo Tracy\Helpers::escapeHtml($panel->id) /* pos 14:114 */;
echo '"';
- echo ($ʟ_tmp = ($panel->lazy ?? false ? '1' : null)) === null ? '' : ' data-tracy-lazy="' . Tracy\Helpers::escapeHtml($ʟ_tmp) . '"' /* pos 14:146 */;
+ echo ($ʟ_tmp = ($panel->lazy)) === null ? '' : ' data-tracy-lazy="' . Tracy\Helpers::escapeHtml($ʟ_tmp) . '"' /* pos 14:145 */;
echo ' data-tracy-content=\'';
- echo str_replace(['&', '\''], ['&', '''], $content) /* pos 14:205 */;
+ echo str_replace(['&', '\''], ['&', '''], $content) /* pos 14:179 */;
echo '\'>
';
diff --git a/src/Tracy/Debugger/DeferredContent.php b/src/Tracy/Debugger/DeferredContent.php
index 792b8873a..45f7a02df 100644
--- a/src/Tracy/Debugger/DeferredContent.php
+++ b/src/Tracy/Debugger/DeferredContent.php
@@ -7,8 +7,7 @@
namespace Tracy;
-use function array_slice, is_string, json_encode, strlen;
-use const JSON_INVALID_UTF8_SUBSTITUTE, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE;
+use function array_slice, is_string, strlen;
/**
@@ -113,15 +112,18 @@ public function sendAssets(): bool
return true;
}
- if (is_string($asset) && preg_match('#^lazy-panel\.([\w.+-]+)$#', $asset, $m)) {
- $key = $m[1];
+ if (is_string($asset) && preg_match('#^lazy-panel\.(\w{10,15})\.([a-z0-9-]+)$#Di', $asset, $m)) {
+ [, $requestId, $panelId] = $m;
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-cache');
header_remove('Set-Cookie');
$lazyItems = &$this->getItems('lazy-panels');
- $content = $lazyItems[$key]['content'] ?? null;
- unset($lazyItems[$key]);
- $str = json_encode(['content' => $content], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
+ $content = $lazyItems[$requestId]['panels'][$panelId] ?? null;
+ unset($lazyItems[$requestId]['panels'][$panelId]);
+ if (empty($lazyItems[$requestId]['panels'])) {
+ unset($lazyItems[$requestId]);
+ }
+ $str = Helpers::jsonEncode(['content' => $content]);
header('Content-Length: ' . strlen($str));
echo $str;
flush();
diff --git a/tests/Tracy/Bar.lazyPanels.phpt b/tests/Tracy/Bar.lazyPanels.phpt
new file mode 100644
index 000000000..7caa0ced0
--- /dev/null
+++ b/tests/Tracy/Bar.lazyPanels.phpt
@@ -0,0 +1,172 @@
+tabCalls++;
+ return 'lazy tab';
+ }
+
+
+ public function getPanel(): string
+ {
+ $this->panelCalls++;
+ return 'lazy content ';
+ }
+}
+
+
+class LazyPanelSessionStorage implements Tracy\SessionStorage
+{
+ public array $data = [];
+
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+
+ public function &getData(): array
+ {
+ return $this->data;
+ }
+}
+
+
+function createLazyPanelDefer(LazyPanelSessionStorage $storage): Tracy\DeferredContent
+{
+ $defer = new Tracy\DeferredContent($storage);
+ $defer->sendAssets();
+ return $defer;
+}
+
+
+test('lazy panel token survives an AJAX response', function () {
+ $_SERVER['HTTP_X_TRACY_AJAX'] = 'abcdef1234';
+ setHtmlMode();
+ $storage = new LazyPanelSessionStorage;
+ $defer = createLazyPanelDefer($storage);
+ $panel = new LazyPanel;
+ $bar = new Tracy\Bar;
+ $bar->addPanel($panel, 'test.panel', lazy: true);
+
+ $bar->render($defer);
+ $bar->renderLazyPanels($defer);
+
+ Assert::same(1, $panel->tabCalls);
+ Assert::same(1, $panel->panelCalls);
+ Assert::match('lazy content %A%tracy-icons%A%', $storage->data['lazy-panels']['abcdef1234']['panels']['test-panel']);
+
+ Assert::same(1, preg_match('#^Tracy\.Debug\.loadAjax\((.*)\);\n$#s', $storage->data['setup']['abcdef1234']['code'], $match));
+ $content = json_decode($match[1], true, flags: JSON_THROW_ON_ERROR);
+ Assert::match('%A%data-tracy-lazy="abcdef1234.test-panel"%A%', $content['panels']);
+
+ unset($_SERVER['HTTP_X_TRACY_AJAX']);
+});
+
+
+test('lazy panel token survives a redirect', function () {
+ setHtmlMode();
+ header('Location: /next');
+ $storage = new LazyPanelSessionStorage;
+ $defer = createLazyPanelDefer($storage);
+ $panel = new LazyPanel;
+ $bar = new Tracy\Bar;
+ $bar->addPanel($panel, 'test.panel', lazy: true);
+
+ $bar->render($defer);
+ $bar->renderLazyPanels($defer);
+
+ $token = $defer->getRequestId() . '.test-panel';
+ Assert::same(1, $panel->tabCalls);
+ Assert::same(1, $panel->panelCalls);
+ Assert::hasKey('test-panel', $storage->data['lazy-panels'][$defer->getRequestId()]['panels']);
+ Assert::match('%A%data-tracy-lazy="' . $token . '"%A%', $storage->data['redirect'][0]['content']['panels']);
+
+ header_remove('Location');
+ http_response_code(200);
+});
+
+
+test('lazy panel content is consumed once', function () {
+ setHtmlMode();
+ $storage = new LazyPanelSessionStorage;
+ $storage->data['lazy-panels']['abcdef1234'] = [
+ 'panels' => ['test-panel' => 'lazy content '],
+ 'time' => time(),
+ ];
+ $_GET['_tracy_bar'] = 'lazy-panel.abcdef1234.test-panel';
+ $defer = new Tracy\DeferredContent($storage);
+
+ ob_start();
+ Assert::true($defer->sendAssets());
+ $response = ob_get_clean();
+
+ Assert::same(['content' => 'lazy content '], json_decode($response, true, flags: JSON_THROW_ON_ERROR));
+ Assert::same([], $storage->data['lazy-panels']);
+ unset($_GET['_tracy_bar']);
+});
+
+
+test('replacing a lazy panel clears the lazy flag', function () {
+ $_SERVER['HTTP_X_TRACY_AJAX'] = 'abcdef1234';
+ setHtmlMode();
+ $storage = new LazyPanelSessionStorage;
+ $defer = createLazyPanelDefer($storage);
+ $lazyPanel = new LazyPanel;
+ $eagerPanel = new LazyPanel;
+ $bar = new Tracy\Bar;
+ $bar->addPanel($lazyPanel, 'test.panel', lazy: true);
+ $bar->addPanel($eagerPanel, 'test.panel');
+
+ $bar->render($defer);
+ $bar->renderLazyPanels($defer);
+
+ Assert::same(0, $lazyPanel->tabCalls);
+ Assert::same(0, $lazyPanel->panelCalls);
+ Assert::same(1, $eagerPanel->tabCalls);
+ Assert::same(1, $eagerPanel->panelCalls);
+ Assert::hasNotKey('lazy-panels', $storage->data);
+
+ unset($_SERVER['HTTP_X_TRACY_AJAX']);
+});
+
+
+test('session cleanup preserves all panels from one request', function () {
+ $_SERVER['HTTP_X_TRACY_AJAX'] = 'abcdef1234';
+ setHtmlMode();
+ $storage = new LazyPanelSessionStorage;
+ $defer = createLazyPanelDefer($storage);
+ $bar = new Tracy\Bar;
+ for ($i = 0; $i < 11; $i++) {
+ $bar->addPanel(new LazyPanel, 'test-' . $i, lazy: true);
+ }
+
+ $bar->render($defer);
+ $bar->renderLazyPanels($defer);
+ $defer->clean();
+
+ Assert::count(1, $storage->data['lazy-panels']);
+ Assert::count(11, $storage->data['lazy-panels']['abcdef1234']['panels']);
+
+ unset($_SERVER['HTTP_X_TRACY_AJAX']);
+});