Skip to content

Commit 96fdd5c

Browse files
authored
Merge pull request #10 from livewire/unblaze
Unblaze
2 parents 76bd2b7 + b533e4a commit 96fdd5c

File tree

11 files changed

+558
-8
lines changed

11 files changed

+558
-8
lines changed

README.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Blaze will automatically optimize it during compilation, pre-rendering the stati
8585
## Table of contents
8686

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

288+
## Making impure components Blaze-eligible with @unblaze
289+
290+
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.
291+
292+
### The problem
293+
294+
Imagine a form input component that's perfect for `@blaze` - except it needs to show validation errors:
295+
296+
```blade
297+
{{-- ❌ Can't use @blaze - $errors prevents optimization --}}
298+
299+
<div>
300+
<label>{{ $label }}</label>
301+
<input type="text" name="{{ $name }}">
302+
303+
@if($errors->has($name))
304+
<span>{{ $errors->first($name) }}</span>
305+
@endif
306+
</div>
307+
```
308+
309+
Without `@unblaze`, you have to choose: either skip `@blaze` entirely (losing all optimization), or remove the error handling (losing functionality).
310+
311+
### The solution: @unblaze
312+
313+
The `@unblaze` directive creates a dynamic section within a folded component:
314+
315+
```blade
316+
{{-- ✅ Now we can use @blaze! --}}
317+
318+
@blaze
319+
320+
@props(['name', 'label'])
321+
322+
<div>
323+
<label>{{ $label }}</label>
324+
<input type="text" name="{{ $name }}">
325+
326+
@unblaze
327+
@if($errors->has($name))
328+
<span>{{ $errors->first($name) }}</span>
329+
@endif
330+
@endunblaze
331+
</div>
332+
```
333+
334+
**What happens:**
335+
- The `<div>`, `<label>`, and `<input>` are folded (pre-rendered at compile time)
336+
- The error handling inside `@unblaze` remains dynamic (evaluated at runtime)
337+
- You get the best of both worlds: optimization + dynamic functionality
338+
339+
### Using scope to pass data into @unblaze
340+
341+
Sometimes you need to pass component props into the `@unblaze` block. Use the `scope` parameter:
342+
343+
```blade
344+
@blaze
345+
346+
@props(['userId', 'showStatus' => true])
347+
348+
<div>
349+
<h2>User Profile</h2>
350+
{{-- Lots of static markup here --}}
351+
352+
@unblaze(scope: ['userId' => $userId, 'showStatus' => $showStatus])
353+
@if($scope['showStatus'])
354+
<div>User #{{ $scope['userId'] }} - Last seen: {{ session('last_seen') }}</div>
355+
@endif
356+
@endunblaze
357+
</div>
358+
```
359+
360+
**How scope works:**
361+
- Variables captured in `scope:` are encoded into the compiled view
362+
- Inside the `@unblaze` block, access them via `$scope['key']`
363+
- This allows the unblaze section to use component props while keeping the rest folded
364+
365+
### Nested components inside @unblaze
366+
367+
You can render other components inside `@unblaze` blocks, which is useful for extracting reusable dynamic sections:
368+
369+
```blade
370+
@blaze
371+
372+
@props(['name', 'label'])
373+
374+
<div>
375+
<label>{{ $label }}</label>
376+
<input type="text" name="{{ $name }}">
377+
378+
@unblaze(scope: ['name' => $name])
379+
<x-form.errors :name="$scope['name']" />
380+
@endunblaze
381+
</div>
382+
```
383+
384+
```blade
385+
{{-- components/form/errors.blade.php --}}
386+
387+
@props(['name'])
388+
389+
@error($name)
390+
<p>{{ $message }}</p>
391+
@enderror
392+
```
393+
394+
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.
395+
396+
### Multiple @unblaze blocks
397+
398+
You can use multiple `@unblaze` blocks in a single component:
399+
400+
```blade
401+
@blaze
402+
403+
<div>
404+
<header>Static Header</header>
405+
406+
@unblaze
407+
<div>Hello, {{ auth()->user()->name }}</div>
408+
@endunblaze
409+
410+
<main>
411+
{{-- Lots of static content --}}
412+
</main>
413+
414+
@unblaze
415+
<input type="hidden" value="{{ csrf_token() }}">
416+
@endunblaze
417+
418+
<footer>Static Footer</footer>
419+
</div>
420+
```
421+
422+
Each `@unblaze` block creates an independent dynamic section, while everything else remains folded.
287423

