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') . '

' + . '' + . '' + . '' + . '' + . '
KeyValue
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.

' + . ''; + + foreach ($queries as $i => [$query, $time]) { + $html .= ''; + } + + $html .= '
#QueryTime
' . ($i + 1) . '' . htmlspecialchars($query) . '' . $time . '
'; + 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().

+ + + +

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 c85df6c74..f848940cf 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; } @@ -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 @@ -167,10 +182,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(); @@ -178,6 +194,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 66af774a2..8cf8da458 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…

Loading panel content…

'; + 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 d603fdfca..698cc0712 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 fcadd0fa1..523f57aee 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); } }