From fcd68926c59765f9da05e0371da61026479cf999 Mon Sep 17 00:00:00 2001 From: Caleb Porzio Date: Sun, 5 Jul 2026 07:41:27 -0400 Subject: [PATCH 1/2] Defer @aware resolution when folding across template boundaries When a foldable component that consumes @aware props is compiled at the root of a template (an @include partial or a nested component's view), its real parent is only knowable at runtime, so folding baked in the prop defaults. A rendered through a partial inside a folded to the default variant's markup instead of the listbox variant's. Now such components are left unfolded so @aware resolves at runtime, and folded parents detect @aware consumers behind includes and nested component templates so they push their data for that runtime lookup. Co-Authored-By: Claude Fable 5 --- src/BlazeManager.php | 53 +++++++++++++++++++ src/Folder/Folder.php | 11 ++++ src/Parser/Nodes/ComponentNode.php | 3 ++ tests/ComparisonTest.php | 14 +++++ tests/FluxProTest.php | 14 +++++ tests/Folder/FolderTest.php | 20 +++++++ .../components/aware-input-wrapper.blade.php | 1 + .../components/category-options.blade.php | 3 ++ .../views/partials/aware-input.blade.php | 1 + .../views/partials/listbox-options.blade.php | 3 ++ 10 files changed, 123 insertions(+) create mode 100644 tests/fixtures/views/components/aware-input-wrapper.blade.php create mode 100644 tests/fixtures/views/components/category-options.blade.php create mode 100644 tests/fixtures/views/partials/aware-input.blade.php create mode 100644 tests/fixtures/views/partials/listbox-options.blade.php diff --git a/src/BlazeManager.php b/src/BlazeManager.php index eef12d8..3f0aa9e 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/Folder/Folder.php b/src/Folder/Folder.php index eda0cdd..069f79a 100644 --- a/src/Folder/Folder.php +++ b/src/Folder/Folder.php @@ -106,6 +106,17 @@ protected function isSafeToFold(ComponentSource $source, ComponentNode $node): b ) { $dynamicAttributes[$prop] = $node->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 c123ba7..81a4001 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/tests/ComparisonTest.php b/tests/ComparisonTest.php index 155bd2a..f3323d6 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 79118ee..f9b4fef 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 d2f7079..132a860 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/fixtures/views/components/aware-input-wrapper.blade.php b/tests/fixtures/views/components/aware-input-wrapper.blade.php new file mode 100644 index 0000000..f12b42e --- /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 0000000..34bd2bb --- /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 0000000..f12b42e --- /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 0000000..34bd2bb --- /dev/null +++ b/tests/fixtures/views/partials/listbox-options.blade.php @@ -0,0 +1,3 @@ +Photography +Design services +Create new From e0f09939ebf21770c5f175358f1d82e561b4359b Mon Sep 17 00:00:00 2001 From: Caleb Porzio Date: Sun, 5 Jul 2026 07:41:37 -0400 Subject: [PATCH 2/2] Persist unblaze replacements inside cached compiled files Since the temporary fold cache started persisting between compiles, a process reusing a cached compiled file had no in-memory replacement for the @unblaze tokens baked into it, causing an undefined array key at render and silently aborting the fold. Store the replacement content in the compiled file itself so any process that renders it repopulates the map, and purge the cache when a stale token from an older format is encountered. Co-Authored-By: Claude Fable 5 --- src/BladeRenderer.php | 12 +++++++++++- src/Exceptions/StaleUnblazeCacheException.php | 16 ++++++++++++++++ src/Unblaze.php | 18 ++++++++++++++++++ tests/Folder/UnblazeTest.php | 19 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/Exceptions/StaleUnblazeCacheException.php diff --git a/src/BladeRenderer.php b/src/BladeRenderer.php index 89301e0..b1d10c3 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/Exceptions/StaleUnblazeCacheException.php b/src/Exceptions/StaleUnblazeCacheException.php new file mode 100644 index 0000000..30ffe1b --- /dev/null +++ b/src/Exceptions/StaleUnblazeCacheException.php @@ -0,0 +1,16 @@ +' . '<'.'?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/Folder/UnblazeTest.php b/tests/Folder/UnblazeTest.php index 508548e..9318a0c 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 = '';