diff --git a/src/Illuminate/View/Compilers/Concerns/CompilesComponents.php b/src/Illuminate/View/Compilers/Concerns/CompilesComponents.php index 0fe45be6d9fa..0790c41c1bff 100644 --- a/src/Illuminate/View/Compilers/Concerns/CompilesComponents.php +++ b/src/Illuminate/View/Compilers/Concerns/CompilesComponents.php @@ -64,7 +64,7 @@ public static function compileClassComponentOpening(string $component, string $a { return implode("\n", [ '', - 'getContainer()->make('.Str::finish($component, '::class').', '.($data ?: '[]').' + (isset($attributes) ? (array) $attributes->getIterator() : [])); ?>', + 'getIterator() : [])); ?>', 'withName('.$alias.'); ?>', 'shouldRender()): ?>', 'startComponent($component->resolveView(), $component->data()); ?>', diff --git a/src/Illuminate/View/Component.php b/src/Illuminate/View/Component.php index 0acee35ca066..4f5186692ae7 100644 --- a/src/Illuminate/View/Component.php +++ b/src/Illuminate/View/Component.php @@ -12,6 +12,48 @@ abstract class Component { + /** + * The properties / methods that should not be exposed to the component. + * + * @var array + */ + protected $except = []; + + /** + * The component alias name. + * + * @var string + */ + public $componentName; + + /** + * The component attributes. + * + * @var \Illuminate\View\ComponentAttributeBag + */ + public $attributes; + + /** + * The view factory instance, if any. + * + * @var \Illuminate\Contracts\View\Factory|null + */ + protected static $factory; + + /** + * The component resolver callback. + * + * @var (\Closure(string, array): Component)|null + */ + protected static $componentsResolver; + + /** + * The cache of blade view names, keyed by contents. + * + * @var array + */ + protected static $bladeViewCache = []; + /** * The cache of public property names, keyed by class. * @@ -27,32 +69,61 @@ abstract class Component protected static $methodCache = []; /** - * The properties / methods that should not be exposed to the component. + * The cache of constructor parameters, keyed by class. * - * @var array + * @var array> */ - protected $except = []; + protected static $constructorParametersCache = []; /** - * The component alias name. + * Get the view / view contents that represent the component. * - * @var string + * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\Support\Htmlable|\Closure|string */ - public $componentName; + abstract public function render(); /** - * The component attributes. + * Resolve the component instance with the given data. * - * @var \Illuminate\View\ComponentAttributeBag + * @param array $data + * @return static */ - public $attributes; + public static function resolve($data) + { + if (static::$componentsResolver) { + return call_user_func(static::$componentsResolver, static::class, $data); + } + + $parameters = static::extractConstructorParameters(); + + $dataKeys = array_keys($data); + + if (empty(array_diff($parameters, $dataKeys))) { + return new static(...array_intersect_key($data, array_flip($parameters))); + } + + return Container::getInstance()->make(static::class, $data); + } /** - * Get the view / view contents that represent the component. + * Extract the constructor parameters for the component. * - * @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\Support\Htmlable|\Closure|string + * @return array */ - abstract public function render(); + protected static function extractConstructorParameters() + { + if (! isset(static::$constructorParametersCache[static::class])) { + $class = new ReflectionClass(static::class); + + $constructor = $class->getConstructor(); + + static::$constructorParametersCache[static::class] = $constructor + ? collect($constructor->getParameters())->map->getName()->all() + : []; + } + + return static::$constructorParametersCache[static::class]; + } /** * Resolve the Blade view or view file that should be used when rendering the component. @@ -72,11 +143,7 @@ public function resolveView() } $resolver = function ($view) { - $factory = Container::getInstance()->make('view'); - - return strlen($view) <= PHP_MAXPATHLEN && $factory->exists($view) - ? $view - : $this->createBladeViewFromString($factory, $view); + return $this->extractBladeViewFromString($view); }; return $view instanceof Closure ? function (array $data = []) use ($view, $resolver) { @@ -88,13 +155,22 @@ public function resolveView() /** * Create a Blade view with the raw component string content. * - * @param \Illuminate\Contracts\View\Factory $factory * @param string $contents * @return string */ - protected function createBladeViewFromString($factory, $contents) + protected function extractBladeViewFromString($contents) { - $factory->addNamespace( + $key = sprintf('%s::%s', static::class, $contents); + + if (isset(static::$bladeViewCache[$key])) { + return static::$bladeViewCache[$key]; + } + + if (strlen($contents) <= PHP_MAXPATHLEN && $this->factory()->exists($contents)) { + return static::$bladeViewCache[$key] = $contents; + } + + $this->factory()->addNamespace( '__components', $directory = Container::getInstance()['config']->get('view.compiled') ); @@ -107,7 +183,7 @@ protected function createBladeViewFromString($factory, $contents) file_put_contents($viewFile, $contents); } - return '__components::'.basename($viewFile, '.blade.php'); + return static::$bladeViewCache[$key] = '__components::'.basename($viewFile, '.blade.php'); } /** @@ -292,4 +368,79 @@ public function shouldRender() { return true; } + + /** + * Get the evaluated view contents for the given view. + * + * @param string|null $view + * @param \Illuminate\Contracts\Support\Arrayable|array $data + * @param array $mergeData + * @return \Illuminate\Contracts\View\View + */ + public function view($view, $data = [], $mergeData = []) + { + return $this->factory()->make($view, $data, $mergeData); + } + + /** + * Get the view factory instance. + * + * @return \Illuminate\Contracts\View\Factory + */ + protected function factory() + { + if (is_null(static::$factory)) { + static::$factory = Container::getInstance()->make('view'); + } + + return static::$factory; + } + + /** + * Flush the component's cached state. + * + * @return void + */ + public static function flushCache() + { + static::$bladeViewCache = []; + static::$constructorParametersCache = []; + static::$methodCache = []; + static::$propertyCache = []; + } + + /** + * Forget the component's factory instance. + * + * @return void + */ + public static function forgetFactory() + { + static::$factory = null; + } + + /** + * Forget the component's resolver callback. + * + * @return void + * + * @internal + */ + public static function forgetComponentsResolver() + { + static::$componentsResolver = null; + } + + /** + * Set the callback that should be used to resolve components within views. + * + * @param \Closure(string $component, array $data): Component $resolver + * @return void + * + * @internal + */ + public static function resolveComponentsUsing($resolver) + { + static::$componentsResolver = $resolver; + } } diff --git a/src/Illuminate/View/Concerns/ManagesEvents.php b/src/Illuminate/View/Concerns/ManagesEvents.php index d6cd9d81b113..10c3bda31190 100644 --- a/src/Illuminate/View/Concerns/ManagesEvents.php +++ b/src/Illuminate/View/Concerns/ManagesEvents.php @@ -4,10 +4,25 @@ use Closure; use Illuminate\Contracts\View\View as ViewContract; +use Illuminate\Support\Arr; use Illuminate\Support\Str; trait ManagesEvents { + /** + * An array of views and whether they have registered "creators". + * + * @var array|true + */ + protected $shouldCallCreators = []; + + /** + * An array of views and whether they have registered "composers". + * + * @var array|true + */ + protected $shouldCallComposers = []; + /** * Register a view creator event. * @@ -17,6 +32,16 @@ trait ManagesEvents */ public function creator($views, $callback) { + if (is_array($this->shouldCallCreators)) { + if ($views == '*') { + $this->shouldCallCreators = true; + } else { + foreach (Arr::wrap($views) as $view) { + $this->shouldCallCreators[$this->normalizeName($view)] = true; + } + } + } + $creators = []; foreach ((array) $views as $view) { @@ -52,6 +77,16 @@ public function composers(array $composers) */ public function composer($views, $callback) { + if (is_array($this->shouldCallComposers)) { + if ($views == '*') { + $this->shouldCallComposers = true; + } else { + foreach (Arr::wrap($views) as $view) { + $this->shouldCallComposers[$this->normalizeName($view)] = true; + } + } + } + $composers = []; foreach ((array) $views as $view) { @@ -174,7 +209,11 @@ protected function addEventListener($name, $callback) */ public function callComposer(ViewContract $view) { - $this->events->dispatch('composing: '.$view->name(), [$view]); + if ($this->shouldCallComposers === true || isset($this->shouldCallComposers[ + $this->normalizeName($view->name()) + ])) { + $this->events->dispatch('composing: '.$view->name(), [$view]); + } } /** @@ -185,6 +224,10 @@ public function callComposer(ViewContract $view) */ public function callCreator(ViewContract $view) { - $this->events->dispatch('creating: '.$view->name(), [$view]); + if ($this->shouldCallCreators === true || isset($this->shouldCallCreators[ + $this->normalizeName((string) $view->name()) + ])) { + $this->events->dispatch('creating: '.$view->name(), [$view]); + } } } diff --git a/src/Illuminate/View/Engines/CompilerEngine.php b/src/Illuminate/View/Engines/CompilerEngine.php index dca6a8710560..5b0234a5d13b 100755 --- a/src/Illuminate/View/Engines/CompilerEngine.php +++ b/src/Illuminate/View/Engines/CompilerEngine.php @@ -23,6 +23,13 @@ class CompilerEngine extends PhpEngine */ protected $lastCompiled = []; + /** + * The view paths that were compiled or are not expired, keyed by the path. + * + * @var array + */ + protected $compiledOrNotExpired = []; + /** * Create a new compiler engine instance. * @@ -51,10 +58,12 @@ public function get($path, array $data = []) // If this given view has expired, which means it has simply been edited since // it was last compiled, we will re-compile the views so we can evaluate a // fresh copy of the view. We'll pass the compiler the path of the view. - if ($this->compiler->isExpired($path)) { + if (! isset($this->compiledOrNotExpired[$path]) && $this->compiler->isExpired($path)) { $this->compiler->compile($path); } + $this->compiledOrNotExpired[$path] = true; + // Once we have the path to the compiled file, we will evaluate the paths with // typical PHP just like any other templates. We also keep a stack of views // which have been rendered for right exception messages to be generated. @@ -101,4 +110,14 @@ public function getCompiler() { return $this->compiler; } + + /** + * Clear the cache of views that were compiled or not expired. + * + * @return void + */ + public function forgetCompiledOrNotExpired() + { + $this->compiledOrNotExpired = []; + } } diff --git a/src/Illuminate/View/ViewServiceProvider.php b/src/Illuminate/View/ViewServiceProvider.php index afae6bb70615..c40c3b9fc5bb 100755 --- a/src/Illuminate/View/ViewServiceProvider.php +++ b/src/Illuminate/View/ViewServiceProvider.php @@ -22,6 +22,10 @@ public function register() $this->registerViewFinder(); $this->registerBladeCompiler(); $this->registerEngineResolver(); + + $this->app->terminating(static function () { + Component::flushCache(); + }); } /** @@ -48,6 +52,10 @@ public function registerFactory() $factory->share('app', $app); + $app->terminating(static function () { + Component::forgetFactory(); + }); + return $factory; }); } @@ -153,7 +161,13 @@ public function registerPhpEngine($resolver) public function registerBladeEngine($resolver) { $resolver->register('blade', function () { - return new CompilerEngine($this->app['blade.compiler'], $this->app['files']); + $compiler = new CompilerEngine($this->app['blade.compiler'], $this->app['files']); + + $this->app->terminating(static function () use ($compiler) { + $compiler->forgetCompiledOrNotExpired(); + }); + + return $compiler; }); } } diff --git a/tests/Integration/View/BladeTest.php b/tests/Integration/View/BladeTest.php index b3d8f51eedc7..5dd83c9a4b24 100644 --- a/tests/Integration/View/BladeTest.php +++ b/tests/Integration/View/BladeTest.php @@ -9,6 +9,14 @@ class BladeTest extends TestCase { + public function tearDown(): void + { + Component::flushCache(); + Component::forgetFactory(); + + parent::tearDown(); + } + public function test_rendering_blade_string() { $this->assertSame('Hello Taylor', Blade::render('Hello {{ $name }}', ['name' => 'Taylor'])); diff --git a/tests/View/Blade/AbstractBladeTestCase.php b/tests/View/Blade/AbstractBladeTestCase.php index 99510b1dee66..93e0739404bf 100644 --- a/tests/View/Blade/AbstractBladeTestCase.php +++ b/tests/View/Blade/AbstractBladeTestCase.php @@ -2,8 +2,10 @@ namespace Illuminate\Tests\View\Blade; +use Illuminate\Container\Container; use Illuminate\Filesystem\Filesystem; use Illuminate\View\Compilers\BladeCompiler; +use Illuminate\View\Component; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -16,12 +18,18 @@ abstract class AbstractBladeTestCase extends TestCase protected function setUp(): void { - $this->compiler = new BladeCompiler($this->getFiles(), __DIR__); parent::setUp(); + + $this->compiler = new BladeCompiler($this->getFiles(), __DIR__); } protected function tearDown(): void { + Container::setInstance(null); + Component::flushCache(); + Component::forgetComponentsResolver(); + Component::forgetFactory(); + m::close(); parent::tearDown(); diff --git a/tests/View/Blade/BladeComponentTagCompilerTest.php b/tests/View/Blade/BladeComponentTagCompilerTest.php index 781adb9ef556..970127539e62 100644 --- a/tests/View/Blade/BladeComponentTagCompilerTest.php +++ b/tests/View/Blade/BladeComponentTagCompilerTest.php @@ -15,13 +15,9 @@ class BladeComponentTagCompilerTest extends AbstractBladeTestCase { - protected function tearDown(): void - { - m::close(); - } - public function testSlotsCanBeCompiled() { + $this->mockViewFactory(); $result = $this->compiler()->compileSlots(' '); @@ -30,6 +26,7 @@ public function testSlotsCanBeCompiled() public function testInlineSlotsCanBeCompiled() { + $this->mockViewFactory(); $result = $this->compiler()->compileSlots(' '); @@ -38,6 +35,7 @@ public function testInlineSlotsCanBeCompiled() public function testDynamicSlotsCanBeCompiled() { + $this->mockViewFactory(); $result = $this->compiler()->compileSlots(' '); @@ -46,6 +44,7 @@ public function testDynamicSlotsCanBeCompiled() public function testDynamicSlotsCanBeCompiledWithKeyOfObjects() { + $this->mockViewFactory(); $result = $this->compiler()->compileSlots(' '); @@ -54,6 +53,7 @@ public function testDynamicSlotsCanBeCompiledWithKeyOfObjects() public function testSlotsWithAttributesCanBeCompiled() { + $this->mockViewFactory(); $result = $this->compiler()->compileSlots(' '); @@ -62,6 +62,7 @@ public function testSlotsWithAttributesCanBeCompiled() public function testInlineSlotsWithAttributesCanBeCompiled() { + $this->mockViewFactory(); $result = $this->compiler()->compileSlots(' '); @@ -70,6 +71,7 @@ public function testInlineSlotsWithAttributesCanBeCompiled() public function testSlotsWithDynamicAttributesCanBeCompiled() { + $this->mockViewFactory(); $result = $this->compiler()->compileSlots(' '); @@ -78,6 +80,7 @@ public function testSlotsWithDynamicAttributesCanBeCompiled() public function testSlotsWithClassDirectiveCanBeCompiled() { + $this->mockViewFactory(); $result = $this->compiler()->compileSlots(' '); @@ -105,6 +108,7 @@ public function testBasicComponentParsing() public function testBasicComponentWithEmptyAttributesParsing() { + $this->mockViewFactory(); $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('
'); $this->assertSame("
##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) @@ -117,6 +121,7 @@ public function testBasicComponentWithEmptyAttributesParsing() public function testDataCamelCasing() { + $this->mockViewFactory(); $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => '1']) @@ -128,6 +133,7 @@ public function testDataCamelCasing() public function testColonData() { + $this->mockViewFactory(); $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => 1]) @@ -139,6 +145,7 @@ public function testColonData() public function testColonDataShortSyntax() { + $this->mockViewFactory(); $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => \$userId]) @@ -150,6 +157,7 @@ public function testColonDataShortSyntax() public function testColonDataWithStaticClassProperty() { + $this->mockViewFactory(); $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => User::\$id]) @@ -161,6 +169,7 @@ public function testColonDataWithStaticClassProperty() public function testColonDataWithStaticClassPropertyAndMultipleAttributes() { + $this->mockViewFactory(); $result = $this->compiler(['input' => TestInputComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestInputComponent', 'input', ['label' => Input::\$label,'name' => \$name,'value' => 'Joe']) @@ -180,6 +189,7 @@ public function testColonDataWithStaticClassPropertyAndMultipleAttributes() public function testSelfClosingComponentWithColonDataShortSyntax() { + $this->mockViewFactory(); $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => \$userId]) @@ -192,6 +202,7 @@ public function testSelfClosingComponentWithColonDataShortSyntax() public function testSelfClosingComponentWithColonDataAndStaticClassPropertyShortSyntax() { + $this->mockViewFactory(); $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => User::\$id]) @@ -204,6 +215,7 @@ public function testSelfClosingComponentWithColonDataAndStaticClassPropertyShort public function testSelfClosingComponentWithColonDataMultipleAttributesAndStaticClassPropertyShortSyntax() { + $this->mockViewFactory(); $result = $this->compiler(['input' => TestInputComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestInputComponent', 'input', ['label' => Input::\$label,'value' => 'Joe','name' => \$name]) @@ -225,6 +237,7 @@ public function testSelfClosingComponentWithColonDataMultipleAttributesAndStatic public function testEscapedColonAttribute() { + $this->mockViewFactory(); $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', ['userId' => 1]) @@ -236,6 +249,7 @@ public function testEscapedColonAttribute() public function testColonAttributesIsEscapedIfStrings() { + $this->mockViewFactory(); $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', []) @@ -259,6 +273,7 @@ public function testClassDirective() public function testColonNestedComponentParsing() { + $this->mockViewFactory(); $result = $this->compiler(['foo:alert' => TestAlertComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'foo:alert', []) @@ -270,6 +285,7 @@ public function testColonNestedComponentParsing() public function testColonStartingNestedComponentParsing() { + $this->mockViewFactory(); $result = $this->compiler(['foo:alert' => TestAlertComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'foo:alert', []) @@ -281,6 +297,7 @@ public function testColonStartingNestedComponentParsing() public function testSelfClosingComponentsCanBeCompiled() { + $this->mockViewFactory(); $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('
'); $this->assertSame("
##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) @@ -295,7 +312,7 @@ public function testClassNamesCanBeGuessed() { $container = new Container; $container->instance(Application::class, $app = m::mock(Application::class)); - $app->shouldReceive('getNamespace')->andReturn('App\\'); + $app->shouldReceive('getNamespace')->once()->andReturn('App\\'); Container::setInstance($container); $result = $this->compiler()->guessClassName('alert'); @@ -309,7 +326,7 @@ public function testClassNamesCanBeGuessedWithNamespaces() { $container = new Container; $container->instance(Application::class, $app = m::mock(Application::class)); - $app->shouldReceive('getNamespace')->andReturn('App\\'); + $app->shouldReceive('getNamespace')->once()->andReturn('App\\'); Container::setInstance($container); $result = $this->compiler()->guessClassName('base.alert'); @@ -335,6 +352,7 @@ public function testComponentsCanBeCompiledWithHyphenAttributes() public function testSelfClosingComponentsCanBeCompiledWithDataAndAttributes() { + $this->mockViewFactory(); $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', ['title' => 'foo']) @@ -348,6 +366,7 @@ public function testSelfClosingComponentsCanBeCompiledWithDataAndAttributes() public function testComponentCanReceiveAttributeBag() { $this->mockViewFactory(); + $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', []) @@ -373,6 +392,7 @@ public function testSelfClosingComponentCanReceiveAttributeBag() public function testComponentsCanHaveAttachedWord() { + $this->mockViewFactory(); $result = $this->compiler(['profile' => TestProfileComponent::class])->compileTags('Words'); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestProfileComponent', 'profile', []) @@ -384,6 +404,7 @@ public function testComponentsCanHaveAttachedWord() public function testSelfClosingComponentsCanHaveAttachedWord() { + $this->mockViewFactory(); $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags('Words'); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', []) @@ -396,6 +417,7 @@ public function testSelfClosingComponentsCanHaveAttachedWord() public function testSelfClosingComponentsCanBeCompiledWithBoundData() { + $this->mockViewFactory(); $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags(''); $this->assertSame("##BEGIN-COMPONENT-CLASS##@component('Illuminate\Tests\View\Blade\TestAlertComponent', 'alert', ['title' => \$title]) @@ -408,6 +430,7 @@ public function testSelfClosingComponentsCanBeCompiledWithBoundData() public function testPairedComponentTags() { + $this->mockViewFactory(); $result = $this->compiler(['alert' => TestAlertComponent::class])->compileTags(' '); @@ -424,8 +447,8 @@ public function testClasslessComponents() $container = new Container; $container->instance(Application::class, $app = m::mock(Application::class)); $container->instance(Factory::class, $factory = m::mock(Factory::class)); - $app->shouldReceive('getNamespace')->andReturn('App\\'); - $factory->shouldReceive('exists')->andReturn(true); + $app->shouldReceive('getNamespace')->once()->andReturn('App\\'); + $factory->shouldReceive('exists')->once()->andReturn(true); Container::setInstance($container); $result = $this->compiler()->compileTags(''); @@ -483,8 +506,8 @@ public function testClasslessComponentsWithAnonymousComponentNamespace() $container->instance(Application::class, $app = m::mock(Application::class)); $container->instance(Factory::class, $factory = m::mock(Factory::class)); - $app->shouldReceive('getNamespace')->andReturn('App\\'); - $factory->shouldReceive('exists')->andReturnUsing(function ($arg) { + $app->shouldReceive('getNamespace')->once()->andReturn('App\\'); + $factory->shouldReceive('exists')->times(3)->andReturnUsing(function ($arg) { // In our test, we'll do as if the 'public.frontend.anonymous-component' // view exists and not the others. return $arg === 'public.frontend.anonymous-component'; @@ -494,7 +517,7 @@ public function testClasslessComponentsWithAnonymousComponentNamespace() $blade = m::mock(BladeCompiler::class)->makePartial(); - $blade->shouldReceive('getAnonymousComponentNamespaces')->andReturn([ + $blade->shouldReceive('getAnonymousComponentNamespaces')->once()->andReturn([ 'frontend' => 'public.frontend', ]); @@ -517,8 +540,8 @@ public function testClasslessComponentsWithAnonymousComponentNamespaceWithIndexV $container->instance(Application::class, $app = m::mock(Application::class)); $container->instance(Factory::class, $factory = m::mock(Factory::class)); - $app->shouldReceive('getNamespace')->andReturn('App\\'); - $factory->shouldReceive('exists')->andReturnUsing(function (string $viewNameBeingCheckedForExistence) { + $app->shouldReceive('getNamespace')->once()->andReturn('App\\'); + $factory->shouldReceive('exists')->times(4)->andReturnUsing(function (string $viewNameBeingCheckedForExistence) { // In our test, we'll do as if the 'public.frontend.anonymous-component' // view exists and not the others. return $viewNameBeingCheckedForExistence === 'admin.auth.components.anonymous-component.index'; @@ -528,7 +551,7 @@ public function testClasslessComponentsWithAnonymousComponentNamespaceWithIndexV $blade = m::mock(BladeCompiler::class)->makePartial(); - $blade->shouldReceive('getAnonymousComponentNamespaces')->andReturn([ + $blade->shouldReceive('getAnonymousComponentNamespaces')->once()->andReturn([ 'admin.auth' => 'admin.auth.components', ]); @@ -546,6 +569,7 @@ public function testClasslessComponentsWithAnonymousComponentNamespaceWithIndexV public function testAttributeSanitization() { + $this->mockViewFactory(); $class = new class { public function __toString() @@ -567,12 +591,7 @@ public function __toString() public function testItThrowsAnExceptionForNonExistingAliases() { - $container = new Container; - $container->instance(Application::class, $app = m::mock(Application::class)); - $container->instance(Factory::class, $factory = m::mock(Factory::class)); - $app->shouldReceive('getNamespace')->andReturn('App\\'); - $factory->shouldReceive('exists')->andReturn(false); - Container::setInstance($container); + $this->mockViewFactory(false); $this->expectException(InvalidArgumentException::class); @@ -584,8 +603,8 @@ public function testItThrowsAnExceptionForNonExistingClass() $container = new Container; $container->instance(Application::class, $app = m::mock(Application::class)); $container->instance(Factory::class, $factory = m::mock(Factory::class)); - $app->shouldReceive('getNamespace')->andReturn('App\\'); - $factory->shouldReceive('exists')->andReturn(false); + $app->shouldReceive('getNamespace')->once()->andReturn('App\\'); + $factory->shouldReceive('exists')->twice()->andReturn(false); Container::setInstance($container); $this->expectException(InvalidArgumentException::class); @@ -598,23 +617,26 @@ public function testAttributesTreatedAsPropsAreRemovedFromFinalAttributes() $container = new Container; $container->instance(Application::class, $app = m::mock(Application::class)); $container->instance(Factory::class, $factory = m::mock(Factory::class)); - $app->shouldReceive('getNamespace')->andReturn('App\\'); - $factory->shouldReceive('exists')->andReturn(false); + $container->alias(Factory::class, 'view'); + $app->shouldReceive('getNamespace')->never()->andReturn('App\\'); + $factory->shouldReceive('exists')->never(); + Container::setInstance($container); $attributes = new ComponentAttributeBag(['userId' => 'bar', 'other' => 'ok']); $component = m::mock(Component::class); - $component->shouldReceive('withName', 'test'); - $component->shouldReceive('shouldRender')->andReturn(true); - $component->shouldReceive('resolveView')->andReturn(''); - $component->shouldReceive('data')->andReturn([]); - $component->shouldReceive('withAttributes'); + $component->shouldReceive('withName')->with('profile')->once(); + $component->shouldReceive('shouldRender')->once()->andReturn(true); + $component->shouldReceive('resolveView')->once()->andReturn(''); + $component->shouldReceive('data')->once()->andReturn([]); + $component->shouldReceive('withAttributes')->once(); + + Component::resolveComponentsUsing(fn () => $component); $__env = m::mock(\Illuminate\View\Factory::class); - $__env->shouldReceive('getContainer->make')->with(TestProfileComponent::class, ['userId' => 'bar', 'other' => 'ok'])->andReturn($component); - $__env->shouldReceive('startComponent'); - $__env->shouldReceive('renderComponent'); + $__env->shouldReceive('startComponent')->once(); + $__env->shouldReceive('renderComponent')->once(); $template = $this->compiler(['profile' => TestProfileComponent::class])->compileTags(''); $template = $this->compiler->compileString($template); @@ -631,6 +653,7 @@ protected function mockViewFactory($existsSucceeds = true) { $container = new Container; $container->instance(Factory::class, $factory = m::mock(Factory::class)); + $container->alias(Factory::class, 'view'); $factory->shouldReceive('exists')->andReturn($existsSucceeds); Container::setInstance($container); } diff --git a/tests/View/Blade/BladeComponentsTest.php b/tests/View/Blade/BladeComponentsTest.php index d650330dbe8d..bb0bbcd4863c 100644 --- a/tests/View/Blade/BladeComponentsTest.php +++ b/tests/View/Blade/BladeComponentsTest.php @@ -4,7 +4,6 @@ use Illuminate\View\Component; use Illuminate\View\ComponentAttributeBag; -use Illuminate\View\Factory; use Mockery as m; class BladeComponentsTest extends AbstractBladeTestCase @@ -17,11 +16,11 @@ public function testComponentsAreCompiled() public function testClassComponentsAreCompiled() { - $this->assertSame(' -getContainer()->make(Test::class, ["foo" => "bar"] + (isset($attributes) ? (array) $attributes->getIterator() : [])); ?> + $this->assertSame(' + "bar"] + (isset($attributes) ? (array) $attributes->getIterator() : [])); ?> withName(\'test\'); ?> shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?>', $this->compiler->compileString('@component(\'Test::class\', \'test\', ["foo" => "bar"])')); +startComponent($component->resolveView(), $component->data()); ?>', $this->compiler->compileString('@component(\'Illuminate\Tests\View\Blade\ComponentStub::class\', \'test\', ["foo" => "bar"])')); } public function testEndComponentsAreCompiled() @@ -62,13 +61,20 @@ public function testPropsAreExtractedFromParentAttributesCorrectlyForClassCompon $component->shouldReceive('withName', 'test'); $component->shouldReceive('shouldRender')->andReturn(false); - $__env = m::mock(Factory::class); - $__env->shouldReceive('getContainer->make')->with('Test', ['foo' => 'bar', 'other' => 'ok'])->andReturn($component); + Component::resolveComponentsUsing(fn () => $component); - $template = $this->compiler->compileString('@component(\'Test::class\', \'test\', ["foo" => "bar"])'); + $template = $this->compiler->compileString('@component(\'Illuminate\Tests\View\Blade\ComponentStub::class\', \'test\', ["foo" => "bar"])'); ob_start(); eval(" ?> $template config = m::mock(Config::class); $container = new Container; @@ -33,8 +37,6 @@ protected function setUp(): void Container::setInstance($container); Facade::setFacadeApplication($container); - - parent::setUp(); } protected function tearDown(): void @@ -44,6 +46,10 @@ protected function tearDown(): void Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); Container::setInstance(null); + Component::flushCache(); + Component::forgetFactory(); + + parent::tearDown(); } public function testInlineViewsGetCreated() @@ -56,12 +62,22 @@ public function testInlineViewsGetCreated() $this->assertSame('__components::c6327913fef3fca4518bcd7df1d0ff630758e241', $component->resolveView()); } - public function testRegularViewsGetReturned() + public function testRegularViewsGetReturnedUsingViewHelper() { $view = m::mock(View::class); $this->viewFactory->shouldReceive('make')->once()->with('alert', [], [])->andReturn($view); - $component = new TestRegularViewComponent; + $component = new TestRegularViewComponentUsingViewHelper; + + $this->assertSame($view, $component->resolveView()); + } + + public function testRegularViewsGetReturnedUsingViewMethod() + { + $view = m::mock(View::class); + $this->viewFactory->shouldReceive('make')->once()->with('alert', [], [])->andReturn($view); + + $component = new TestRegularViewComponentUsingViewMethod; $this->assertSame($view, $component->resolveView()); } @@ -85,6 +101,171 @@ public function testHtmlablesGetReturned() $this->assertInstanceOf(Htmlable::class, $view); $this->assertSame('

Hello foo

', $view->toHtml()); } + + public function testResolveWithUnresolvableDependency() + { + $this->expectException(BindingResolutionException::class); + $this->expectExceptionMessage('Unresolvable dependency resolving'); + + TestInlineViewComponentWhereRenderDependsOnProps::resolve([]); + } + + public function testResolveDependenciesWithoutContainer() + { + $component = TestInlineViewComponentWhereRenderDependsOnProps::resolve(['content' => 'foo']); + $this->assertSame('foo', $component->render()); + + $component = new class extends Component + { + public function __construct($a = null, $b = null) + { + $this->content = $a.$b; + } + + public function render() + { + return $this->content; + } + }; + + $component = $component::resolve(['a' => 'a', 'b' => 'b']); + $component = $component::resolve(['b' => 'b', 'a' => 'a']); + $this->assertSame('ab', $component->render()); + } + + public function testResolveDependenciesWithContainerIfNecessary() + { + $component = TestInlineViewComponentWithContainerDependencies::resolve([]); + $this->assertSame($this->viewFactory, $component->dependency); + + $component = TestInlineViewComponentWithContainerDependenciesAndProps::resolve(['content' => 'foo']); + $this->assertSame($this->viewFactory, $component->dependency); + $this->assertSame('foo', $component->render()); + } + + public function testResolveComponentsUsing() + { + $component = new TestInlineViewComponent; + + Component::resolveComponentsUsing(fn () => $component); + + $this->assertSame($component, Component::resolve('bar')); + } + + public function testBladeViewCacheWithRegularViewNameViewComponent() + { + $component = new TestRegularViewNameViewComponent; + + $this->viewFactory->shouldReceive('exists')->twice()->andReturn(true); + + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + + $cache = (fn () => $component::$bladeViewCache)->call($component); + $this->assertSame([$component::class.'::alert' => 'alert'], $cache); + + $component::flushCache(); + + $cache = (fn () => $component::$bladeViewCache)->call($component); + $this->assertSame([], $cache); + + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + $this->assertSame('alert', $component->resolveView()); + } + + public function testBladeViewCacheWithInlineViewComponent() + { + $component = new TestInlineViewComponent; + + $this->viewFactory->shouldReceive('exists')->twice()->andReturn(false); + + $this->config->shouldReceive('get')->twice()->with('view.compiled')->andReturn('/tmp'); + + $this->viewFactory->shouldReceive('addNamespace') + ->with('__components', '/tmp') + ->twice(); + + $compiledViewName = '__components::c6327913fef3fca4518bcd7df1d0ff630758e241'; + $contents = '::Hello {{ $title }}'; + $cacheKey = $component::class.$contents; + + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + + $cache = (fn () => $component::$bladeViewCache)->call($component); + $this->assertSame([$cacheKey => $compiledViewName], $cache); + + $component::flushCache(); + + $cache = (fn () => $component::$bladeViewCache)->call($component); + $this->assertSame([], $cache); + + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + $this->assertSame($compiledViewName, $component->resolveView()); + } + + public function testBladeViewCacheWithInlineViewComponentWhereRenderDependsOnProps() + { + $componentA = new TestInlineViewComponentWhereRenderDependsOnProps('A'); + $componentB = new TestInlineViewComponentWhereRenderDependsOnProps('B'); + + $this->viewFactory->shouldReceive('exists')->twice()->andReturn(false); + + $this->config->shouldReceive('get')->twice()->with('view.compiled')->andReturn('/tmp'); + + $this->viewFactory->shouldReceive('addNamespace') + ->with('__components', '/tmp') + ->twice(); + + $compiledViewNameA = '__components::6dcd4ce23d88e2ee9568ba546c007c63d9131c1b'; + $compiledViewNameB = '__components::ae4f281df5a5d0ff3cad6371f76d5c29b6d953ec'; + $cacheAKey = $componentA::class.'::A'; + $cacheBKey = $componentB::class.'::B'; + + $this->assertSame($compiledViewNameA, $componentA->resolveView()); + $this->assertSame($compiledViewNameA, $componentA->resolveView()); + $this->assertSame($compiledViewNameB, $componentB->resolveView()); + $this->assertSame($compiledViewNameB, $componentB->resolveView()); + + $cacheA = (fn () => $componentA::$bladeViewCache)->call($componentA); + $cacheB = (fn () => $componentB::$bladeViewCache)->call($componentB); + $this->assertSame($cacheA, $cacheB); + $this->assertSame([ + $cacheAKey => $compiledViewNameA, + $cacheBKey => $compiledViewNameB, + ], $cacheA); + + $componentA::flushCache(); + + $cacheA = (fn () => $componentA::$bladeViewCache)->call($componentA); + $cacheB = (fn () => $componentB::$bladeViewCache)->call($componentB); + $this->assertSame($cacheA, $cacheB); + $this->assertSame([], $cacheA); + } + + public function testFactoryGetsSharedBetweenComponents() + { + $regular = new TestRegularViewNameViewComponent; + $inline = new TestInlineViewComponent; + + $getFactory = fn ($component) => (fn () => $component->factory())->call($component); + + $this->assertSame($this->viewFactory, $getFactory($regular)); + + Container::getInstance()->instance('view', 'foo'); + $this->assertSame($this->viewFactory, $getFactory($inline)); + + Component::forgetFactory(); + $this->assertNotSame($this->viewFactory, $getFactory($inline)); + } } class TestInlineViewComponent extends Component @@ -102,7 +283,63 @@ public function render() } } -class TestRegularViewComponent extends Component +class TestInlineViewComponentWithContainerDependencies extends Component +{ + public $dependency; + + public function __construct(FactoryContract $dependency) + { + $this->dependency = $dependency; + } + + public function render() + { + return ''; + } +} + +class TestInlineViewComponentWithContainerDependenciesAndProps extends Component +{ + public $content; + + public $dependency; + + public function __construct(FactoryContract $dependency, $content) + { + $this->content = $content; + $this->dependency = $dependency; + } + + public function render() + { + return $this->content; + } +} + +class TestInlineViewComponentWithoutDependencies extends Component +{ + public function render() + { + return 'alert'; + } +} + +class TestInlineViewComponentWhereRenderDependsOnProps extends Component +{ + public $content; + + public function __construct($content) + { + $this->content = $content; + } + + public function render() + { + return $this->content; + } +} + +class TestRegularViewComponentUsingViewHelper extends Component { public $title; @@ -117,6 +354,21 @@ public function render() } } +class TestRegularViewComponentUsingViewMethod extends Component +{ + public $title; + + public function __construct($title = 'foo') + { + $this->title = $title; + } + + public function render() + { + return $this->view('alert'); + } +} + class TestRegularViewNameViewComponent extends Component { public $title; diff --git a/tests/View/ViewCompilerEngineTest.php b/tests/View/ViewCompilerEngineTest.php index f826e9656ac3..a194a64b7150 100755 --- a/tests/View/ViewCompilerEngineTest.php +++ b/tests/View/ViewCompilerEngineTest.php @@ -39,6 +39,22 @@ public function testViewsAreNotRecompiledIfTheyAreNotExpired() ', $results); } + public function testThatViewsAreNotAskTwiceIfTheyAreExpired() + { + $engine = $this->getEngine(); + $engine->getCompiler()->shouldReceive('getCompiledPath')->with(__DIR__.'/fixtures/foo.php')->andReturn(__DIR__.'/fixtures/basic.php'); + $engine->getCompiler()->shouldReceive('isExpired')->twice()->andReturn(false); + $engine->getCompiler()->shouldReceive('compile')->never(); + + $engine->get(__DIR__.'/fixtures/foo.php'); + $engine->get(__DIR__.'/fixtures/foo.php'); + $engine->get(__DIR__.'/fixtures/foo.php'); + + $engine->forgetCompiledOrNotExpired(); + + $engine->get(__DIR__.'/fixtures/foo.php'); + } + protected function getEngine() { return new CompilerEngine(m::mock(CompilerInterface::class), new Filesystem); diff --git a/tests/View/ViewFactoryTest.php b/tests/View/ViewFactoryTest.php index dddc7f261464..232c4edaac21 100755 --- a/tests/View/ViewFactoryTest.php +++ b/tests/View/ViewFactoryTest.php @@ -182,6 +182,156 @@ public function testPrependedExtensionOverridesExistingExtensions() $this->assertSame('baz', key($extensions)); } + public function testCallCreatorsDoesNotDispatchEventsWhenIsNotNecessary() + { + $factory = $this->getFactory(); + $factory->getDispatcher()->shouldReceive('listen')->never(); + $factory->getDispatcher()->shouldReceive('dispatch')->never(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->callCreator($view); + } + + public function testCallCreatorsDoesDispatchEventsWhenIsNecessary() + { + $factory = $this->getFactory(); + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('creating: name', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('creating: name', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->twice()->andReturn('name'); + + $factory->creator('name', fn () => true); + + $factory->callCreator($view); + } + + public function testCallCreatorsDoesDispatchEventsWhenIsNecessaryUsingWildcards() + { + $factory = $this->getFactory(); + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('creating: *', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('creating: name', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->creator('*', fn () => true); + + $factory->callCreator($view); + } + + public function testCallCreatorsDoesDispatchEventsWhenIsNecessaryUsingNormalizedNames() + { + $factory = $this->getFactory(); + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('creating: components.button', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('creating: components/button', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->twice()->andReturn('components/button'); + + $factory->creator('components.button', fn () => true); + + $factory->callCreator($view); + } + + public function testCallComposersDoesNotDispatchEventsWhenIsNotNecessary() + { + $factory = $this->getFactory(); + $factory->getDispatcher()->shouldReceive('listen')->never(); + $factory->getDispatcher()->shouldReceive('dispatch')->never(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->callComposer($view); + } + + public function testCallComposerDoesDispatchEventsWhenIsNecessary() + { + $factory = $this->getFactory(); + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: name', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('composing: name', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->twice()->andReturn('name'); + + $factory->composer('name', fn () => true); + + $factory->callComposer($view); + } + + public function testCallComposersDoesDispatchEventsWhenIsNecessaryUsingWildcards() + { + $factory = $this->getFactory(); + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: *', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('composing: name', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->once()->andReturn('name'); + + $factory->composer('*', fn () => true); + + $factory->callComposer($view); + } + + public function testCallComposersDoesDispatchEventsWhenIsNecessaryUsingNormalizedNames() + { + $factory = $this->getFactory(); + $factory->getDispatcher() + ->shouldReceive('listen') + ->with('composing: components.button', m::type(Closure::class)) + ->once(); + + $factory->getDispatcher() + ->shouldReceive('dispatch') + ->with('composing: components/button', m::type('array')) + ->once(); + + $view = m::mock(View::class); + $view->shouldReceive('name')->twice()->andReturn('components/button'); + + $factory->composer('components.button', fn () => true); + + $factory->callComposer($view); + } + public function testComposersAreProperlyRegistered() { $factory = $this->getFactory(); @@ -244,7 +394,15 @@ public function testCallComposerCallsProperEvent() { $factory = $this->getFactory(); $view = m::mock(View::class); - $view->shouldReceive('name')->once()->andReturn('name'); + $dispatcher = m::mock(DispatcherContract::class); + $factory->setDispatcher($dispatcher); + + $dispatcher->shouldReceive('listen', m::any())->once(); + + $view->shouldReceive('name')->twice()->andReturn('name'); + + $factory->composer('name', fn () => true); + $factory->getDispatcher()->shouldReceive('dispatch')->once()->with('composing: name', [$view]); $factory->callComposer($view); @@ -589,7 +747,7 @@ public function testExceptionsInSectionsAreThrown() $factory->getEngineResolver()->shouldReceive('resolve')->twice()->andReturn($engine); $factory->getFinder()->shouldReceive('find')->once()->with('layout')->andReturn(__DIR__.'/fixtures/section-exception-layout.php'); $factory->getFinder()->shouldReceive('find')->once()->with('view')->andReturn(__DIR__.'/fixtures/section-exception.php'); - $factory->getDispatcher()->shouldReceive('dispatch')->times(4); + $factory->getDispatcher()->shouldReceive('dispatch')->never(); $factory->make('view')->render(); }