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).
';
+ 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..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;
/**
@@ -17,13 +17,22 @@ class Bar
{
/** @var IBarPanel[] */
private array $panels = [];
+
+ /** @var array panel ID => lazy flag */
+ private array $lazyPanels = [];
+
+ /** @var array lazy token => [panel ID, panel] */
+ private array $pendingLazyPanels = [];
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 +42,12 @@ public function addPanel(IBarPanel $panel, ?string $id = null): static
}
$this->panels[$id] = $panel;
+ if ($lazy) {
+ $this->lazyPanels[$id] = true;
+ } else {
+ unset($this->lazyPanels[$id]);
+ }
+
return $this;
}
@@ -74,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());
}
@@ -82,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(),
];
@@ -92,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'];
@@ -127,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) {
@@ -143,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) {
@@ -158,9 +173,16 @@ private function renderPanels(string $suffix = ''): array
foreach ($this->panels as $id => $panel) {
$idHtml = preg_replace('#[^a-z0-9]+#i', '-', $id) . $suffix;
+ $lazyToken = null;
try {
$tab = (string) $panel->getTab();
- $panelHtml = $tab ? $panel->getPanel() : null;
+ 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;
+ }
} catch (\Throwable $e) {
while (ob_get_level() > $obLevel) { // restore ob-level if broken
@@ -170,10 +192,11 @@ private function renderPanels(string $suffix = ''): array
$idHtml = "error-$idHtml";
$tab = "Error in $id";
$panelHtml = "
Error: $id
" . nl2br(Helpers::escapeHtml($e)) . '
';
+ $lazyToken = null;
unset($e);
}
- $panels[] = (object) ['id' => $idHtml, 'tab' => $tab, 'panel' => $panelHtml];
+ $panels[] = (object) ['id' => $idHtml, 'tab' => $tab, 'panel' => $panelHtml, 'lazy' => $lazyToken];
}
restore_error_handler();
@@ -181,6 +204,57 @@ 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
+ {
+ $pendingPanels = $this->pendingLazyPanels;
+ $this->pendingLazyPanels = [];
+ if (!$pendingPanels || !$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 = '