Skip to content
Open
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,30 @@ When a sub-plugin's standalone counterpart is still active:
| `Conflict_Policy::DEFER` | Leave the standalone active; the load guard stands the bundled copy down. |
| `Conflict_Policy::NOTICE_ONLY` | Leave it active and ask the user to deactivate it. |

A policy the library does not recognise is treated as `NOTICE_ONLY`, never as the default. A typo in
a stored policy should not deactivate a plugin the site owner deliberately turned on.

`DEACTIVATE` deactivates the standalone and then **ends the request with a redirect**, so it only
runs on ordinary admin page loads — never on the front end, and never during cron, AJAX, or WP-CLI.
Ending a checkout POST or a cron run to deactivate a plugin would cost far more than the conflict
does. The deactivation is silent: the standalone's own deactivation hook is not fired, because at
`plugins_loaded` a routine `flush_rewrite_rules()` in that callback would rebuild the rules before a
single post type is registered.

### Per-sub-plugin policy override

`conflict_policy` accepts a `callable( Sub_Plugin ): string`, so one sub-plugin can decide at
runtime without a container and without touching the library:

```php
'conflict_policy' => static function ( Sub_Plugin $sub_plugin ) {
// Stand down if a newer standalone supersedes the bundled copy.
return my_standalone_version_at_least( $sub_plugin, '3.0.0' )
? Conflict_Policy::DEFER
: Conflict_Policy::DEACTIVATE;
},
```

### Sub-plugin configuration

| Key | Type | Required | Meaning |
Expand Down Expand Up @@ -142,6 +166,7 @@ Config::set_container( $container );
|---|---|---|
| `Contracts\Registrar_Interface` | `Registrar` | Holds the registered sub-plugins. |
| `Contracts\Notices_Interface` | `Notices` | Notice queue and rendering. |
| `Conflict\Resolver_Interface` | `Conflict\Resolver` | Standalone detection, deactivation, redirect. |

The default notices queue into the option `{prefix}_plugin_absorber_notices` — a network option on
multisite — and render for users who can `activate_plugins`. Read `Notices::option_name()` if you
Expand Down
41 changes: 36 additions & 5 deletions docs/superpowers/plans/2026-07-31-plugin-absorber.md
Original file line number Diff line number Diff line change
Expand Up @@ -3838,8 +3838,34 @@ loading anything.'
- Create: `src/Conflict/Resolver_Interface.php`, `src/Conflict/Resolver.php`, `tests/unit/Conflict/ResolverTest.php`
- Modify: `src/Loader.php` (add `resolver()` and the @1 hook), `README.md`

> **Deviations, deliberate (added 2026-08-03, from the PR 12 review):**
>
> 1. **`deactivate_plugins()` is called with no `$network_wide` argument, and the plan's reasoning
> for passing one is factually wrong.** The plan says omitting it makes the call "a silent no-op
> for a network-activated plugin", producing an endless redirect. Verified against core: the
> default is `null`, not `false`. Core enters the network branch on `false !== $network_wide` and
> the blog branch on `true !== $network_wide`, so `null` takes **both**. The infinite loop never
> existed. Worse, passing a computed `true` *skips* the blog branch, so a plugin that is both
> network-active and listed in a blog's `active_plugins` keeps that entry and needs a second
> request — and a second deactivation hook — to clear. Two tests now exercise real core rather
> than a stub, since this claim is the only thing the argument rested on.
> 2. **The deactivation is silent.** With `$silent = false` core fires `deactivate_plugin` and the
> standalone's own `register_deactivation_hook()` callback — at `plugins_loaded`, before `init`.
> A routine `flush_rewrite_rules()` there rebuilds the rules with no post type or taxonomy
> registered, and every custom permalink on the site 404s. Core's own automatic deactivations,
> `validate_active_plugins()` and the plugin upgrader, both pass `true`; only its interactive
> admin paths are noisy.
> 3. **`redirect_destination()` matches the screen, not a substring of an absolute URL.**
> `wp_get_referer()` prefers the `_wp_http_referer` field that every nonce-bearing admin form
> carries, and that field holds a bare *path* — so comparing against `admin_url()` missed every
> admin form POST, the whole network admin, and any site behind a TLS-terminating proxy where
> `admin_url()` says http and the referrer says https. The "never interrupt an inline update"
> guard the plan is proudest of did not fire for a plugins.php bulk action.
> 4. **The destructive path is gated on the request context** — see the correction to deferred
> issue B.

