Skip to content

Commit 44bb6fc

Browse files
committed
feat: Add lazy panel loading support (#530)
Panels registered with `lazy: true` have their getPanel() deferred to a shutdown function. The tab renders immediately, but panel content is stored in session and fetched via AJAX on first interaction. This avoids expensive panel rendering blocking the page response. Usage: Debugger::getBar()->addPanel($panel, 'id', lazy: true);
1 parent 785fbfa commit 44bb6fc

8 files changed

Lines changed: 289 additions & 12 deletions

File tree

examples/lazy-panels.php

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
require __DIR__ . '/../src/tracy.php';
6+
7+
use Tracy\Debugger;
8+
use Tracy\IBarPanel;
9+
10+
// For security reasons, Tracy is visible only on localhost.
11+
// You may force Tracy to run in development mode by passing the Debugger::Development instead of Debugger::Detect.
12+
Debugger::enable(Debugger::Detect, __DIR__ . '/log');
13+
14+
15+
/**
16+
* Example: A normal (eager) panel — getPanel() is called during the request.
17+
*/
18+
class NormalPanel implements IBarPanel
19+
{
20+
public function getTab(): string
21+
{
22+
return '<span title="Normal Panel">⚡ Normal</span>';
23+
}
24+
25+
public function getPanel(): string
26+
{
27+
return '<h1>Normal Panel</h1>'
28+
. '<div class="tracy-inner">'
29+
. '<p>This panel was rendered <strong>during the request</strong> (eager).</p>'
30+
. '<p>Time: ' . date('H:i:s') . '</p>'
31+
. '</div>';
32+
}
33+
}
34+
35+
36+
/**
37+
* Example: A "heavy" panel that simulates expensive computation.
38+
* When registered with lazy: true, getPanel() is NOT called during the request.
39+
* Instead, it is rendered in the shutdown function and served via AJAX on click.
40+
*/
41+
class HeavyPanel implements IBarPanel
42+
{
43+
public function getTab(): string
44+
{
45+
return '<span title="Heavy Panel (lazy)">🐢 Heavy</span>';
46+
}
47+
48+
public function getPanel(): string
49+
{
50+
// Simulate expensive operation (e.g., database profiling, API calls)
51+
usleep(500_000); // 500ms delay
52+
53+
return '<h1>Heavy Panel (lazy loaded)</h1>'
54+
. '<div class="tracy-inner">'
55+
. '<p>This panel was rendered <strong>after the response</strong> (lazy).</p>'
56+
. '<p>It simulates a 500ms expensive computation.</p>'
57+
. '<p>Time: ' . date('H:i:s') . '</p>'
58+
. '<table><tr><th>Key</th><th>Value</th></tr>'
59+
. '<tr><td>PHP Version</td><td>' . PHP_VERSION . '</td></tr>'
60+
. '<tr><td>Memory Peak</td><td>' . number_format(memory_get_peak_usage() / 1024 / 1024, 2) . ' MB</td></tr>'
61+
. '<tr><td>Extensions</td><td>' . count(get_loaded_extensions()) . ' loaded</td></tr>'
62+
. '</table>'
63+
. '</div>';
64+
}
65+
}
66+
67+
68+
/**
69+
* Example: Another lazy panel showing database-like profiling info.
70+
*/
71+
class DatabasePanel implements IBarPanel
72+
{
73+
public function getTab(): string
74+
{
75+
return '<span title="Database Panel (lazy)">🗄️ DB</span>';
76+
}
77+
78+
public function getPanel(): string
79+
{
80+
usleep(300_000); // 300ms delay
81+
82+
$queries = [
83+
['SELECT * FROM users WHERE id = 1', '0.5ms'],
84+
['SELECT * FROM posts WHERE user_id = 1 ORDER BY created_at DESC LIMIT 10', '2.1ms'],
85+
['UPDATE users SET last_login = NOW() WHERE id = 1', '0.3ms'],
86+
];
87+
88+
$html = '<h1>Database Panel (lazy loaded)</h1>'
89+
. '<div class="tracy-inner">'
90+
. '<p>Simulated database queries — rendered lazily after the response was sent.</p>'
91+
. '<table><tr><th>#</th><th>Query</th><th>Time</th></tr>';
92+
93+
foreach ($queries as $i => [$query, $time]) {
94+
$html .= '<tr><td>' . ($i + 1) . '</td><td><code>' . htmlspecialchars($query) . '</code></td><td>' . $time . '</td></tr>';
95+
}
96+
97+
$html .= '</table></div>';
98+
return $html;
99+
}
100+
}
101+
102+
103+
// Register panels:
104+
// Normal panel (eager) — rendered during the request
105+
Debugger::getBar()->addPanel(new NormalPanel, 'example-normal');
106+
107+
// Heavy panel — lazy: true means getPanel() is deferred to shutdown function
108+
Debugger::getBar()->addPanel(new HeavyPanel, 'example-heavy', lazy: true);
109+
110+
// Database panel — also lazy
111+
Debugger::getBar()->addPanel(new DatabasePanel, 'example-database', lazy: true);
112+
113+
?>
114+
<!DOCTYPE html><html class=arrow><link rel="stylesheet" href="assets/style.css">
115+
116+
<h1>Tracy: Lazy Panel Loading Demo</h1>
117+
118+
<h2>How it works</h2>
119+
<p>This demo shows the <code>lazy: true</code> parameter for <code>Debugger::getBar()->addPanel()</code>.</p>
120+
121+
<ul>
122+
<li><strong>⚡ Normal</strong> — A regular panel. Its <code>getPanel()</code> is called during the request.</li>
123+
<li><strong>🐢 Heavy</strong> — A lazy panel simulating a 500ms expensive operation. Content loads on click.</li>
124+
<li><strong>🗄️ DB</strong> — A lazy panel simulating database query profiling. Content loads on click.</li>
125+
</ul>
126+
127+
<h2>Usage</h2>
128+
<pre><code>// Register a lazy panel — getPanel() is NOT called during the request
129+
Debugger::getBar()->addPanel(new MyExpensivePanel, 'my-panel', lazy: true);
130+
</code></pre>
131+
132+
<p>Lazy panels have their <code>getTab()</code> called normally (so the tab is always visible),
133+
but <code>getPanel()</code> is deferred to a shutdown function. The content is stored in the session
134+
and fetched via AJAX when you click or hover over the panel tab.</p>
135+
136+
<p>This is useful for panels that perform expensive operations like database profiling,
137+
API call logging, or heavy data analysis — they won't slow down your page response time.</p>
138+
139+
<?php
140+
141+
if (Debugger::$productionMode) {
142+
echo '<p><b>For security reasons, Tracy is visible only on localhost. Look into the source code to see how to enable Tracy.</b></p>';
143+
}

src/Tracy/Bar/Bar.php

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,19 @@ class Bar
1919
{
2020
/** @var IBarPanel[] */
2121
private array $panels = [];
22+
23+
/** @var array<string, bool> panel ID => lazy flag */
24+
private array $lazyPanels = [];
2225
private bool $loaderRendered = false;
2326

2427

2528
/**
2629
* Add custom panel.
30+
* @param bool $lazy If true, panel content is rendered after the response is sent
31+
* and loaded via AJAX when the user clicks on the tab.
32+
* Use for panels whose getPanel() is expensive and not needed on every request.
2733
*/
28-
public function addPanel(IBarPanel $panel, ?string $id = null): static
34+
public function addPanel(IBarPanel $panel, ?string $id = null, bool $lazy = false): static
2935
{
3036
if ($id === null) {
3137
$c = 0;
@@ -35,6 +41,10 @@ public function addPanel(IBarPanel $panel, ?string $id = null): static
3541
}
3642

3743
$this->panels[$id] = $panel;
44+
if ($lazy) {
45+
$this->lazyPanels[$id] = true;
46+
}
47+
3848
return $this;
3949
}
4050

@@ -141,9 +151,14 @@ private function renderPanels(string $suffix = ''): array
141151

142152
foreach ($this->panels as $id => $panel) {
143153
$idHtml = preg_replace('#[^a-z0-9]+#i', '-', $id) . $suffix;
154+
$lazy = isset($this->lazyPanels[$id]);
144155
try {
145156
$tab = (string) $panel->getTab();
146-
$panelHtml = $tab ? $panel->getPanel() : null;
157+
if ($lazy && $tab) {
158+
$panelHtml = null; // will be rendered later via shutdown function
159+
} else {
160+
$panelHtml = $tab ? $panel->getPanel() : null;
161+
}
147162

148163
} catch (\Throwable $e) {
149164
while (ob_get_level() > $obLevel) { // restore ob-level if broken
@@ -153,13 +168,68 @@ private function renderPanels(string $suffix = ''): array
153168
$idHtml = "error-$idHtml";
154169
$tab = "Error in $id";
155170
$panelHtml = "<h1>Error: $id</h1><div class='tracy-inner'>" . nl2br(Helpers::escapeHtml($e)) . '</div>';
171+
$lazy = false;
156172
unset($e);
157173
}
158174

159-
$panels[] = (object) ['id' => $idHtml, 'tab' => $tab, 'panel' => $panelHtml];
175+
$panels[] = (object) ['id' => $idHtml, 'tab' => $tab, 'panel' => $panelHtml, 'lazy' => $lazy];
160176
}
161177

162178
restore_error_handler();
163179
return $panels;
164180
}
181+
182+
183+
/**
184+
* Renders lazy panels in shutdown function and stores them in session.
185+
* @internal
186+
*/
187+
public function renderLazyPanels(DeferredContent $defer): void
188+
{
189+
if (!$defer->isAvailable()) {
190+
return;
191+
}
192+
193+
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
194+
if (error_reporting() & $severity) {
195+
throw new \ErrorException($message, 0, $severity, $file, $line);
196+
}
197+
198+
return true;
199+
});
200+
201+
$obLevel = ob_get_level();
202+
203+
foreach ($this->panels as $id => $panel) {
204+
if (!isset($this->lazyPanels[$id])) {
205+
continue;
206+
}
207+
208+
try {
209+
$tab = (string) $panel->getTab();
210+
$panelHtml = $tab ? $panel->getPanel() : null;
211+
} catch (\Throwable $e) {
212+
while (ob_get_level() > $obLevel) {
213+
ob_end_clean();
214+
}
215+
216+
$panelHtml = "<h1>Error: $id</h1><div class='tracy-inner'>" . nl2br(Helpers::escapeHtml($e)) . '</div>';
217+
unset($e);
218+
}
219+
220+
if ($panelHtml !== null) {
221+
$icons = '<div class="tracy-icons">'
222+
. '<a href="#" data-tracy-action="window" title="open in window">&curren;</a>'
223+
. '<a href="#" data-tracy-action="close" title="close window">&times;</a>'
224+
. '</div>';
225+
$lazyItems = &$defer->getItems('lazy-panels');
226+
$lazyItems[$defer->getRequestId() . '.' . preg_replace('#[^a-z0-9]+#i', '-', $id)] = [
227+
'content' => $panelHtml . "\n" . $icons,
228+
'time' => time(),
229+
];
230+
}
231+
}
232+
233+
restore_error_handler();
234+
}
165235
}

