Skip to content

Render component via view - #214

Draft
ghabriel25 wants to merge 31 commits into
livewire:mainfrom
ghabriel25:direct-view-render
Draft

ghabriel25 wants to merge 31 commits into
livewire:mainfrom
ghabriel25:direct-view-render

Conversation

@ghabriel25

@ghabriel25 ghabriel25 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

The scenario

Rendering a Blaze component via view() produces no output.

view('components.alert')->render(); // ''

This particularly affects class-based components as their views are often compiled by Blaze based on path.

class Alert extends Component
{
    public function render()
    {
        return view('components.alert');
    }
}

To fix this, the user needs to exclude class-based components like so:

Blaze::optimize()
    ->in(resource_path('views/components')
    ->in(resource_path('views/components/alert.blade.php', compile: false);

However, this is unintuitive.

The problem

Compiled Blaze components only contain a function definition and do not produce any output when required:

<?php
if (!function_exists('_e80ab8377fa9b7760ed62de7ee0f017e')):
function _e80ab8377fa9b7760ed62de7ee0f017e() {
?>
<div></div>
<?php } endif; ?>

The solution

Call the function from within the compiled file, using $__path to detect when Laravel is rendering it as a view:

if (! function_exists('...')):
    // Define the function
endif;

if (isset($__path) && ($__path === __FILE__ || realpath($__path) === __FILE__)):
    // Call the function
endif;

The $__path variable comes from File::getRequire() that's used by Blade to render file contents:

public function getRequire($path, array $data = [])
{
    $__path = $path;
    $__data = $data;

    return (static function () use ($__path, $__data) {
        extract($__data, EXTR_SKIP);

        return require $__path;
    })();
}

Fixes #210

@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Benchmark Result: Default

Attempt Blade Blaze Change
#1 * 93.26ms 14.37ms 84.6%
#2 97.35ms 14.50ms 85.1%
#3 96.06ms 14.59ms 84.8%
#4 95.64ms 14.39ms 85%
#5 95.73ms 14.40ms 85%
#6 * 93.03ms 14.23ms 84.7%
#7 95.45ms 14.22ms 85.1%
#8 96.22ms 14.62ms 84.8%
#9 * 98.31ms 14.53ms 85.2%
#10 96.35ms 14.35ms 85.1%
Snapshot 94.83ms 14.45ms 84.8%
Result 96.06ms (~) 14.40ms (~) 85% (~)

Median of 10 attempts (* = outlier, excluded from result), 5000 iterations x 10 rounds, 17.56s total

To run a specific benchmark, comment /benchmark <name>
attributes, aware, class, default, forwarding, merge, named-slots, no-attributes, slot, compilation

@ganyicz

ganyicz commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

@ghabriel25 amazing job!

Makes me curious what caused such a big difference previously. I realize you weren't optimizing that blaze component, we normally use @blaze in the benchmarks but I guess that wouldn't work because it's class-based so Blaze::optimize() is the way (later on, we can just switch all benchmarks to using that). But even then, you would be comparing Blade vs Blade, which doesn't explain 60% performance regression. Not super important, just curious.

Can you try to get the benchmark working in the CI? It can be more accurate than running locally. Looks like you already attempted that with the benchmark-on-demand, that might be tricky. I would try changing the default one instead (ci.yml), that should work.

@ghabriel25

Copy link
Copy Markdown
Contributor Author

The big difference between previous benchmark and current benchmark was I use @blaze directive in class-based component view and not optimizing it inside service provider.

I'm not sure either as why using @blaze directive inside class-based component view would cause performance-regression.

@ganyicz

ganyicz commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Yeah, that's weird because that would mean the component simply wasn't compiled by Blaze so the times should be roughly the same as Blade. Anyway, we don't have to investigate that. I was just curious.

@ghabriel25

Copy link
Copy Markdown
Contributor Author

@ganyicz As for this

Can you try to get the benchmark working in the CI? It can be more accurate than running locally. Looks like you already attempted that with the benchmark-on-demand, that might be tricky. I would try changing the default one instead (ci.yml), that should work.

To be honest, I dont really understand how it works and how to use it.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Benchmark Result: View

Attempt Blade Blaze Change
#1 85.74ms 89.46ms -4.3%
#2 84.70ms 88.06ms -4%
#3 87.36ms 93.53ms -7.1%
#4 86.45ms 90.74ms -5%
#5 87.79ms 91.24ms -3.9%
#6 88.25ms 93.96ms -6.5%
#7 * 90.65ms 95.71ms -5.6%
#8 87.60ms 91.07ms -4%
#9 86.27ms 92.86ms -7.6%
#10 87.97ms 92.53ms -5.2%
Result 87.36ms 91.24ms -4.4%

Median of 10 attempts (* = outlier, excluded from result), 5000 iterations x 10 rounds, 25.65s total

To run a specific benchmark, comment /benchmark <name>
attributes, aware, class, default, forwarding, merge, named-slots, no-attributes, slot, compilation

@ganyicz

ganyicz commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

No worries, I just set it up.

I'm just wondering if we're actually testing the class-based components now.

In the benchmark we have <x-bench.blade.alert />, I think this would resolve to components/bench/blade/alert.blade.php

But the class is named BladeAlert for that to be resolved I'm pretty sure the name would need to be <x-blade-alert>

Or, even better we should match the namespace and put the class in App\View\Components\Bench\Blade\Alert.php so the class is resolved when you do <x-bench.blade.alert>

@ghabriel25

Copy link
Copy Markdown
Contributor Author

Let me change it

@ganyicz

ganyicz commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

The benchmark still wasn't setup correctly, now it is and this is the real result unfortunately (the comment will update with every push)

If you're still up to this I would like to pull the thread a little more before we abandon it and figure out what exactly is causing the regression:

  • Is it the realpath() call? If so we should double check if the one around $__path is needed, it might be normalized already. With the second one we might be able to bake in the result at compile time instead of calling it at render time
  • Worse case would be that it's just the extra work from Blaze, but in theory that should be super cheap, don't see how that would add so much overhead
  • Anything else? Manipulating the $__data array? Maybe the app('blade.runtime) call (these are super expensive, do we even need it? $__blaze should be always available)

But if you want to abandon it or don't feel like working on it that's fine just let me know.

@ghabriel25

ghabriel25 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@ganyicz

  1. I think realpath() is one of the reason
  2. If $__blaze always available, then app(\'blaze.runtime\') shouldnt be called?

I'll investigate this later one last time, if its still causing performance regression then its better to avoid this at the moment

@ganyicz

ganyicz commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Okay cool, thanks! Give it one more try, now you have a reliable benchmark setup at least, you can just keep pushing here and checking that comment

2 - Yeah if that's the case we shouldn't even need the 'app()' call there

@ghabriel25

ghabriel25 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@ganyicz I have tested all approach that I can think about and I can't find happy path for this.

Even with this commit 7274636 still causing -83%. Using flag to avoid nested ob doesnt make it better and the last commit using dual-template makes @extends invalid (double output)

I think #212 is a good choice only if we can determine the path was class-based component which can be tricky (maybe tackled before compile, idk)

@ghabriel25

ghabriel25 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Is there any change? Why it suddenly improved so much?

@ganyicz we dont need to extract $__data again if $__view is true because PhpEngine::evaluatePath() + Filesystem::getRequire() already did that

ob_start(); // PhpEngine

extract($__data, EXTR_SKIP);
require $__path;   // → trigger → Blaze function

return ltrim(ob_get_clean()); // PhpEngine 

So we need to

  • remove that (optionally)
  • move ob_start() inside else before endif
  • only echo ltrim(ob_get_clean()) from blaze function if $__view === false

@ghabriel25

ghabriel25 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

The remaining regression comes from realpath() comparison, I'm not sure how to bake it at compile time.

We could use another short-circuit check (tested: not working)

if (isset($__path) && ($__path === __FILE__ || basename($__path) === basename(__FILE__) || realpath($__path) === realpath(__FILE__)))

Laravel’s Compiler::getCompiledPath produces a unique filename. Basename collision risk is negligible.

@ganyicz

ganyicz commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Hey @ghabriel25

Yeah I figured it out yesterday, sorry for not writing an update, it was too late lol

Here's what was going on (click to expand):
It didn't make sense to me why the code was so much slower, here's how I thought about it:

The benchmark showed that Blade + Blaze compilation was 80% slower than Blade alone, meaning the Blaze part alone adds 80% of how long Blade takes. But that doesn't make sense because Blaze is ~90% faster than Blade so it should only add ~10% of how long Blade takes (plus, when rendering via view we don't need the attribute bag etc. so it should be even faster)

So I wanted to figure out exactly what part of the code is making it that much slower, I started stripping everything down, benchmarking it, until I was only left with this:

<?php
if (!function_exists('_hash')):
function _hash($__blaze, $__data = [], $__slots = [], $__bound = [], $__keys = [], $__this = null) {
?>
<div></div><?php
} endif;
if (true) {
_1639f578bedf972e908202bd4387b693($__blaze, $__data, [], [], [], null);
}
?> 

And this was still ~20% slower than Blade.

That didn't feel right as it's just a function call with nothing in it. I asked Astra what could possibly add overhead here and it figured out the culprit was OPCache. Basically the file was never cached by OPCache so every single render added overhead from compiling the PHP code - which wouldn't happen in a real environment. So basically the benchmark wasn't representative of real production environment. I fixed that in this PR: #215 so now we're seeing realistic numbers.

If you're running the benchmarks locally you should also add these to your php.ini to get the same results:

opcache.enable_cli=1
opcache.file_update_protection=0

Anyway, thanks a lot for picking it up!

To your points:

  • I think we do need that extract call because we don't have access to all the variables from inside the function, right?
  • Agreed on the output buffer, we don't need that for the view render, Blade will do that itself.
  • I don't understand the issue with the path comparison, can you elaborate please? What are the values of the __FILE__ and $__path and why are they not matching?

@ganyicz

ganyicz commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

@ghabriel25

About this, I've tried it and this is what I found. The test below only passes when it run by itself (no batching test), when I run full test, it always fails.

Hmm, that's strange, why does it work when you run the test by itself but not with the full test suite?

That sounds like a caching issue, maybe there should be a view:clear in beforeEach inside those tests?

@ghabriel25

ghabriel25 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Already did that too but still the same. Maybe its related to OPCache, idk. You know about that better than me.

Update
Actually its already there

// IntegrationTest.php
beforeEach(fn () => Artisan::call('view:clear'));
image image

@ganyicz

ganyicz commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Yeah I'm not sure, but we need to understand why it happens before we apply a fix like compiling the echoes, otherwise how do we know if that's the right fix if we don't understand the problem?

@ghabriel25

Copy link
Copy Markdown
Contributor Author

@ganyicz I found something that might help to understand this

Inside IntegrationTest,

test('echo handlers work for direct view renders', function () {
    Blade::stringable(fn (Stringable $v) => $v->upper());

    Blaze::optimize()->in(fixture_path('views/components'));

    expect(view('components.alert', ['message' => str('hello')])->render())->toBe('<div>HELLO</div>');
});

test('renders components as views', function () {
    Blaze::optimize()->in(fixture_path('views/components'));

    expect(view('components.alert', ['message' => 'Hello world'])->render())->toBe('<div>Hello world</div>');
});

This order will pass on batch run also for single run. Try to flip the order, echo handlers work for direct view renders will fails on batch run but passes on single run

@ganyicz

ganyicz commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Hmm, try to look at the compiled files in vendor/testbench-core/laravel maybe that will tell you something

Also it might be that the function is already defined so the second test is still using the original function

@ghabriel25

Copy link
Copy Markdown
Contributor Author

I'll leave it for now as it-is to prevent cache-poisoning when test run in batch

renders components as views
        │
        └── compiles alert.php
              └── no echo handler
                    └── cached has no applyEchoHandler()

echo handlers work...
        │
        ├── registers handler
        │
        └── loads SAME compiled alert.php
              └── using cached so doesnt apply echo handler

@ganyicz

ganyicz commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Ah I see so it's just an issue of how our tests are set up. We compile the same component two different ways.

In that case we should just change the tests, not the code, right?

@ghabriel25

Copy link
Copy Markdown
Contributor Author

@ganyicz doesnt it break when user render same component but different props just like test setup?

@ganyicz

ganyicz commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

If I understand it correctly it depends on whether there is an echo handler registered, not props.

The echo handler should be registered in a service provider for the entire app so it wouldn't change between component renders.

But double check it, make sure you understand it and let me know, I'll have a look at it later.

@ghabriel25

ghabriel25 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

I just tested it on Livewire app and yes, it actually rendered as expected without the echo handler

public function boot()
{
    Blade::stringable(fn (Stringable $v) => $v->upper());

    Blaze::optimize()->in(resource_path('views/components'));
}
<x-alert message="Hello" />

<x-alert :message="str('world')" />
image

I have removed the echo handler. Now we can focus on improving the performance like how to determine when we should append $__blaze->pushData($__data);. Currently, its always there whether the component has @aware or not

@ganyicz

ganyicz commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

I don't think we should be appending $__blaze->pushData($__data).

The issue was about @aware reading wrong component stack.

Meaning if you put the @aware directive into a class-based component that's inside a non-Blaze component, it will only read Blaze aware data because we have our own aware compiler with a separate data stack.

I just pushed a commit with what I think the fix should look like.

@ghabriel25

ghabriel25 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@ganyicz

Meaning if you put the @aware directive into a class-based component that's inside a non-Blaze component, it will only read Blaze aware data because we have our own aware compiler with a separate data stack.

Try to flip the scenario and this will break if you put @aware directive into a blaze/non-blaze component thats's inside class-based component which has @props directive just like failed test on last commit. What do you think?

@ganyicz

ganyicz commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

It should only break if you put a Blaze component with aware into a class-based component, which is a documented limitation

The goal here is to make the class-based components behave identical to regular Blade, even though their view might have been compiled by Blaze

Related to that, as I mentioned before it would be ideal if we wrote the tests as comparison tests, that would force us to consider what is actually important - parity with Blade - instead of making up new scenarios.

@ghabriel25

ghabriel25 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

I have adjusted the test based on our goal. I've tried to write comparison test on local and it passes so it basically behave like regular blade component.

However I think we still missing something here as this test still fails

test('aware resolves parent data on class-based component', function () {
    Blaze::optimize()->in(fixture_path('views/components'));

    $html = Blade::render('<x-wrapper type="number"><x-aware /></x-wrapper>');

    expect($html)->toContain('type="number"');
});

your fix e44d58d works for non-Blaze parent -> view class

I have pushed c10efc5 which I think solve both

  • non-Blaze parent -> view class
  • Blaze parent -> view class

and I'd stop here

@ganyicz

ganyicz commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

I'm pretty sure that test would fail even before this PR, per readme:

Cross boundary @aware between Blade and Blaze [is not supported]
Both parent and child must use Blaze for values to propagate

I'm not against solving that (would be nice to get rid of the rest of limitations) but let's make that a separate PR, if we're going to allow Blaze -> Blade, we should also make sure that Blade -> Blaze works even outside of views so it's not confusing.

@ghabriel25

Copy link
Copy Markdown
Contributor Author

I see. Then I'll mark it skip for now and change things back as your commit

@ghabriel25

ghabriel25 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

@ganyicz I have cleaned up things and this is the compiled output

<?php
if (!function_exists('_2b5dbee582313bd37e1d6aecb4c2e629')):
function _2b5dbee582313bd37e1d6aecb4c2e629($__blaze, $__data = [], $__slots = [], $__bound = [], $__keys = [], $__this = null, $__view = false) {
if ($__view):
$__bladeCompiler = $__blaze->compiler;
if (($__data['attributes'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) {
$attributes = new \Illuminate\View\ComponentAttributeBag($__data['attributes']->all()); unset($__data['attributes']);
} else {
$attributes ??= new \Illuminate\View\ComponentAttributeBag([]);
}
extract($__data, EXTR_SKIP);
else:
$__env = $__blaze->env;

if (($__data['attributes'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data['attributes']->all(); unset($__data['attributes']); }
extract($__slots, EXTR_SKIP); unset($__slots);
extract($__data, EXTR_SKIP);
$attributes = \Livewire\Blaze\Runtime\BlazeAttributeBag::make($__data, $__bound, $__keys);
unset($__data, $__bound, $__keys);
ob_start();
endif;
?>

// template

<?php
if (!$__view) { echo ltrim(ob_get_clean()); }
} endif;
if (isset($__path) && ($__path === __FILE__ || realpath($__path) === __FILE__)) {
_2b5dbee582313bd37e1d6aecb4c2e629($__blaze, $__data, [], [], [], $__this ?? null, true);
}
?>

I'll leave it as it is for now but let me know if you find another regression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optimizing directory with Blaze::optimize()->in() renders class-based templates as empty output

2 participants