**Interfaces:**
- Consumes: `Loader::all()` (Task 9), `Loader::notices()` (Task 10), `Sub_Plugin::is_standalone_plugin_active()` / `is_standalone_plugin_network_active()` / `get_conflict_policy()` (Task 7), `Conflict_Policy::*` (Task 6).
- Consumes: `Loader::all()` (Task 9), `Loader::notices()` (Task 10), `Sub_Plugin::is_standalone_plugin_active()` / `get_conflict_policy()` (Task 7), `Conflict_Policy::*` (Task 6).
- Produces:
- `Conflict\Resolver_Interface` with `resolve_all(): void`
- `Conflict\Resolver::redirect_destination( $referrer )` — `protected`, returns `string|false`
Expand Down Expand Up @@ -5777,10 +5803,15 @@ Submit `https://github.com/stellarwp/plugin-absorber` at <https://packagist.org/

Recorded in the spec, deliberately not fixed in 1.0.0:

- **B** — `resolve_all()` runs on front-end requests. With no referrer it redirects to
`admin_url( 'plugins.php' )`, bouncing a logged-out visitor to the login screen. Wrapping it in
`is_admin()` fixes it and is safe, since the load guard already prevents any front-end fatal.
Matches both reference implementations as-is.
- ~~**B**~~ — **fixed in PR 12, not deferred (2026-08-03).** The issue was understated. Bouncing a
logged-out visitor to the login screen is the mildest case, not the representative one: the same
code path turns a visitor's checkout POST into a 302 that silently drops the order, bounces a
login POST back to a blank form, aborts `wp-cron.php` before its event loop, and ends a WP-CLI
command with status 0 and no output, because `header()` does nothing under the CLI SAPI. Losing
an order to avoid a conflict that cannot cause a front-end fatal is not a trade worth deferring.
`is_admin()` alone is also not the fix the entry claims — `admin-ajax.php` and `admin-post.php`
both define `WP_ADMIN`. `Loader::run_conflict_resolution()` now gates on `is_admin()` plus not
cron, not AJAX, and not WP-CLI.
- **E** — `Activation::maybe_run()` reads the option, runs the callback, then writes. Two
simultaneous first requests can both run it. `add_option()` as an atomic claim would close it.
- **F** — `Config::get_version()` is stored but never read.
Expand Down
148 changes: 148 additions & 0 deletions src/Conflict/Resolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber\Conflict;

use Nexcess\PluginAbsorber\Conflict_Policy;
use Nexcess\PluginAbsorber\Loader;
use Nexcess\PluginAbsorber\Sub_Plugin;