288424
## Performance expectations
289425

src/BladeService.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
namespace Livewire\Blaze;
44

55
use Illuminate\Support\Facades\Event;
6+
use Illuminate\Support\Facades\File;
7+
use Livewire\Blaze\Unblaze;
68
use ReflectionClass;
79

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

16+
$temporaryCachePath = storage_path('framework/views/blaze/isolated-render/');
17+
18+
File::ensureDirectoryExists($temporaryCachePath);
19+
1420
$factory = app('view');
1521

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

2531
[$compiler, $restore] = $this->freezeObjectProperties($compiler, [
32+
'cachePath' => $temporaryCachePath,
2633
'rawBlocks',
27-
'prepareStringsForCompilationUsing' => [],
34+
'prepareStringsForCompilationUsing' => [
35+
function ($input) {
36+
if (Unblaze::hasUnblaze($input)) {
37+
$input = Unblaze::processUnblazeDirectives($input);
38+
}
39+
40+
return $input;
41+
}
42+
],
2843
'path' => null,
2944
]);
3045

3146
try {
3247
$result = $compiler->render($template);
48+
49+
$result = Unblaze::replaceUnblazePrecompiledDirectives($result);
3350
} finally {
3451
$restore();
3552
$restoreFactory();
53+
54+
File::deleteDirectory($temporaryCachePath);
3655
}
3756

3857
return $result;

src/BlazeServiceProvider.php

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@
22

33
namespace Livewire\Blaze;
44

5-
use Livewire\Blaze\Directive\BlazeDirective;
6-
use Livewire\Blaze\Tokenizer\Tokenizer;
7-
use Illuminate\Support\ServiceProvider;
8-
use Livewire\Blaze\Memoizer\Memoizer;
95
use Livewire\Blaze\Walker\Walker;
6+
use Livewire\Blaze\Tokenizer\Tokenizer;
107
use Livewire\Blaze\Parser\Parser;
8+
use Livewire\Blaze\Memoizer\Memoizer;
119
use Livewire\Blaze\Folder\Folder;
10+
use Livewire\Blaze\Directive\BlazeDirective;
11+
use Illuminate\Support\ServiceProvider;
12+
use Illuminate\Support\Facades\Blade;
1213

