Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions examples/lazy-panels.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

require __DIR__ . '/../src/tracy.php';

use Tracy\Debugger;
use Tracy\IBarPanel;

// For security reasons, Tracy is visible only on localhost.
// You may force Tracy to run in development mode by passing the Debugger::Development instead of Debugger::Detect.
Debugger::enable(Debugger::Detect, __DIR__ . '/log');


/**
* Example: A normal (eager) panel — getPanel() is called during the request.
*/
class NormalPanel implements IBarPanel
{
public function getTab(): string
{
return '<span title="Normal Panel">⚡ Normal</span>';
}

public function getPanel(): string
{
return '<h1>Normal Panel</h1>'
. '<div class="tracy-inner">'
. '<p>This panel was rendered <strong>during the request</strong> (eager).</p>'
. '<p>Time: ' . date('H:i:s') . '</p>'
. '</div>';
}
}


/**
* 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 '<span title="Heavy Panel (lazy)">🐢 Heavy</span>';
}

public function getPanel(): string
{
// Simulate expensive operation (e.g., database profiling, API calls)
usleep(500_000); // 500ms delay

return '<h1>Heavy Panel (lazy loaded)</h1>'
. '<div class="tracy-inner">'
. '<p>This panel was rendered <strong>after the response</strong> (lazy).</p>'
. '<p>It simulates a 500ms expensive computation.</p>'
. '<p>Time: ' . date('H:i:s') . '</p>'
. '<table><tr><th>Key</th><th>Value</th></tr>'
. '<tr><td>PHP Version</td><td>' . PHP_VERSION . '</td></tr>'
. '<tr><td>Memory Peak</td><td>' . number_format(memory_get_peak_usage() / 1024 / 1024, 2) . ' MB</td></tr>'
. '<tr><td>Extensions</td><td>' . count(get_loaded_extensions()) . ' loaded</td></tr>'
. '</table>'
. '</div>';
}
}


/**
* Example: Another lazy panel showing database-like profiling info.
*/
class DatabasePanel implements IBarPanel
{
public function getTab(): string
{
return '<span title="Database Panel (lazy)">🗄️ DB</span>';
}

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 = '<h1>Database Panel (lazy loaded)</h1>'
. '<div class="tracy-inner">'
. '<p>Simulated database queries — rendered lazily after the response was sent.</p>'
. '<table><tr><th>#</th><th>Query</th><th>Time</th></tr>';

foreach ($queries as $i => [$query, $time]) {
$html .= '<tr><td>' . ($i + 1) . '</td><td><code>' . htmlspecialchars($query) . '</code></td><td>' . $time . '</td></tr>';
}

$html .= '</table></div>';
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);

?>
<!DOCTYPE html><html class=arrow><link rel="stylesheet" href="assets/style.css">

<h1>Tracy: Lazy Panel Loading Demo</h1>

<h2>How it works</h2>
<p>This demo shows the <code>lazy: true</code> parameter for <code>Debugger::getBar()->addPanel()</code>.</p>

<ul>
<li><strong>⚡ Normal</strong> — A regular panel. Its <code>getPanel()</code> is called during the request.</li>
<li><strong>🐢 Heavy</strong> — A lazy panel simulating a 500ms expensive operation. Content loads on click.</li>
<li><strong>🗄️ DB</strong> — A lazy panel simulating database query profiling. Content loads on click.</li>
</ul>

<h2>Usage</h2>
<pre><code>// Register a lazy panel — getPanel() is NOT called during the request
Debugger::getBar()->addPanel(new MyExpensivePanel, 'my-panel', lazy: true);
</code></pre>

<p>Lazy panels have their <code>getTab()</code> called normally (so the tab is always visible),
but <code>getPanel()</code> 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.</p>

<p>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.</p>

<?php