/**
* Default conflict resolution: detect the active standalone and act per policy.
*
* @since 1.0.0
*/
class Resolver implements Resolver_Interface {
/**
* @since 1.0.0
*
* @return void
*/
public function resolve_all(): void {
foreach ( Loader::all() as $sub_plugin ) {
// Registrar_Interface::all() only declares `array`, so a host-bound registrar can
// return anything. Calling a predicate on it would fatal inside plugins_loaded.
if ( ! $sub_plugin instanceof Sub_Plugin ) {
continue;
}

if ( ! $sub_plugin->is_enabled() || ! $sub_plugin->is_standalone_plugin_active() ) {
continue;
}

$this->resolve( $sub_plugin );
}
}

/**
* @since 1.0.0
*
* @param Sub_Plugin $sub_plugin Sub-plugin whose standalone is active.
*
* @return void
*/
protected function resolve( Sub_Plugin $sub_plugin ): void {
$policy = $sub_plugin->get_conflict_policy();

// A host may persist a policy in an option and a filter may return anything. Falling
// through to deactivate() would turn off a plugin the site owner deliberately activated
// on the strength of a typo, so an unrecognised policy takes the conservative branch.
if ( ! Conflict_Policy::is_valid( $policy ) ) {
$policy = Conflict_Policy::NOTICE_ONLY;
}

switch ( $policy ) {
case Conflict_Policy::DEFER:
// The standalone wins. Its own constant makes the load path skip the bundled copy.
return;

case Conflict_Policy::NOTICE_ONLY:
Loader::notices()->queue_conflict_notice( $sub_plugin );

return;

case Conflict_Policy::DEACTIVATE:
default:
$this->deactivate( $sub_plugin );
}
}

/**
* @since 1.0.0
*
* @param Sub_Plugin $sub_plugin Sub-plugin whose standalone is active.
*
* @return void
*/
protected function deactivate( Sub_Plugin $sub_plugin ): void {
if ( ! function_exists( 'deactivate_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}

// Silent, and with no $network_wide argument.
//
// Silent because this is an unattended deactivation, and the standalone's own deactivation
// hook has already been registered this request. Running it at plugins_loaded means a
// routine flush_rewrite_rules() in that callback regenerates the rules before init has
// registered a single post type, and every custom permalink on the site starts 404ing.
// Core makes the same call: its interactive paths are noisy, its automatic ones --
// validate_active_plugins(), the plugin upgrader -- are silent.
//
// The $network_wide default is null, not false, and null is the value that handles both
// scopes. Core enters the network branch on `false !== $network_wide` and the blog branch
// on `true !== $network_wide`, so null takes both. Passing a computed true would skip the
// blog branch, stranding an entry for a plugin that is active in both, which then takes a
// second request and a second deactivation hook to clear.
deactivate_plugins( $sub_plugin->get_standalone_plugin_basename(), true );

// Queued after the deactivation but before the redirect, so the explanation is durable
// whether or not the request goes on to end here.
Loader::notices()->queue_merge_notice( $sub_plugin );

$destination = $this->redirect_destination( wp_get_referer() );

if ( $destination !== false ) {
wp_safe_redirect( $destination );

exit;
}
}

/**
* Where to send the user after deactivating, or false to stay put.
*
* Never trap the user mid-update: an inline update on the plugins list must not be
* interrupted, and the update screens must not be reloaded.
*
* @since 1.0.0
*
* @param string|false $referrer Result of wp_get_referer().
*
* @return string|false
*/
protected function redirect_destination( $referrer ) {
if ( ! is_string( $referrer ) || $referrer === '' ) {
return admin_url( 'plugins.php' );
}

// Match on the screen, not on a substring of an absolute URL. wp_get_referer() prefers
// the _wp_http_referer field that every nonce-bearing admin form carries, and that field
// holds a bare path -- so comparing against admin_url() misses every admin form POST,
// misses the network admin entirely, and misses any site behind a TLS-terminating proxy
// where admin_url() says http and the referrer says https.
$screen = basename( (string) wp_parse_url( $referrer, PHP_URL_PATH ) );

if ( $screen === 'update.php' || $screen === 'update-core.php' ) {
return admin_url( 'plugins.php' );
}

// Staying put: a redirect here would drop a bulk action or interrupt an inline update.
if ( $screen === 'plugins.php' ) {
return false;
}

return $referrer;
}
}
28 changes: 28 additions & 0 deletions src/Conflict/Resolver_Interface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

namespace Nexcess\PluginAbsorber\Conflict;

/**
* Decides what happens when a sub-plugin's standalone counterpart is still active.
*
* Bind a replacement to change conflict handling globally.
*
* @since 1.0.0
*/
interface Resolver_Interface {
/**
* Act on every registered sub-plugin whose standalone is active.
*
* Runs at plugins_loaded priority 1, before the load loop, and an implementation that
* deactivates is expected to end the request — so anything after it in that dispatch will
* not run.
*
* @since 1.0.0
*
* @return void
*/
public function resolve_all(): void;
}
72 changes: 71 additions & 1 deletion src/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

namespace Nexcess\PluginAbsorber;

use Nexcess\PluginAbsorber\Conflict\Resolver;
use Nexcess\PluginAbsorber\Conflict\Resolver_Interface;
use Nexcess\PluginAbsorber\Contracts\Notices_Interface;
use Nexcess\PluginAbsorber\Contracts\Registrar_Interface;
use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
Expand Down Expand Up @@ -38,6 +40,15 @@ class Loader {
*/
private const LOAD_PRIORITY = 2;

/**
* plugins_loaded priority conflict resolution runs at, ahead of the load loop.
*
* @since 1.0.0
*
* @var int
*/
private const RESOLVE_PRIORITY = 1;

/**
* Resolve an interface from the container when bound, else construct the default.
*
Expand Down Expand Up @@ -114,6 +125,17 @@ public static function notices(): Notices_Interface {
return self::resolve( Notices_Interface::class, Notices::class );
}

/**
* @since 1.0.0
*
* @throws Config_Exception When the container binding does not implement the interface.
*
* @return Resolver_Interface
*/
public static function resolver(): Resolver_Interface {
return self::resolve( Resolver_Interface::class, Resolver::class );
}

/**
* Register one bundled sub-plugin. Call once per sub-plugin, before boot().
*
Expand Down Expand Up @@ -169,6 +191,10 @@ public static function reset(): void {
* container optional. Each trampoline delegates to the resolved collaborator, so rebinding
* still takes effect.
*
* Called too late, this runs the work inline instead of wiring it — and conflict resolution
* can end the request, so on an admin page load this call may not return. Boot at
* plugins_loaded priority 0, as documented, and it always returns.
*
* @since 1.0.0
*
* @return void
Expand All @@ -195,18 +221,62 @@ public static function boot(): void {
if ( self::load_priority_has_passed() ) {
_doing_it_wrong(
__METHOD__,
'Loader::boot() must run before plugins_loaded priority 2. Loading inline instead.',
'Loader::boot() must run before plugins_loaded priority 2. Resolving and loading inline instead.',
'1.0.0'
);

// Same order as the hooks would have run in: a standalone that wins the conflict
// defines the guard constant, and the load loop has to see that.
self::run_conflict_resolution();
self::load_all();

return;
}

add_action( 'plugins_loaded', [ self::class, 'run_conflict_resolution' ], self::RESOLVE_PRIORITY );
add_action( 'plugins_loaded', [ self::class, 'load_all' ], self::LOAD_PRIORITY );
}

/**
* @since 1.0.0
*
* @return void
*/
public static function run_conflict_resolution(): void {
if ( ! self::is_interactive_admin_request() || ! self::has_hook_prefix() ) {
return;
}

self::resolver()->resolve_all();
}

/**
* Whether this request is one a person is watching in wp-admin.
*
* Conflict resolution deactivates a plugin and ends the request, so it must only run where
* someone is there to see the result. Unguarded it fires at plugins_loaded on every request:
* a visitor's checkout POST becomes a 302 that drops the order, a login POST bounces back to
* a blank form, wp-cron never reaches its event loop, and a WP-CLI command exits 0 having
* printed nothing, because header() is a no-op under the CLI SAPI.
*
* is_admin() alone is not enough: admin-ajax.php and admin-post.php both define WP_ADMIN.
*
* @since 1.0.0
*
* @return bool
*/
private static function is_interactive_admin_request(): bool {
if ( defined( 'WP_CLI' ) && WP_CLI ) {
return false;
}

if ( wp_doing_cron() || wp_doing_ajax() ) {
return false;
}

return is_admin();
}

/**
* Whether plugins_loaded has already run past the priority the load hook wants.
*
Expand Down
Loading
Loading