Skip to content

Commit a20e459

Browse files
committed
TwigComponent DataCollector + Profiler Panel
1 parent 9265573 commit a20e459

File tree

14 files changed

+897
-3
lines changed

14 files changed

+897
-3
lines changed

src/TwigComponent/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# CHANGELOG
22

3+
## 2.13.0
4+
5+
- Add profiler integration: `TwigComponentDataCollector` and debug toolbar templates
6+
7+
## 2.12.0
8+
9+
- Added a `debug:twig-component` command.
10+
- Fixed bad exception when the error comes from a Twig template.
11+
- Fixed deprecation with `TemplateCacheWarmer` return type.
12+
313
## 2.11.0
414

515
- Support ...spread operator with html syntax (requires Twig 3.7.0 or higher)

src/TwigComponent/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"symfony/framework-bundle": "^5.4|^6.0",
4040
"symfony/phpunit-bridge": "^6.0",
4141
"symfony/stimulus-bundle": "^2.9.1",
42+
"symfony/stopwatch": "^5.4|^6.0|^7.0",
4243
"symfony/twig-bundle": "^5.4|^6.0",
4344
"symfony/webpack-encore-bundle": "^1.15"
4445
},

src/TwigComponent/config/debug.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\DependencyInjection\Loader\Configurator;
13+
14+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
15+
use Symfony\UX\TwigComponent\DataCollector\TwigComponentDataCollector;
16+
use Symfony\UX\TwigComponent\EventListener\TwigComponentLoggerListener;
17+
18+
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
19+
20+
return static function (ContainerConfigurator $container) {
21+
$container->services()
22+
23+
->set('ux.twig_component.component_logger_listener', TwigComponentLoggerListener::class)
24+
->args([
25+
service('debug.stopwatch')->ignoreOnInvalid(),
26+
])
27+
->tag('kernel.event_subscriber')
28+
29+
->set('ux.twig_component.data_collector', TwigComponentDataCollector::class)
30+
->args([
31+
service('ux.twig_component.component_logger_listener'),
32+
service('twig'),
33+
])
34+
->tag('data_collector', [
35+
'template' => '@TwigComponent/Collector/twig_component.html.twig',
36+
'id' => 'twig_component',
37+
'priority' => 256,
38+
]);
39+
};

