Skip to content
Merged
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
136 changes: 136 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Blaze will automatically optimize it during compilation, pre-rendering the stati
## Table of contents

- [When to use @blaze](#when-to-use-blaze)
- [Making impure components Blaze-eligible with @unblaze](#making-impure-components-blaze-eligible-with-unblaze)
- [Performance expectations](#performance-expectations)
- [Debugging](#debugging)
- [AI assistant integration](#ai-assistant-integration)
Expand Down Expand Up @@ -284,6 +285,141 @@ When a component can't be folded due to dynamic content, Blaze automatically fal
- **Test thoroughly**: After adding `@blaze`, verify the component still works correctly across different requests
- **Blaze is forgiving**: If a component can't be optimized, Blaze will automatically fall back to normal rendering

## Making impure components Blaze-eligible with @unblaze

Sometimes you have a component that's *mostly* static, but contains a small dynamic section that would normally prevent it from being folded (like `$errors`, `request()`, or `session()`). The `@unblaze` directive lets you "punch a hole" in an otherwise static component, keeping the static parts optimized while allowing specific sections to remain dynamic.

### The problem

Imagine a form input component that's perfect for `@blaze` - except it needs to show validation errors:

```blade
{{-- ❌ Can't use @blaze - $errors prevents optimization --}}

<div>
<label>{{ $label }}</label>
<input type="text" name="{{ $name }}">

@if($errors->has($name))
<span>{{ $errors->first($name) }}</span>
@endif
</div>
```

Without `@unblaze`, you have to choose: either skip `@blaze` entirely (losing all optimization), or remove the error handling (losing functionality).

### The solution: @unblaze

The `@unblaze` directive creates a dynamic section within a folded component:

```blade
{{-- ✅ Now we can use @blaze! --}}

@blaze

@props(['name', 'label'])

<div>
<label>{{ $label }}</label>
<input type="text" name="{{ $name }}">

@unblaze
@if($errors->has($name))
<span>{{ $errors->first($name) }}</span>
@endif
@endunblaze
</div>
```

**What happens:**
- The `<div>`, `<label>`, and `<input>` are folded (pre-rendered at compile time)
- The error handling inside `@unblaze` remains dynamic (evaluated at runtime)
- You get the best of both worlds: optimization + dynamic functionality

### Using scope to pass data into @unblaze

Sometimes you need to pass component props into the `@unblaze` block. Use the `scope` parameter:

```blade
@blaze

@props(['userId', 'showStatus' => true])

<div>
<h2>User Profile</h2>
{{-- Lots of static markup here --}}

@unblaze(scope: ['userId' => $userId, 'showStatus' => $showStatus])
@if($scope['showStatus'])
<div>User #{{ $scope['userId'] }} - Last seen: {{ session('last_seen') }}</div>
@endif
@endunblaze
</div>
```

**How scope works:**
- Variables captured in `scope:` are encoded into the compiled view
- Inside the `@unblaze` block, access them via `$scope['key']`
- This allows the unblaze section to use component props while keeping the rest folded

### Nested components inside @unblaze

You can render other components inside `@unblaze` blocks, which is useful for extracting reusable dynamic sections:

```blade
@blaze

@props(['name', 'label'])

<div>
<label>{{ $label }}</label>
<input type="text" name="{{ $name }}">

@unblaze(scope: ['name' => $name])
<x-form.errors :name="$scope['name']" />
@endunblaze
</div>
```

```blade
{{-- components/form/errors.blade.php --}}

@props(['name'])

@error($name)
<p>{{ $message }}</p>
@enderror
```

This allows you to keep your error display logic in a separate component while still using it within the unblaze section. The form input remains folded, and only the error component is evaluated at runtime.

### Multiple @unblaze blocks

You can use multiple `@unblaze` blocks in a single component:

```blade
@blaze

<div>
<header>Static Header</header>

@unblaze
<div>Hello, {{ auth()->user()->name }}</div>
@endunblaze

<main>
{{-- Lots of static content --}}
</main>

@unblaze
<input type="hidden" value="{{ csrf_token() }}">
@endunblaze

<footer>Static Footer</footer>
</div>
```

Each `@unblaze` block creates an independent dynamic section, while everything else remains folded.

## Performance expectations

Expand Down
21 changes: 20 additions & 1 deletion src/BladeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace Livewire\Blaze;

use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File;
use Livewire\Blaze\Unblaze;
use ReflectionClass;

class BladeService
Expand All @@ -11,6 +13,10 @@ public function isolatedRender(string $template): string
{
$compiler = app('blade.compiler');

$temporaryCachePath = storage_path('framework/views/blaze/isolated-render/');

File::ensureDirectoryExists($temporaryCachePath);

$factory = app('view');

[$factory, $restoreFactory] = $this->freezeObjectProperties($factory, [
Expand All @@ -23,16 +29,29 @@ public function isolatedRender(string $template): string
]);

[$compiler, $restore] = $this->freezeObjectProperties($compiler, [
'cachePath' => $temporaryCachePath,
'rawBlocks',
'prepareStringsForCompilationUsing' => [],
'prepareStringsForCompilationUsing' => [
function ($input) {
if (Unblaze::hasUnblaze($input)) {
$input = Unblaze::processUnblazeDirectives($input);
}

return $input;
}
],
'path' => null,
]);

try {
$result = $compiler->render($template);

$result = Unblaze::replaceUnblazePrecompiledDirectives($result);
} finally {
$restore();
$restoreFactory();

File::deleteDirectory($temporaryCachePath);
}

return $result;
Expand Down
24 changes: 18 additions & 6 deletions src/BlazeServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@

namespace Livewire\Blaze;

use Livewire\Blaze\Directive\BlazeDirective;
use Livewire\Blaze\Tokenizer\Tokenizer;
use Illuminate\Support\ServiceProvider;
use Livewire\Blaze\Memoizer\Memoizer;
use Livewire\Blaze\Walker\Walker;
use Livewire\Blaze\Tokenizer\Tokenizer;
use Livewire\Blaze\Parser\Parser;
use Livewire\Blaze\Memoizer\Memoizer;
use Livewire\Blaze\Folder\Folder;
use Livewire\Blaze\Directive\BlazeDirective;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Blade;

class BlazeServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->registerBlazeManager();
$this->registerBlazeDirectiveFallback();
$this->registerBlazeDirectiveFallbacks();
$this->registerBladeMacros();
$this->interceptBladeCompilation();
$this->interceptViewCacheInvalidation();
Expand Down Expand Up @@ -44,8 +45,19 @@ protected function registerBlazeManager(): void
$this->app->bind('blaze', fn ($app) => $app->make(BlazeManager::class));
}

protected function registerBlazeDirectiveFallback(): void
protected function registerBlazeDirectiveFallbacks(): void
{
Blade::directive('unblaze', function ($expression) {
return ''
. '<'.'?php $__getScope = fn($scope = []) => $scope; ?>'
. '<'.'?php if (isset($scope)) $__scope = $scope; ?>'
. '<'.'?php $scope = $__getScope('.$expression.'); ?>';
});

Blade::directive('endunblaze', function () {
return '<'.'?php if (isset($__scope)) { $scope = $__scope; unset($__scope); } ?>';
});

BlazeDirective::registerFallback();
}

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

namespace Livewire\Blaze;

use Illuminate\Support\Arr;

class Unblaze
{
static $unblazeScopes = [];
static $unblazeReplacements = [];

public static function storeScope($token, $scope = [])
{
static::$unblazeScopes[$token] = $scope;
}

public static function hasUnblaze(string $template): bool
{
return str_contains($template, '@unblaze');
}

public static function processUnblazeDirectives(string $template)
{
$compiler = static::getHackedBladeCompiler();

$expressionsByToken = [];

$compiler->directive('unblaze', function ($expression) use (&$expressionsByToken) {
$token = str()->random(10);

$expressionsByToken[$token] = $expression;

return '[STARTUNBLAZE:'.$token.']';
});

$compiler->directive('endunblaze', function () {
return '[ENDUNBLAZE]';
});

$result = $compiler->compileStatementsMadePublic($template);

$result = preg_replace_callback('/(\[STARTUNBLAZE:([0-9a-zA-Z]+)\])(.*?)(\[ENDUNBLAZE\])/s', function ($matches) use (&$expressionsByToken) {
$token = $matches[2];
$expression = $expressionsByToken[$token];
$innerContent = $matches[3];

static::$unblazeReplacements[$token] = $innerContent;

return ''
. '[STARTCOMPILEDUNBLAZE:'.$token.']'
. '<'.'?php \Livewire\Blaze\Unblaze::storeScope("'.$token.'", '.$expression.') ?>'
. '[ENDCOMPILEDUNBLAZE]';
}, $result);

return $result;
}

public static function replaceUnblazePrecompiledDirectives(string $template)
{
if (str_contains($template, '[STARTCOMPILEDUNBLAZE')) {
$template = preg_replace_callback('/(\[STARTCOMPILEDUNBLAZE:([0-9a-zA-Z]+)\])(.*?)(\[ENDCOMPILEDUNBLAZE\])/s', function ($matches) use (&$expressionsByToken) {
$token = $matches[2];

$innerContent = static::$unblazeReplacements[$token];

$scope = static::$unblazeScopes[$token];

$runtimeScopeString = var_export($scope, true);

return ''
. '<'.'?php if (isset($scope)) $__scope = $scope; ?>'
. '<'.'?php $scope = '.$runtimeScopeString.'; ?>'
. $innerContent
. '<'.'?php if (isset($__scope)) { $scope = $__scope; unset($__scope); } ?>';
}, $template);
}

return $template;
}

public static function getHackedBladeCompiler()
{
$instance = new class (
app('files'),
storage_path('framework/views'),
) extends \Illuminate\View\Compilers\BladeCompiler {
/**
* Make this method public...
*/
public function compileStatementsMadePublic($template)
{
return $this->compileStatements($template);
}

/**
* Tweak this method to only process custom directives so we
* can restrict rendering solely to @island related directives...
*/
protected function compileStatement($match)
{
if (str_contains($match[1], '@')) {
$match[0] = isset($match[3]) ? $match[1].$match[3] : $match[1];
} elseif (isset($this->customDirectives[$match[1]])) {
$match[0] = $this->callCustomDirective($match[1], Arr::get($match, 3));
} elseif (method_exists($this, $method = 'compile'.ucfirst($match[1]))) {
// Don't process through built-in directive methods...
// $match[0] = $this->$method(Arr::get($match, 3));

// Just return the original match...
return $match[0];
} else {
return $match[0];
}

return isset($match[3]) ? $match[0] : $match[0].$match[2];
}
};

return $instance;
}
}
6 changes: 5 additions & 1 deletion tests/BenchmarkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ function clearCache() {
$files = glob(__DIR__ . '/../vendor/orchestra/testbench-core/laravel/storage/framework/views/*');
foreach ($files as $file) {
if (!str_ends_with($file, '.gitignore')) {
unlink($file);
if (is_dir($file)) {
rmdir($file);
} else {
unlink($file);
}
}
}
}
Expand Down
Loading