if (Debugger::$productionMode) {
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>';
}
77 changes: 74 additions & 3 deletions src/Tracy/Bar/Bar.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ class Bar
{
/** @var IBarPanel[] */
private array $panels = [];

/** @var array<string, true> 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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -155,9 +165,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
Expand All @@ -167,17 +182,73 @@ private function renderPanels(string $suffix = ''): array
$idHtml = "error-$idHtml";
$tab = "Error in $id";
$panelHtml = "<h1>Error: $id</h1><div class='tracy-inner'>" . nl2br(Helpers::escapeHtml($e)) . '</div>';
$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();
return $panels;
}


/**
* 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 = '<div class="tracy-icons">'
. '<a href="#" data-tracy-action="window" title="open in window">&curren;</a>'
. '<a href="#" data-tracy-action="close" title="close window">&times;</a>'
. '</div>';
$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 = "<h1>Error: $id</h1><div class='tracy-inner'>" . nl2br(Helpers::escapeHtml($e)) . '</div>';
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.
*/
Expand Down
53 changes: 49 additions & 4 deletions src/Tracy/Bar/assets/bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<h1>Loading…</h1><div class="tracy-inner"><p>Loading panel content…</p></div>';
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'),
Expand Down Expand Up @@ -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 = '<h1>Error</h1><div class="tracy-inner"><p>Lazy panel content is no longer available. It may have expired from the session.</p></div>';
}
})
.catch(() => {
elem.innerHTML = elem.tracyContent = '<h1>Error</h1><div class="tracy-inner"><p>Failed to load lazy panel content.</p></div>';
});
}


is(mode) {
return this.elem.classList.contains(mode);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Tracy/Bar/assets/bar.latte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
{/switch}

{foreach $panels as $panel}
<li n:if="$panel->tab">{if $panel->panel}<a href="#" rel="tracy-debug-panel-{$panel->id}">{trim($panel->tab)|noescape}</a>
<li n:if="$panel->tab">{if $panel->panel || ($panel->lazy ?? false)}<a href="#" rel="tracy-debug-panel-{$panel->id}">{trim($panel->tab)|noescape}</a>
{else}<span>{trim($panel->tab)|noescape}</span>
{/if}</li>
{/foreach}
Expand Down
2 changes: 1 addition & 1 deletion src/Tracy/Bar/assets/panels.latte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<div itemscope>
{foreach $panels as $panel}
{do $content = $panel->panel ? $panel->panel . "\n" . $icons : ''}
<div class={[tracy-panel, $type !== ajax ? tracy-panel-persist, 'tracy-panel-' . $type]} id="tracy-debug-panel-{$panel->id}" data-tracy-content='{str_replace(['&', "'"], ['&amp;', '&#039;'], $content)|noescape}'></div>
<div class={[tracy-panel, $type !== ajax ? tracy-panel-persist, 'tracy-panel-' . $type]} id="tracy-debug-panel-{$panel->id}" data-tracy-lazy="{($panel->lazy ?? false) ? '1' : null}" data-tracy-content='{str_replace(['&', "'"], ['&amp;', '&#039;'], $content)|noescape}'></div>
{/foreach}
<meta itemprop=tracy-snapshot content={=Dumper::$liveSnapshot[0] ?? []|json}>
{do Dumper::$liveSnapshot = []}
Expand Down
6 changes: 3 additions & 3 deletions src/Tracy/Bar/dist/bar.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ echo "\n";
foreach ($panels as $panel) /* pos 16:2 */ {
if ($panel->tab) /* pos 17:7 */ {
echo ' <li>';
if ($panel->panel) /* pos 17:26 */ {
if ($panel->panel || ($panel->lazy ?? false)) /* pos 17:26 */ {
echo '<a href="#" rel="tracy-debug-panel-';
echo Tracy\Helpers::escapeHtml($panel->id) /* pos 17:79 */;
echo Tracy\Helpers::escapeHtml($panel->id) /* pos 17:106 */;
echo '">';
echo trim($panel->tab) /* pos 17:93 */;
echo trim($panel->tab) /* pos 17:120 */;
echo '</a>
';
} else /* pos 18:3 */ {
Expand Down
6 changes: 4 additions & 2 deletions src/Tracy/Bar/dist/panels.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -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(['&', '\''], ['&amp;', '&#039;'], $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(['&', '\''], ['&amp;', '&#039;'], $content) /* pos 14:205 */;
echo '\'></div>
';

Expand Down
Loading