diff --git a/src/BladeRenderer.php b/src/BladeRenderer.php index 89301e0e..b1d10c3c 100644 --- a/src/BladeRenderer.php +++ b/src/BladeRenderer.php @@ -8,6 +8,7 @@ use Illuminate\View\Compilers\BladeCompiler; use Illuminate\View\Component; use Illuminate\View\ComponentSlot; +use Livewire\Blaze\Exceptions\StaleUnblazeCacheException; use Livewire\Blaze\Parser\Attribute; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\SlotNode; @@ -139,7 +140,16 @@ function ($input) { $restoreRuntime(); } - $result = Unblaze::replaceUnblazePrecompiledDirectives($result); + try { + $result = Unblaze::replaceUnblazePrecompiledDirectives($result); + } catch (StaleUnblazeCacheException $th) { + // The token came from a compiled file cached by an older version of Blaze. + // Delete the cache so it gets regenerated on the next compile, and let + // the fold be aborted so the component renders normally this time... + $this->deleteTemporaryCacheDirectory(); + + throw $th; + } return $result; } diff --git a/src/BlazeManager.php b/src/BlazeManager.php index eef12d80..3f0aa9eb 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -21,6 +21,7 @@ use Livewire\Blaze\Support\Directives; use Livewire\Blaze\Support\ComponentSource; use Livewire\Blaze\Parser\Nodes\SlotNode; +use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Support\AttributeParser; class BlazeManager @@ -79,6 +80,10 @@ public function compile(string $template, ?string $path = null): string $ast = $this->walker->walk( nodes: $this->parser->parse($clean), preCallback: function ($node) use (&$dataStack) { + if ($node instanceof ComponentNode) { + $node->hasComponentAncestors = $dataStack !== []; + } + if ($node instanceof ComponentNode && $node->children) { $dataStack[] = $node->attributes; @@ -417,10 +422,58 @@ protected function hasAwareDescendant(ComponentNode|SlotNode $node): bool if ($this->hasAwareDescendant($child)) { return true; } + + // Components rendered from within the child's own template aren't + // lexically visible here, so we need to scan its source too... + if ($this->templateHasAwareComponents($source)) { + return true; + } } elseif ($child instanceof SlotNode) { if ($this->hasAwareDescendant($child)) { return true; } + } elseif ($child instanceof TextNode) { + // Included partials can render components that use @aware... + if (preg_match('/@include|@each/', $child->content)) { + return true; + } + } + } + + return false; + } + + /** + * Recursively check if a component's template renders components that use @aware. + */ + protected function templateHasAwareComponents(ComponentSource $source, array &$visited = []): bool + { + if (! $source->exists() || isset($visited[$source->path])) { + return false; + } + + $visited[$source->path] = true; + + $content = $source->content(); + + // Includes and dynamically resolved components can't be inspected statically... + if (preg_match('/@include|@each|delegate-component|dynamic-component/', $content)) { + return true; + } + + preg_match_all('/<(x-|x:|flux:)([\w.-]+)/', $content, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $name = $match[1] === 'flux:' ? 'flux::' . $match[2] : $match[2]; + + $childSource = ComponentSource::for($this->blade->componentNameToPath($name)); + + if ($childSource->directives->has('aware')) { + return true; + } + + if ($this->templateHasAwareComponents($childSource, $visited)) { + return true; } } diff --git a/src/Exceptions/StaleUnblazeCacheException.php b/src/Exceptions/StaleUnblazeCacheException.php new file mode 100644 index 00000000..30ffe1ba --- /dev/null +++ b/src/Exceptions/StaleUnblazeCacheException.php @@ -0,0 +1,16 @@ +parentsAttributes[$prop]; } + + // When an @aware prop isn't resolvable from the template itself, the real + // parent is only known at runtime — the template may be rendered inside + // another component through an @include or a slot — so folding here + // would bake in the wrong value... + if (! isset($node->attributes[$prop]) + && ! isset($node->parentsAttributes[$prop]) + && ! $node->hasComponentAncestors + ) { + return false; + } } if (array_key_exists('attributes', $dynamicAttributes)) { diff --git a/src/Parser/Nodes/ComponentNode.php b/src/Parser/Nodes/ComponentNode.php index c123ba7d..81a4001c 100644 --- a/src/Parser/Nodes/ComponentNode.php +++ b/src/Parser/Nodes/ComponentNode.php @@ -10,6 +10,9 @@ class ComponentNode extends Node /** Pre-computed by the Walker before children are compiled to TextNodes. */ public bool $hasAwareDescendants = false; + /** Whether this node is nested inside another component in the same template. */ + public bool $hasComponentAncestors = false; + public function __construct( public string $name, public string $prefix, diff --git a/src/Unblaze.php b/src/Unblaze.php index 403a5592..0008bf93 100644 --- a/src/Unblaze.php +++ b/src/Unblaze.php @@ -3,6 +3,7 @@ namespace Livewire\Blaze; use Livewire\Blaze\Compiler\DirectiveCompiler; +use Livewire\Blaze\Exceptions\StaleUnblazeCacheException; use Illuminate\Support\Str; /** @@ -22,6 +23,14 @@ public static function storeScope($token, $scope = []) static::$unblazeScopes[$token] = $scope; } + /** + * Store the original template content for an @unblaze token. + */ + public static function storeReplacement($token, $content) + { + static::$unblazeReplacements[$token] = $content; + } + /** * Check if a template contains @unblaze directives. */ @@ -57,8 +66,13 @@ public static function processUnblazeDirectives(string $template) static::$unblazeReplacements[$token] = $innerContent; + // The replacement is also stored from within the compiled file itself (base64 + // encoded so it's opaque to later compilation passes) because the file is + // cached on disk and may be reused by a process where the in-memory + // replacement stored above no longer exists... return '' . '[STARTCOMPILEDUNBLAZE:'.$token.']' + . '<'.'?php \Livewire\Blaze\Unblaze::storeReplacement("'.$token.'", base64_decode(\''.base64_encode($innerContent).'\')) ?>' . '<'.'?php \Livewire\Blaze\Unblaze::storeScope("'.$token.'", '.$expression.') ?>' . '[ENDCOMPILEDUNBLAZE:'.$token.']'; }, $result); @@ -83,6 +97,10 @@ public static function replaceUnblazePrecompiledDirectives(string $template) $token = substr($token, 0, -(strlen($trim) + 1)); } + if (! array_key_exists($token, static::$unblazeReplacements)) { + throw new StaleUnblazeCacheException($token); + } + $innerContent = Blaze::compileForUnblaze( static::$unblazeReplacements[$token] ); diff --git a/tests/ComparisonTest.php b/tests/ComparisonTest.php index 155bd2a1..f3323d63 100644 --- a/tests/ComparisonTest.php +++ b/tests/ComparisonTest.php @@ -52,6 +52,20 @@ BLADE )); +test('foldable aware through include partial', fn () => compare(<<<'BLADE' + + @include('partials.aware-input') + + BLADE +)); + +test('foldable aware through nested component', fn () => compare(<<<'BLADE' + + + + BLADE +)); + test('foldable boolean attributes', fn () => compare(<<<'BLADE' BLADE, diff --git a/tests/FluxProTest.php b/tests/FluxProTest.php index 79118ee9..f9b4fef9 100644 --- a/tests/FluxProTest.php +++ b/tests/FluxProTest.php @@ -34,6 +34,20 @@ BLADE )); +test('listbox with options emitted from an include partial', fn () => compare(<<<'BLADE' + + @include('partials.listbox-options') + + BLADE +)); + +test('listbox with options emitted from a nested component', fn () => compare(<<<'BLADE' + + + + BLADE +)); + test('chart', fn () => compare(<<<'BLADE' diff --git a/tests/Folder/FolderTest.php b/tests/Folder/FolderTest.php index d2f7079f..132a8605 100644 --- a/tests/Folder/FolderTest.php +++ b/tests/Folder/FolderTest.php @@ -238,6 +238,26 @@ expect($folded)->toBeInstanceOf(TextNode::class); }); +test('does not fold components with unresolvable aware props at the template root', function () { + // The template may be rendered inside another component at runtime + // (through an @include or a slot), so the aware value is unknowable here... + $input = ''; + + $node = app(Parser::class)->parse($input)[0]; + $folded = app(Folder::class)->fold($node); + + expect($folded)->toBeInstanceOf(ComponentNode::class); +}); + +test('folds components with aware props at the template root when provided directly', function () { + $input = ''; + + $node = app(Parser::class)->parse($input)[0]; + $folded = app(Folder::class)->fold($node); + + expect($folded)->toBeInstanceOf(TextNode::class); +}); + test('does not fold components with no blaze directive', function () { $input = ''; diff --git a/tests/Folder/UnblazeTest.php b/tests/Folder/UnblazeTest.php index 508548e4..9318a0c3 100644 --- a/tests/Folder/UnblazeTest.php +++ b/tests/Folder/UnblazeTest.php @@ -38,6 +38,25 @@ ); }); +test('compiles unblaze blocks when the temporary compiled file was cached by a previous process', function () { + $input = ''; + + $fold = function () use ($input) { + $node = app(Parser::class)->parse($input)[0]; + + return (new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/input-unblaze.blade.php')), app(BladeRenderer::class), app(BladeService::class)))->fold(); + }; + + $first = $fold(); + + // The temporary compiled file persists on disk between processes, but the + // unblaze replacements are stored in memory, so we flush them here to + // simulate a fold happening in a fresh process reusing the cache... + \Livewire\Blaze\Unblaze::flushState(); + + expect($fold())->toBe($first); +}); + test('folds dynamic attributes used inside unblaze directive', function () { $input = ''; diff --git a/tests/fixtures/views/components/aware-input-wrapper.blade.php b/tests/fixtures/views/components/aware-input-wrapper.blade.php new file mode 100644 index 00000000..f12b42e9 --- /dev/null +++ b/tests/fixtures/views/components/aware-input-wrapper.blade.php @@ -0,0 +1 @@ + diff --git a/tests/fixtures/views/components/category-options.blade.php b/tests/fixtures/views/components/category-options.blade.php new file mode 100644 index 00000000..34bd2bbb --- /dev/null +++ b/tests/fixtures/views/components/category-options.blade.php @@ -0,0 +1,3 @@ +Photography +Design services +Create new diff --git a/tests/fixtures/views/partials/aware-input.blade.php b/tests/fixtures/views/partials/aware-input.blade.php new file mode 100644 index 00000000..f12b42e9 --- /dev/null +++ b/tests/fixtures/views/partials/aware-input.blade.php @@ -0,0 +1 @@ + diff --git a/tests/fixtures/views/partials/listbox-options.blade.php b/tests/fixtures/views/partials/listbox-options.blade.php new file mode 100644 index 00000000..34bd2bbb --- /dev/null +++ b/tests/fixtures/views/partials/listbox-options.blade.php @@ -0,0 +1,3 @@ +Photography +Design services +Create new