src/Tracy/Bar/assets/bar.js

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,16 @@ class Panel {
3131
let elem = this.elem;
3232

3333
this.init = function () {};
34-
elem.innerHTML = elem.tracyContent = elem.dataset.tracyContent;
35-
delete elem.dataset.tracyContent;
36-
Tracy.Dumper.init(Debug.layer);
37-
evalScripts(elem);
34+
35+
if (elem.dataset.tracyLazy && !elem.dataset.tracyContent) {
36+
elem.innerHTML = elem.tracyContent = '<h1>Loading\u2026</h1><div class="tracy-inner"><p>Loading panel content\u2026</p></div>';
37+
this.fetchLazyContent();
38+
} else {
39+
elem.innerHTML = elem.tracyContent = elem.dataset.tracyContent;
40+
delete elem.dataset.tracyContent;
41+
Tracy.Dumper.init(Debug.layer);
42+
evalScripts(elem);
43+
}
3844

3945
draggable(elem, {
4046
handles: elem.querySelectorAll('h1'),
@@ -87,6 +93,45 @@ class Panel {
8793
}
8894

8995

96+
fetchLazyContent() {
97+
let elem = this.elem;
98+
let panelId = elem.id.replace('tracy-debug-panel-', '');
99+
let url = baseUrl + '_tracy_bar=lazy-panel.' + requestId + '.' + panelId + '&XDEBUG_SESSION_STOP=1&v=' + Math.random();
100+
101+
fetch(url)
102+
.then((response) => response.json())
103+
.then((data) => {
104+
if (data.content) {
105+
elem.innerHTML = elem.tracyContent = data.content;
106+
delete elem.dataset.tracyLazy;
107+
Tracy.Dumper.init(Debug.layer);
108+
evalScripts(elem);
109+
110+
elem.querySelectorAll('.tracy-icons a').forEach((link) => {
111+
link.addEventListener('click', (e) => {
112+
if (link.dataset.tracyAction === 'close') {
113+
this.toPeek();
114+
} else if (link.dataset.tracyAction === 'window') {
115+
this.toWindow();
116+
}
117+
e.preventDefault();
118+
e.stopImmediatePropagation();
119+
});
120+
});
121+
122+
if (this.is('tracy-panel-persist')) {
123+
Tracy.Toggle.persist(elem);
124+
}
125+
} else {
126+
elem.innerHTML = elem.tracyContent = '<h1>Error</h1><div class="tracy-inner"><p>Lazy panel content not available. The panel may have expired from the session.</p></div>';
127+
}
128+
})
129+
.catch(() => {
130+
elem.innerHTML = elem.tracyContent = '<h1>Error</h1><div class="tracy-inner"><p>Failed to load lazy panel content.</p></div>';
131+
});
132+
}
133+
134+
90135
is(mode) {
91136
return this.elem.classList.contains(mode);
92137
}

src/Tracy/Bar/dist/bar.phtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ https://tracy.nette.org">
1717
<?php endif ?>
1818
<?php if ($type === 'ajax'): ?> <li>AJAX</li>
1919
<?php endif ?>
20-
<?php foreach ($panels as $panel): ?><?php if ($panel->tab): ?> <li><?php if ($panel->panel): ?>
20+
<?php foreach ($panels as $panel): ?><?php if ($panel->tab): ?> <li><?php if ($panel->panel || ($panel->lazy ?? false)): ?>
2121
<a href="#" rel="tracy-debug-panel-<?= Tracy\Helpers::escapeHtml($panel->id) ?>
2222
"><?= trim($panel->tab) ?>
2323
</a>

src/Tracy/Bar/dist/panels.phtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ declare(strict_types=1);
1212
</div>
1313
' ?><div itemscope>
1414
<?php foreach ($panels as $panel): ?><?php $content = $panel->panel ? $panel->panel . "\n" . $icons : '' ?> <div class="<?= Tracy\Helpers::escapeHtml(implode(' ', array_filter(['tracy-panel', $type !== 'ajax' ? 'tracy-panel-persist' : null, 'tracy-panel-' . $type]))) ?>" id="tracy-debug-panel-<?= Tracy\Helpers::escapeHtml($panel->id) ?>
15-
" data-tracy-content='<?= str_replace(['&', "'"], ['&amp;', '&#039;'], $content) ?>
15+
"<?php if ($panel->lazy ?? false): ?> data-tracy-lazy="1"<?php endif ?> data-tracy-content='<?= str_replace(['&', "'"], ['&amp;', '&#039;'], $content) ?>
1616
'></div>
1717
<?php endforeach ?> <meta itemprop=tracy-snapshot content=<?= Dumper::formatSnapshotAttribute(Dumper::$liveSnapshot) ?>
1818
>

src/Tracy/Debugger/DeferredContent.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,21 @@ public function sendAssets(): bool
112112
return true;
113113
}
114114

115+
if (is_string($asset) && preg_match('#^lazy-panel\.([\w.+-]+)$#', $asset, $m)) {
116+
$key = $m[1];
117+
header('Content-Type: application/json; charset=UTF-8');
118+
header('Cache-Control: no-cache');
119+
header_remove('Set-Cookie');
120+
$lazyItems = &$this->getItems('lazy-panels');
121+
$content = $lazyItems[$key]['content'] ?? null;
122+
unset($lazyItems[$key]);
123+
$str = json_encode(['content' => $content], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
124+
header('Content-Length: ' . strlen($str));
125+
echo $str;
126+
flush();
127+
return true;
128+
}
129+
115130
if ($this->deferred) {
116131
header('X-Tracy-Ajax: 1'); // session must be already locked
117132
}

src/Tracy/Debugger/DevelopmentStrategy.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,14 @@ public function handleError(
109109
}
110110

111111

112-
public function dispatch(): void
112+
public function sendAssets(): bool
113113
{
114114
if (!Helpers::isCli() && $this->defer->sendAssets()) {
115115
$this->assetsSent = true;
116-
exit;
116+
return true;
117117
}
118+
119+
return false;
118120
}
119121

120122

@@ -135,5 +137,6 @@ public function renderBar(): void
135137
}
136138

137139
$this->bar->render($this->defer);
140+
$this->bar->renderLazyPanels($this->defer);
138141
}
139142
}

0 commit comments

Comments
 (0)