From 6ff8b8eb6c541aa23cdb9c741f25388380c3164b Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 13:17:50 +0200 Subject: [PATCH 1/4] test: warm up discovery before timing an empty scan The first scan in a process loads the discoverer's classes (150-225 ms idle), which pushed the empty-directory scan past the 250 ms threshold under pest --parallel. --- .../Services/DiscoveryCacheManagerSlowScanTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php index 61f009ea..c06cfd23 100644 --- a/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php +++ b/tests/Unit/Discovery/Infrastructure/Services/DiscoveryCacheManagerSlowScanTest.php @@ -73,6 +73,15 @@ function scanOf(DiscoveryCacheManager $manager, string $path): void // threshold is wrong and every site gets a log line per request. $this->logger->shouldNotReceive('warning'); + // The first scan in a process loads the discoverer's classes: 150 to + // 225 ms measured on an idle machine, past the threshold under the + // load of `pest --parallel`. That is autoloading, not the scan this + // test is about, so it is paid on another directory first. + $warmUp = sys_get_temp_dir().'/pollora-scan-'.bin2hex(random_bytes(6)); + mkdir($warmUp); + scanOf(managerFor(true), $warmUp); + rmdir($warmUp); + scanOf(managerFor(true, $this->logger), $this->dir); }); From ea97e50559e5dd092c78bc36eb4a09c6153b148e Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 13:24:47 +0200 Subject: [PATCH 2/4] fix: never discover a plugin's dependency, build or asset directories A plugin with neither app/ nor src/ was scanned from its root, node_modules included: 2 to 7 s per request in debug mode on a blocks-only plugin with a Vite build. --- CHANGELOG.md | 3 ++ .../Application/Services/PluginRegistrar.php | 51 ++++++++++++++++--- tests/Unit/Plugin/PluginDiscoveryPathTest.php | 43 +++++++++++----- 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce75291b..b33a202b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...develop) +### Fixed +- A plugin with neither `app/` nor `src/` — one that only ships blocks, which need no class — was discovered from its root, `node_modules` included: 2 to 7 seconds on every request in debug mode, measured on a blocks-only plugin with a Vite build. Its other top-level directories are scanned instead, never `node_modules`, `vendor`, `bower_components`, `build`, `dist`, `public`, `resources`, `assets`, `languages`, `lang` or a hidden directory + ## [v13.34.0-beta.2](https://github.com/Pollora/framework/compare/v13.34.0-beta...v13.34.0-beta.2) - 2026-09-30 ### Added diff --git a/src/Plugin/Application/Services/PluginRegistrar.php b/src/Plugin/Application/Services/PluginRegistrar.php index fe61095d..c7ee2724 100644 --- a/src/Plugin/Application/Services/PluginRegistrar.php +++ b/src/Plugin/Application/Services/PluginRegistrar.php @@ -26,6 +26,26 @@ */ class PluginRegistrar { + /** + * Directories of a plugin root that never hold discoverable classes. + * + * Dependencies, build output, views and assets, translations, and hidden + * directories (`.git`, `.github`). Walking them costs the request and + * finds nothing that belongs to the plugin. + */ + private const array NON_DISCOVERABLE_DIRECTORIES = [ + 'node_modules', + 'vendor', + 'bower_components', + 'build', + 'dist', + 'public', + 'resources', + 'assets', + 'languages', + 'lang', + ]; + /** * Collection of registered plugins. * @@ -246,7 +266,9 @@ protected function discoverPluginStructures(PluginModuleInterface $plugin): void try { $discoveryService = $this->app->get(ModuleDiscoveryOrchestratorInterface::class); - $discoveryService->discover($this->getPluginDiscoveryPath($plugin)); + foreach ($this->getPluginDiscoveryPaths($plugin) as $path) { + $discoveryService->discover($path); + } } catch (\Exception $exception) { $this->logError(sprintf('Plugin discovery error for %s: ', $plugin->getName()).$exception->getMessage()); } @@ -269,20 +291,37 @@ protected function discoverPluginStructures(PluginModuleInterface $plugin): void * out. It also found ten PHP files shipped inside `@wordpress/style-engine` * and handed them to discovery, which is its own kind of wrong. * - * The root is still used when a plugin has neither directory, so a plugin - * that keeps its classes at the top level is discovered as before. + * A plugin with neither directory — one that only ships blocks, which + * need no class at all — is scanned in its other top-level directories, + * never in the ones listed in {@see NON_DISCOVERABLE_DIRECTORIES}. Its + * root was scanned whole before: 2 to 7 seconds per request, measured on + * a blocks-only plugin with a Vite build. + * + * @return list */ - protected function getPluginDiscoveryPath(PluginModuleInterface $plugin): string + protected function getPluginDiscoveryPaths(PluginModuleInterface $plugin): array { $basePath = rtrim($plugin->getPath(), '/'); foreach (['/app', '/src'] as $directory) { if (is_dir($basePath.$directory)) { - return $basePath.$directory; + return [$basePath.$directory]; + } + } + + $paths = []; + + foreach (scandir($basePath) ?: [] as $entry) { + if (str_starts_with($entry, '.') || in_array($entry, self::NON_DISCOVERABLE_DIRECTORIES, true)) { + continue; + } + + if (is_dir($basePath.'/'.$entry)) { + $paths[] = $basePath.'/'.$entry; } } - return $basePath; + return $paths; } /** diff --git a/tests/Unit/Plugin/PluginDiscoveryPathTest.php b/tests/Unit/Plugin/PluginDiscoveryPathTest.php index 0f207c01..708ee076 100644 --- a/tests/Unit/Plugin/PluginDiscoveryPathTest.php +++ b/tests/Unit/Plugin/PluginDiscoveryPathTest.php @@ -21,9 +21,13 @@ function registrar(): object { return new class(new Container, new WordPressPluginParser) extends PluginRegistrar { - public function discoveryPathFor(PluginModuleInterface $plugin): string + /** @return list */ + public function discoveryPathsFor(PluginModuleInterface $plugin): array { - return $this->getPluginDiscoveryPath($plugin); + $paths = $this->getPluginDiscoveryPaths($plugin); + sort($paths); + + return $paths; } }; } @@ -58,26 +62,40 @@ function pluginAt(string $path): PluginModuleInterface it('scans app/ when the plugin has one', function (): void { mkdir($this->root.'/app'); - expect(registrar()->discoveryPathFor(pluginAt($this->root)))->toBe($this->root.'/app'); + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([$this->root.'/app']); }); it('falls back to src/', function (): void { mkdir($this->root.'/src'); - expect(registrar()->discoveryPathFor(pluginAt($this->root)))->toBe($this->root.'/src'); + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([$this->root.'/src']); }); it('prefers app/ over src/ when both are there', function (): void { mkdir($this->root.'/app'); mkdir($this->root.'/src'); - expect(registrar()->discoveryPathFor(pluginAt($this->root)))->toBe($this->root.'/app'); + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([$this->root.'/app']); }); - it('keeps scanning the root for a plugin that has neither', function (): void { - // A plugin holding its classes at the top level is discovered exactly - // as it was; nothing is narrowed away from it. - expect(registrar()->discoveryPathFor(pluginAt($this->root)))->toBe($this->root); + it('scans the other top-level directories of a plugin that has neither', function (): void { + // A plugin keeping its classes in, say, includes/ is still discovered. + mkdir($this->root.'/includes'); + mkdir($this->root.'/config'); + + expect(registrar()->discoveryPathsFor(pluginAt($this->root))) + ->toBe([$this->root.'/config', $this->root.'/includes']); + }); + + it('never scans a directory that cannot hold discoverable classes', function (): void { + // A blocks-only plugin: no class, a Vite build. Its root was scanned + // whole, node_modules included — 2 to 7 seconds on every request. + foreach (['node_modules', 'vendor', 'build', 'dist', 'public', 'resources', 'assets', 'languages', '.git', '.github'] as $directory) { + mkdir($this->root.'/'.$directory); + } + touch($this->root.'/demo-plugin.php'); + + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([]); }); it('never hands back a directory holding node_modules when app/ exists', function (): void { @@ -85,15 +103,12 @@ function pluginAt(string $path): PluginModuleInterface mkdir($this->root.'/app'); mkdir($this->root.'/node_modules'); - $path = registrar()->discoveryPathFor(pluginAt($this->root)); - - expect($path)->toBe($this->root.'/app') - ->and(is_dir($path.'/node_modules'))->toBeFalse(); + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([$this->root.'/app']); }); it('does not care how the path was spelled', function (): void { mkdir($this->root.'/app'); - expect(registrar()->discoveryPathFor(pluginAt($this->root.'/')))->toBe($this->root.'/app'); + expect(registrar()->discoveryPathsFor(pluginAt($this->root.'/')))->toBe([$this->root.'/app']); }); }); From 70879c3c3e7ea756930264940f495398e3044a80 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 13:35:34 +0200 Subject: [PATCH 3/4] style: blank line after the loop, as Rector wants --- tests/Unit/Plugin/PluginDiscoveryPathTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Plugin/PluginDiscoveryPathTest.php b/tests/Unit/Plugin/PluginDiscoveryPathTest.php index 708ee076..74f783e9 100644 --- a/tests/Unit/Plugin/PluginDiscoveryPathTest.php +++ b/tests/Unit/Plugin/PluginDiscoveryPathTest.php @@ -93,6 +93,7 @@ function pluginAt(string $path): PluginModuleInterface foreach (['node_modules', 'vendor', 'build', 'dist', 'public', 'resources', 'assets', 'languages', '.git', '.github'] as $directory) { mkdir($this->root.'/'.$directory); } + touch($this->root.'/demo-plugin.php'); expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([]); From 38ebf9fe238a772af20e2e2df6de3b64116fd74d Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 13:58:50 +0200 Subject: [PATCH 4/4] chore: release v13.34.0 The first stable release since v13.4.4, on Laravel 13.34. Points the documentation submodule at the stable install instructions. --- CHANGELOG.md | 6 +++++- documentation | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b33a202b..b11678d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0...develop) + +## [v13.34.0](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...v13.34.0) - 2026-09-30 + +The first stable release since v13.4.4, on Laravel 13.34. It gathers every change from v13.32.0-beta to v13.34.0-beta.2 below; coming from 13.4, read those entries, or run Nectar's `upgrade-pollora-v13-32` prompt ([full comparison](https://github.com/Pollora/framework/compare/v13.4.4...v13.34.0)). ### Fixed - A plugin with neither `app/` nor `src/` — one that only ships blocks, which need no class — was discovered from its root, `node_modules` included: 2 to 7 seconds on every request in debug mode, measured on a blocks-only plugin with a Vite build. Its other top-level directories are scanned instead, never `node_modules`, `vendor`, `bower_components`, `build`, `dist`, `public`, `resources`, `assets`, `languages`, `lang` or a hidden directory diff --git a/documentation b/documentation index d03b1a68..e85d8d36 160000 --- a/documentation +++ b/documentation @@ -1 +1 @@ -Subproject commit d03b1a687efba45f5866b0b41bfcdcfb48e2c845 +Subproject commit e85d8d364c92617d17f51d753a6bc91852503a69