Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/BladeRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
53 changes: 53 additions & 0 deletions src/BlazeManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/Exceptions/StaleUnblazeCacheException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Livewire\Blaze\Exceptions;

/**
* Thrown when a cached compiled file references an @unblaze token whose
* replacement content is unknown (e.g. it was cached by an older version
* of Blaze that only stored replacements in memory).
*/
class StaleUnblazeCacheException extends \Exception
{
public function __construct(string $token)
{
parent::__construct("Unknown @unblaze token [{$token}] found in a cached compiled file.");
}
}
11 changes: 11 additions & 0 deletions src/Folder/Folder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
3 changes: 3 additions & 0 deletions src/Parser/Nodes/ComponentNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions src/Unblaze.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Livewire\Blaze;

use Livewire\Blaze\Compiler\DirectiveCompiler;
use Livewire\Blaze\Exceptions\StaleUnblazeCacheException;
use Illuminate\Support\Str;

/**
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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]
);
Expand Down
14 changes: 14 additions & 0 deletions tests/ComparisonTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@
BLADE
));

test('foldable aware through include partial', fn () => compare(<<<'BLADE'
<x-foldable.wrapper type="number">
@include('partials.aware-input')
</x-foldable.wrapper>
BLADE
));

test('foldable aware through nested component', fn () => compare(<<<'BLADE'
<x-foldable.wrapper type="number">
<x-aware-input-wrapper />
</x-foldable.wrapper>
BLADE
));

test('foldable boolean attributes', fn () => compare(<<<'BLADE'
<x-foldable.input :readonly="$readonly" />
BLADE,
Expand Down
14 changes: 14 additions & 0 deletions tests/FluxProTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@
BLADE
));

test('listbox with options emitted from an include partial', fn () => compare(<<<'BLADE'
<flux:select variant="listbox" searchable placeholder="Choose...">
@include('partials.listbox-options')
</flux:select>
BLADE
));

test('listbox with options emitted from a nested component', fn () => compare(<<<'BLADE'
<flux:select variant="listbox" searchable placeholder="Choose...">
<x-category-options />
</flux:select>
BLADE
));

test('chart', fn () => compare(<<<'BLADE'
<flux:chart wire:model="data" class="w-full aspect-2/1">
<flux:chart.viewport class="size-full">
Expand Down
20 changes: 20 additions & 0 deletions tests/Folder/FolderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<x-foldable.input-aware />';

$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 = '<x-foldable.input-aware type="number" />';

$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 = '<x-foldable.input-no-blaze />';

Expand Down
19 changes: 19 additions & 0 deletions tests/Folder/UnblazeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@
);
});

test('compiles unblaze blocks when the temporary compiled file was cached by a previous process', function () {
$input = '<x-foldable.input-unblaze name="address" />';

$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 = '<x-foldable.input-unblaze :name="$field" />';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<x-foldable.input-aware />
3 changes: 3 additions & 0 deletions tests/fixtures/views/components/category-options.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<flux:select.option>Photography</flux:select.option>
<flux:select.option>Design services</flux:select.option>
<flux:select.option.create wire:click="startCreate">Create new</flux:select.option.create>
1 change: 1 addition & 0 deletions tests/fixtures/views/partials/aware-input.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<x-foldable.input-aware />
3 changes: 3 additions & 0 deletions tests/fixtures/views/partials/listbox-options.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<flux:select.option>Photography</flux:select.option>
<flux:select.option>Design services</flux:select.option>
<flux:select.option.create wire:click="startCreate">Create new</flux:select.option.create>
Loading