Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 45 additions & 6 deletions src/Plugin/Application/Services/PluginRegistrar.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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());
}
Expand All @@ -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<string>
*/
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;
}

/**
Expand Down
44 changes: 30 additions & 14 deletions tests/Unit/Plugin/PluginDiscoveryPathTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ function registrar(): object
{
return new class(new Container, new WordPressPluginParser) extends PluginRegistrar
{
public function discoveryPathFor(PluginModuleInterface $plugin): string
/** @return list<string> */
public function discoveryPathsFor(PluginModuleInterface $plugin): array
{
return $this->getPluginDiscoveryPath($plugin);
$paths = $this->getPluginDiscoveryPaths($plugin);
sort($paths);

return $paths;
}
};
}
Expand Down Expand Up @@ -58,42 +62,54 @@ 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('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('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('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 {
// The whole point: the root is where a Vite build puts node_modules.
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']);
});
});
Loading