src/TwigComponent/src/ComponentRenderer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public function render(MountedComponent $mounted): string
7676
$event->getTemplateIndex(),
7777
)->render($event->getVariables());
7878
} finally {
79-
$this->componentStack->pop();
79+
$mounted = $this->componentStack->pop();
8080

8181
$event = new PostRenderEvent($mounted);
8282
$this->dispatcher->dispatch($event);
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\DataCollector;
13+
14+
use Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
18+
use Symfony\Component\VarDumper\Caster\ClassStub;
19+
use Symfony\Component\VarDumper\Cloner\Data;
20+
use Symfony\UX\TwigComponent\Event\PostRenderEvent;
21+
use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
22+
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
23+
use Symfony\UX\TwigComponent\EventListener\TwigComponentLoggerListener;
24+
use Twig\Environment;
25+
use Twig\Error\LoaderError;
26+
27+
/**
28+
* @author Simon André <[email protected]>
29+
*/
30+
class TwigComponentDataCollector extends AbstractDataCollector implements LateDataCollectorInterface
31+
{
32+
private bool $hasStub;
33+
34+
public function __construct(
35+
private readonly TwigComponentLoggerListener $logger,
36+
private readonly Environment $twig,
37+
) {
38+
$this->hasStub = class_exists(ClassStub::class);
39+
}
40+
41+
public function collect(Request $request, Response $response, \Throwable $exception = null): void
42+
{
43+
}
44+
45+
public function lateCollect(): void
46+
{
47+
$this->collectDataFromLogger();
48+
$this->data = $this->cloneVar($this->data);
49+
}
50+
51+
public function getData(): array|Data
52+
{
53+
return $this->data;
54+
}
55+
56+
public function getName(): string
57+
{
58+
return 'twig_component';
59+
}
60+
61+
public function reset(): void
62+
{
63+
$this->logger->reset();
64+
parent::reset();
65+
}
66+
67+
public function getComponents(): array|Data
68+
{
69+
return $this->data['components'] ?? [];
70+
}
71+
72+
public function getComponentCount(): int
73+
{
74+
return $this->data['component_count'] ?? 0;
75+
}
76+
77+
public function getPeakMemoryUsage(): int
78+
{
79+
return $this->data['peak_memory_usage'] ?? 0;
80+
}
81+
82+
public function getRenders(): array|Data
83+
{
84+
return $this->data['renders'] ?? [];
85+
}
86+
87+
public function getRenderCount(): int
88+
{
89+
return $this->data['render_count'] ?? 0;
90+
}
91+
92+
public function getRenderTime(): int
93+
{
94+
return $this->data['render_time'] ?? 0;
95+
}
96+
97+
private function collectDataFromLogger(): void
98+
{
99+
$components = [];
100+
$renders = [];
101+
$ongoingRenders = [];
102+
103+
foreach ($this->logger->getEvents() as [$event, $profile]) {
104+
if ($event instanceof PreCreateForRenderEvent) {
105+
$componentName = $event->getName();
106+
// $components[$componentName]
107+
}
108+
109+
if ($event instanceof PreRenderEvent) {
110+
$mountedComponent = $event->getMountedComponent();
111+
112+
$metadata = $event->getMetadata();
113+
$componentName = $metadata->getName();
114+
115+
$components[$componentName] ??= [
116+
'name' => $componentName,
117+
'class' => $componentClass = $mountedComponent->getComponent()::class,
118+
'class_stub' => $this->hasStub ? new ClassStub($componentClass) : $componentClass,
119+
'template' => $metadata->getTemplate(),
120+
'template_path' => $this->resolveTemplatePath($metadata->getTemplate()), // defer ? lazy ?
121+
'render_count' => 0,
122+
'render_time' => 0,
123+
];
124+
125+
$renderId = spl_object_id($mountedComponent);
126+
$renders[$renderId] = [
127+
'name' => $componentName,
128+
'class' => $componentClass,
129+
'is_embed' => $event->isEmbedded(),
130+
'input_props' => $mountedComponent->getInputProps(),
131+
'attributes' => $mountedComponent->getAttributes()->all(),
132+
'variables' => $event->getVariables(),
133+
'template_index' => $event->getTemplateIndex(),
134+
'component' => $mountedComponent->getComponent(),
135+
'depth' => \count($ongoingRenders),
136+
'children' => [],
137+
'render_start' => $profile[0],
138+
];
139+
140+
if ($parentId = end($ongoingRenders)) {
141+
$renders[$parentId]['children'][] = $renderId;
142+
}
143+
144+
$ongoingRenders[$renderId] = $renderId;
145+
continue;
146+
}
147+
148+
if ($event instanceof PostRenderEvent) {
149+
$mountedComponent = $event->getMountedComponent();
150+
$componentName = $mountedComponent->getName();
151+
$renderId = spl_object_id($mountedComponent);
152+
153+
$renderTime = ($profile[0] - $renders[$renderId]['render_start']) * 1000;
154+
$renders[$renderId] += [
155+
'render_end' => $profile[0],
156+
'render_time' => $renderTime,
157+
'render_memory' => $profile[1],
158+
];
159+
160+
++$components[$componentName]['render_count'];
161+
$components[$componentName]['render_time'] += $renderTime;
162+
163+
unset($ongoingRenders[$renderId]);
164+
}
165+
}
166+
167+
// Sort by render count DESC
168+
uasort($components, fn ($a, $b) => $b['render_count'] <=> $a['render_count']);
169+
170+
$this->data['components'] = $components;
171+
$this->data['component_count'] = \count($components);
172+
173+
$this->data['renders'] = $renders;
174+
$this->data['render_count'] = \count($renders);
175+
$rootRenders = array_filter($renders, fn (array $r) => 0 === $r['depth']);
176+
$this->data['render_time'] = array_sum(array_column($rootRenders, 'render_time'));
177+
178+
$this->data['peak_memory_usage'] = max([0, ...array_column($renders, 'render_memory')]);
179+
}
180+
181+
private function resolveTemplatePath(string $logicalName): ?string
182+
{
183+
try {
184+
$source = $this->twig->getLoader()->getSourceContext($logicalName);
185+
} catch (LoaderError) {
186+
return null;
187+
}
188+
189+
return $source->getPath();
190+
}
191+
}

src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@
1111

1212
namespace Symfony\UX\TwigComponent\DependencyInjection;
1313

14+
use Symfony\Component\Config\FileLocator;
1415
use Symfony\Component\DependencyInjection\Argument\AbstractArgument;
1516
use Symfony\Component\DependencyInjection\ChildDefinition;
1617
use Symfony\Component\DependencyInjection\ContainerBuilder;
1718
use Symfony\Component\DependencyInjection\Exception\LogicException;
1819
use Symfony\Component\DependencyInjection\Extension\Extension;
20+
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
21+
use Symfony\Component\DependencyInjection\Parameter;
1922
use Symfony\Component\DependencyInjection\Reference;
23+
use Symfony\Component\Stopwatch\Stopwatch;
2024
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
25+
use Symfony\UX\TwigComponent\Command\ComponentDebugCommand;
2126
use Symfony\UX\TwigComponent\ComponentFactory;
2227
use Symfony\UX\TwigComponent\ComponentRenderer;
2328
use Symfony\UX\TwigComponent\ComponentRendererInterface;
@@ -28,6 +33,8 @@
2833
use Symfony\UX\TwigComponent\Twig\ComponentLexer;
2934
use Symfony\UX\TwigComponent\Twig\TwigEnvironmentConfigurator;
3035

36+
use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator;
37+
3138
/**
3239
* @author Kevin Bond <[email protected]>
3340
*
@@ -37,6 +44,8 @@ final class TwigComponentExtension extends Extension
3744
{
3845
public function load(array $configs, ContainerBuilder $container): void
3946
{
47+
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config'));
48+
4049
if (!isset($container->getParameter('kernel.bundles')['TwigBundle'])) {
4150
throw new LogicException('The TwigBundle is not registered in your application. Try running "composer require symfony/twig-bundle".');
4251
}
@@ -91,5 +100,19 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %
91100
$container->register('ux.twig_component.twig.environment_configurator', TwigEnvironmentConfigurator::class)
92101
->setDecoratedService(new Reference('twig.configurator.environment'))
93102
->setArguments([new Reference('ux.twig_component.twig.environment_configurator.inner')]);
103+
104+
$container->register('console.command.stimulus_component_debug', ComponentDebugCommand::class)
105+
->setArguments([
106+
new Parameter('twig.default_path'),
107+
new Reference('ux.twig_component.component_factory'),
108+
new Reference('twig'),
109+
tagged_iterator('twig.component'),
110+
])
111+
->addTag('console.command')
112+
;
113+
114+
if ($container->getParameter('kernel.debug') && class_exists(Stopwatch::class)) {
115+
$loader->load('debug.php');
116+
}
94117
}
95118
}

0 commit comments

Comments
 (0)