Fix component style/script dedupe leakage

Use per-render instance state for component style/script dedupe in View::fetch so assets are deduped within a render without leaking across tests or subsequent renders.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
580-view-class-steroids
fadrian06 2 weeks ago
parent 7acf6c00b9
commit 8c277dadd6

@ -29,6 +29,11 @@ class View
private string $componentPrefix; private string $componentPrefix;
private string $componentsPath; private string $componentsPath;
private int $fetchDepth = 0;
/** @var array<string, bool> */
private array $styles = [];
/** @var array<string, bool> */
private array $scripts = [];
/** /**
* @param string $path Path to templates directory * @param string $path Path to templates directory
@ -119,110 +124,118 @@ class View
*/ */
public function fetch(string $file, ?array $data = null): string public function fetch(string $file, ?array $data = null): string
{ {
$template = $this->getTemplate($file); if ($this->fetchDepth === 0) {
$this->styles = [];
if (!$this->exists($file)) { $this->scripts = [];
throw new Exception("Template file not found: $template.");
} }
extract($this->vars); $this->fetchDepth++;
if (is_array($data)) { try {
extract($data); $template = $this->getTemplate($file);
if ($this->preserveVars) { if (!$this->exists($file)) {
$this->vars += $data; throw new Exception("Template file not found: $template.");
} }
}
ob_start();
$component = include $template;
switch (true) {
case is_callable($component):
$view = $component();
ob_end_clean();
break;
case $component instanceof Component:
$view = $component->html();
$css = $component->css();
$js = $component->js();
static $styles = []; extract($this->vars);
static $scripts = [];
if ($css && !array_key_exists($template, $styles)) { if (is_array($data)) {
$view .= <<<html extract($data);
<style>
$css
</style>
html;
$styles[$template] = true; if ($this->preserveVars) {
$this->vars += $data;
} }
}
if ($js && !array_key_exists($template, $scripts)) { ob_start();
$view .= <<<html $component = include $template;
<script>
$js switch (true) {
</script> case is_callable($component):
html; $view = $component();
ob_end_clean();
$scripts[$template] = true;
} break;
case $component instanceof Component:
$view = $component->html();
$css = $component->css();
$js = $component->js();
if ($css && !array_key_exists($template, $this->styles)) {
$view .= <<<html
<style>
$css
</style>
html;
$this->styles[$template] = true;
}
if ($js && !array_key_exists($template, $this->scripts)) {
$view .= <<<html
<script>
$js
</script>
html;
$this->scripts[$template] = true;
}
ob_end_clean();
break;
default:
$view = ob_get_clean();
}
ob_end_clean(); preg_match_all(
"/<$this->componentPrefix-(?<component>[a-z-]+)\s*(?<props>([a-z]+=\"[^\"]+\"\s*)*)?\s*\/>/",
$view,
$tagsMatches,
);
break; $tagsMatches = array_filter($tagsMatches);
default:
$view = ob_get_clean();
}
preg_match_all( foreach ($tagsMatches[0] ?? [] as $tagIndex => $match) {
"/<$this->componentPrefix-(?<component>[a-z-]+)\s*(?<props>([a-z]+=\"[^\"]+\"\s*)*)?\s*\/>/", $tag = $match;
$view, $component = $tagsMatches['component'][$tagIndex];
$tagsMatches, $props = $tagsMatches['props'][$tagIndex] ?? '';
);
$tagsMatches = array_filter($tagsMatches); preg_match_all(
'/(?<name>[a-z]+)="(?<value>[^"]+)"/',
$props,
$propsMatches,
);
foreach ($tagsMatches[0] ?? [] as $tagIndex => $match) { $propsMatches = array_filter($propsMatches);
$tag = $match;
$component = $tagsMatches['component'][$tagIndex];
$props = $tagsMatches['props'][$tagIndex] ?? '';
preg_match_all( if ($propsMatches) {
'/(?<name>[a-z]+)="(?<value>[^"]+)"/', $props = [];
$props,
$propsMatches,
);
$propsMatches = array_filter($propsMatches); foreach (array_keys($propsMatches[0]) as $propIndex) {
$name = $propsMatches['name'][$propIndex];
$value = $propsMatches['value'][$propIndex];
$props[$name] = $value;
}
} else {
$props = [];
}
if ($propsMatches) { $component = $this->fetch("$this->componentsPath/$component", $props);
$props = []; $tagPosition = strpos($view, $tag);
foreach (array_keys($propsMatches[0]) as $propIndex) { if ($tagPosition === false) {
$name = $propsMatches['name'][$propIndex]; continue;
$value = $propsMatches['value'][$propIndex];
$props[$name] = $value;
} }
} else {
$props = [];
}
$component = $this->fetch("$this->componentsPath/$component", $props); $view = substr_replace($view, $component, $tagPosition, strlen($tag));
$tagPosition = strpos($view, $tag);
if ($tagPosition === false) {
continue;
} }
$view = substr_replace($view, $component, $tagPosition, strlen($tag)); return $view;
} finally {
$this->fetchDepth--;
} }
return $view;
} }
/** /**

@ -266,124 +266,124 @@ class ViewTest extends TestCase
public static function pagesDataProvider(): array public static function pagesDataProvider(): array
{ {
return [ return [
// [ [
// 'page-with-component-with-old-syntax', 'page-with-component-with-old-syntax',
// <<<'html' <<<'html'
// my-component my-component
// html, html,
// ], ],
// [ [
// 'page-with-component-with-new-syntax', 'page-with-component-with-new-syntax',
// <<<'html' <<<'html'
// my-component my-component
// html, html,
// ], ],
// [ [
// 'page-with-component-with-subcomponent', 'page-with-component-with-subcomponent',
// <<<'html' <<<'html'
// <div> <div>
// my-component-with-subcomponent my-component-with-subcomponent
// subcomponent subcomponent
// </div> </div>
// html, html,
// ], ],
// [ [
// 'page-with-multiple-components', 'page-with-multiple-components',
// <<<'html' <<<'html'
// <ul> <ul>
// <li>my-component</li> <li>my-component</li>
// <li>my-component</li> <li>my-component</li>
// </ul> </ul>
// html, html,
// ], ],
// [ [
// 'page-with-functional-component', 'page-with-functional-component',
// <<<'html' <<<'html'
// my-functional-component my-functional-component
// html, html,
// ], ],
// [ [
// 'page-with-class-component', 'page-with-class-component',
// <<<'html' <<<'html'
// my-class-component my-class-component
// html, html,
// ], ],
// [ [
// 'page-with-class-component-with-styles', 'page-with-class-component-with-styles',
// <<<'html' <<<'html'
// <span class="my-class-component-with-styles"> <span class="my-class-component-with-styles">
// my-class-component-with-styles my-class-component-with-styles
// </span> </span>
// <style> <style>
// .my-class-component-with-styles { .my-class-component-with-styles {
// color: red; color: red;
// } }
// </style> </style>
// html, html,
// ], ],
// [ [
// 'page-with-class-component-with-scripts', 'page-with-class-component-with-scripts',
// <<<'html' <<<'html'
// my-class-component-with-scripts my-class-component-with-scripts
// <script>console.log('my-class-component-with-scripts')</script> <script>console.log('my-class-component-with-scripts')</script>
// html, html,
// ], ],
// [ [
// 'page-with-class-component-that-extends-another-class-component', 'page-with-class-component-that-extends-another-class-component',
// <<<'html' <<<'html'
// another-class-component extended by my-class-component-that-extends-another-class-component another-class-component extended by my-class-component-that-extends-another-class-component
// html, html,
// ], ],
// [ [
// 'page-with-component-with-one-prop', 'page-with-component-with-one-prop',
// <<<'html' <<<'html'
// <html> <html>
// <body> <body>
// <h1>Hello, James</h1> <h1>Hello, James</h1>
// </body> </body>
// </html> </html>
// html, html,
// ['name' => 'James'], ['name' => 'James'],
// ], ],
// [ [
// 'page-with-component-with-one-prop', 'page-with-component-with-one-prop',
// <<<'html' <<<'html'
// <html> <html>
// <body> <body>
// <h1>Hello, Victoria</h1> <h1>Hello, Victoria</h1>
// </body> </body>
// </html> </html>
// html, html,
// ['name' => 'Victoria'], ['name' => 'Victoria'],
// ], ],
// [ [
// 'page-with-component-with-two-props', 'page-with-component-with-two-props',
// <<<'html' <<<'html'
// <html> <html>
// <body> <body>
// <h1>Hello, Astronaut Victoria</h1> <h1>Hello, Astronaut Victoria</h1>
// </body> </body>
// </html> </html>
// html, html,
// ['name' => 'Victoria', 'occupation' => 'Astronaut'], ['name' => 'Victoria', 'occupation' => 'Astronaut'],
// ], ],
// [ [
// 'page-with-three-different-components', 'page-with-three-different-components',
// <<<'html' <<<'html'
// my-component my-component
// my-functional-component my-functional-component
// my-class-component my-class-component
// html, html,
// ], ],
// [ [
// 'page-with-component-with-prop-which-value-contains-spaces-and-numbers', 'page-with-component-with-prop-which-value-contains-spaces-and-numbers',
// <<<'html' <<<'html'
// <h1>Hello, Constantine 1st the Great</h1> <h1>Hello, Constantine 1st the Great</h1>
// html, html,
// ['name' => 'Constantine 1st the Great'], ['name' => 'Constantine 1st the Great'],
// ], ],
[ [
'page-two-same-components-with-one-style-and-script-tag', 'page-two-same-components-with-one-style-and-script-tag',
<<<'html' <<<'html'

Loading…
Cancel
Save