1314
class BlazeServiceProvider extends ServiceProvider
1415
{
1516
public function register(): void
1617
{
1718
$this->registerBlazeManager();
18-
$this->registerBlazeDirectiveFallback();
19+
$this->registerBlazeDirectiveFallbacks();
1920
$this->registerBladeMacros();
2021
$this->interceptBladeCompilation();
2122
$this->interceptViewCacheInvalidation();
@@ -44,8 +45,19 @@ protected function registerBlazeManager(): void
4445
$this->app->bind('blaze', fn ($app) => $app->make(BlazeManager::class));
4546
}
4647

47-
protected function registerBlazeDirectiveFallback(): void
48+
protected function registerBlazeDirectiveFallbacks(): void
4849
{
50+
Blade::directive('unblaze', function ($expression) {
51+
return ''
52+
. '<'.'?php $__getScope = fn($scope = []) => $scope; ?>'
53+
. '<'.'?php if (isset($scope)) $__scope = $scope; ?>'
54+
. '<'.'?php $scope = $__getScope('.$expression.'); ?>';
55+
});
56+
57+
Blade::directive('endunblaze', function () {
58+
return '<'.'?php if (isset($__scope)) { $scope = $__scope; unset($__scope); } ?>';
59+
});
60+
4961
BlazeDirective::registerFallback();
5062
}
5163

src/Unblaze.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
namespace Livewire\Blaze;
4+
5+
use Illuminate\Support\Arr;
6+
7+
class Unblaze
8+
{
9+
static $unblazeScopes = [];
10+
static $unblazeReplacements = [];
11+
12+
public static function storeScope($token, $scope = [])
13+
{
14+
static::$unblazeScopes[$token] = $scope;
15+
}
16+
17+
public static function hasUnblaze(string $template): bool
18+
{
19+
return str_contains($template, '@unblaze');
20+
}
21+
22+
public static function processUnblazeDirectives(string $template)
23+
{
24+
$compiler = static::getHackedBladeCompiler();
25+
26+
$expressionsByToken = [];
27+
28+
$compiler->directive('unblaze', function ($expression) use (&$expressionsByToken) {
29+
$token = str()->random(10);
30+
31+
$expressionsByToken[$token] = $expression;
32+
33+
return '[STARTUNBLAZE:'.$token.']';
34+
});
35+
36+
$compiler->directive('endunblaze', function () {
37+
return '[ENDUNBLAZE]';
38+
});
39+
40+
$result = $compiler->compileStatementsMadePublic($template);
41+
42+
$result = preg_replace_callback('/(\[STARTUNBLAZE:([0-9a-zA-Z]+)\])(.*?)(\[ENDUNBLAZE\])/s', function ($matches) use (&$expressionsByToken) {
43+
$token = $matches[2];
44+
$expression = $expressionsByToken[$token];
45+
$innerContent = $matches[3];
46+
47+
static::$unblazeReplacements[$token] = $innerContent;
48+
49+
return ''
50+
. '[STARTCOMPILEDUNBLAZE:'.$token.']'
51+
. '<'.'?php \Livewire\Blaze\Unblaze::storeScope("'.$token.'", '.$expression.') ?>'
52+
. '[ENDCOMPILEDUNBLAZE]';
53+
}, $result);
54+
55+
return $result;
56+
}
57+
58+
public static function replaceUnblazePrecompiledDirectives(string $template)
59+
{
60+
if (str_contains($template, '[STARTCOMPILEDUNBLAZE')) {
61+
$template = preg_replace_callback('/(\[STARTCOMPILEDUNBLAZE:([0-9a-zA-Z]+)\])(.*?)(\[ENDCOMPILEDUNBLAZE\])/s', function ($matches) use (&$expressionsByToken) {
62+
$token = $matches[2];
63+
64+
$innerContent = static::$unblazeReplacements[$token];
65+
66+
$scope = static::$unblazeScopes[$token];
67+
68+
$runtimeScopeString = var_export($scope, true);
69+
70+
return ''
71+
. '<'.'?php if (isset($scope)) $__scope = $scope; ?>'
72+
. '<'.'?php $scope = '.$runtimeScopeString.'; ?>'
73+
. $innerContent
74+
. '<'.'?php if (isset($__scope)) { $scope = $__scope; unset($__scope); } ?>';
75+
}, $template);
76+
}
77+
78+
return $template;
79+
}
80+
81+
public static function getHackedBladeCompiler()
82+
{
83+
$instance = new class (
84+
app('files'),
85+
storage_path('framework/views'),
86+
) extends \Illuminate\View\Compilers\BladeCompiler {
87+
/**
88+
* Make this method public...
89+
*/
90+
public function compileStatementsMadePublic($template)
91+
{
92+
return $this->compileStatements($template);
93+
}
94+
95+
/**
96+
* Tweak this method to only process custom directives so we
97+
* can restrict rendering solely to @island related directives...
98+
*/
99+
protected function compileStatement($match)
100+
{
101+
if (str_contains($match[1], '@')) {
102+
$match[0] = isset($match[3]) ? $match[1].$match[3] : $match[1];
103+
} elseif (isset($this->customDirectives[$match[1]])) {
104+
$match[0] = $this->callCustomDirective($match[1], Arr::get($match, 3));
105+
} elseif (method_exists($this, $method = 'compile'.ucfirst($match[1]))) {
106+
// Don't process through built-in directive methods...
107+
// $match[0] = $this->$method(Arr::get($match, 3));
108+
109+
// Just return the original match...
110+
return $match[0];
111+
} else {
112+
return $match[0];
113+
}
114+
115+
return isset($match[3]) ? $match[0] : $match[0].$match[2];
116+
}
117+
};
118+
119+
return $instance;
120+
}
121+
}

tests/BenchmarkTest.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ function clearCache() {
3131
$files = glob(__DIR__ . '/../vendor/orchestra/testbench-core/laravel/storage/framework/views/*');
3232
foreach ($files as $file) {
3333
if (!str_ends_with($file, '.gitignore')) {
34-
unlink($file);
34+
if (is_dir($file)) {
35+
rmdir($file);
36+
} else {
37+
unlink($file);
38+
}
3539
}
3640
}
3741
}

0 commit comments

Comments
 (0)