-
Notifications
You must be signed in to change notification settings - Fork 3
Description
This issue describes an additional funcitonality that I think would be nice to have, based on the experience of using WP App Container for plugins and from discussion with colleagues trying to implement modularity in their plugins.
The scenario
Let's imagine we have a simple plugin that needs to perform a couple of operations.
The first operation must be done pretty early, let's say on "init".
The second operation can't happen that early, because it depends on a condition that is only possible to check at a later hook.
The plugin could use two modules, one per operation.
Let's ignore for now the first module, and let's imagine how the second module could look like.
class MyTemplateRedirectModule implements ServiceModule, ExecutableModule
{
public function services(): array
{
// register here a few services
}
public function run(ContainerInterface $c): bool
{
return add_action(
'template_redirect',
function () use ($c) {
if ( ! is_post_type_archive(MyCpt::NAME) ) {
return;
}
// do things here using services from container
}
);
}
}This a quite common situation. We only need to do "things" on specific type of frontoffice request, but the first hook to safely check for conditional tags is template_redirect.
The issue
Now, if in our website we have a request that satisfies our condition once every 1000 requests, it means that 99.9% of the times the module registers services in the container for no reason.
When the number of services registered is not trivial, register services might be resource consuming, because pollutes memory and trigger autoload (which might be slow file-based operation).
When template_redirect runs, it is too late to add things in the container, because the plugin boot is executed earlier (because there's a plugin functionality that needs to run earlier).
That's why we check the condition inside run(), but the container instance passed there is read-only, so we need to add services earlier.
In short, the problem is that when we have a module that should register things conditionally, and the condition is not available very soon (quite common in WordPress), we are forced to register things unconditionally and then check the condition later.
The possible solution
I think that a possible solution could be to introduce a DeferredModule interface, similar to this:
interface DeferredModule extends Module
{
public function deferredTo(): string;
public function factoryModule(Package $package): ?Module;
}and a Package::addDeferredModule() method, with a signature like this:
public function addDeferredModule(DeferredModule $module): Package;Internally, the method would add an action to the hook returned by DeferredModule::deferredTo() and inside that hook, it would call DeferredModule::factoryModule() and the returned module, if any, would be added in the same way it is hadded when calling Package::addModule().
The code could look like this (it's an idea, completely untested):
public function addDeferredModule(DeferredModule $module): Package
{
add_action(
$module->deferredTo(),
function (): void {
$deferredModule = $module->factoryModule($this);
if (!$deferredModule || $this->statusIs(self::STATUS_FAILED)) {
return;
}
// services/factories/extensions are added here...
if (($deferredModule instanceof ExecutableModule) && $this->statusIs(self::STATUS_BOOTED)) {
// deferred + executable module is executed right-away, when package was already booted
$module->run($this->container());
}
}
);
}With the above code in place, in the scenario I described a the beginning, the MyTemplateRedirectModule could be rewritten like this:
class MyTemplateRedirectModule implements ServiceModule, ExecutableModule, DeferredModule
{
public function deferredTo(): string
{
return 'template_redirect';
}
public function factoryModule(Package $package): ?Module
{
return is_post_type_archive(MyCpt::NAME) ? $this : null;
}
public function services(): array
{
// register here a few services
}
public function run(ContainerInterface $c): bool
{
// do things here using services from container
}
}In this case the deferred module return itself from factoryModule(), but it could return another module.
Anyway, having the proposed code in place, by calling the following line the issue is solved in a pretty simple way.
$package->addDeferredModule(new MyTemplateRedirectModule());For requests that are not archive of a specific post type, not only the module does not execute anything, it also does not register anything.
Additional notes
Considerations on addDeferredModule()
The code proposed above for addDeferredModule() is simplified. There are other things to consider, e.g.:
- which priority to use when adding the hook for deferred module? We could accept an additional
$priorityparameter inaddDeferredModule, or maybe have anotherPrioritizedDeferredModuleinterface which would extendDeferredModulewith an additionalpriority()method. - In some situations there's not a viable action hook to use, but only a filter. So it might be required to use
add_filterinstead ofadd_action.We could accept an additional$isFilterparameter inaddDeferredModule, or maybe have anotherFilterHookDeferredModuleinterface which would extendDeferredModuleto distinguish actions from filters. - If it is decided to don't use additional parameters for
addDeferredModule(), but use additioanl interfaces for the two reasosn exposed in the previous two points, theaddDeferredModule()would have the same signature ofaddModule()so they can be merged in a single method, and based on the interface apply the deferred workflow.
Deferred modules for inter-module dependency
Deferred modules could be used for another use case, thanks to the fact that the DeferredModule::factoryModule() receives an instance of the package.
Imagine a module that should be added only if another module is present.
Package class has the moduleIs() method, but that can't be used on Package::ACTION_INIT to determine if a given package was added or not, because maybe the target package will be added later. And in any case, can't be used to check if a module was executed or not, because the execution happens later.
With the addition of the proposed functionalities, it would be possible to do things like:
class SomeModuleDependantModule implements ServiceModule, ExecutableModule, DeferredModule
{
public function __construct(string $hookName)
{
$this->hookName = $hookName;
}
public function deferredTo(): string
{
return $this->hookName;
}
public function factoryModule(Package $package): ?Module
{
return $package->moduleIs(SomeModule::class, Package::MODULE_EXECUTED)
? $this
: null;
}
public function services(): array
{
// register here a few services
}
public function run(ContainerInterface $c): bool
{
// do things here using services from container
}
}
$plugin = plugin();
$module = new SomeModuleDependantModule($plugin->hookName(Package::ACTION_READY));
$plugin->addDeferredModule($module);In the snippet above, I added a deferred module that when the package is ready (ACTION_READY action is fired) checks if the SomeModule module was executed successfully, and only if so will add itself as an additional module.
A similar workflow can be currently implemented. In fact, currently it is possible to do:
add_action(
$package->hookName(Package::ACTION_READY)
function (Package $package) {
if ($package->moduleIs(SomeModule::class, Package::MODULE_EXECUTED)) {
(new SomeModuleDependantModule())->run()
}
}
);So we can currently execute SomeModuleDependantModule only if SomeModule was executed successfully, but we can't currently add services at that point, because when ACTION_READY hook is fired, the container is read-only, so any service required by SomeModuleDependantModule would need to be registered unconditionally.