feat: document the nested-add_action-at-lower-priority hook bug (real incident, 3 plugins affected)

This commit is contained in:
2026-08-03 06:19:50 +02:00
parent eeb08a0d77
commit 3242dadc47
@@ -142,6 +142,42 @@ time it's `require`'d a second time (as a stray plugin include), ABSPATH
twice from two different code paths." Don't assume that guard is doing
more than it actually does.
## Never `add_action('some_hook', ..., $lower_priority)` from inside a callback already running on `some_hook`
A callback added to a LOWER (earlier) priority than the one currently
executing, from inside another callback on the SAME hook, will silently
never run in the current pass. `WP_Hook::apply_filters()` snapshots the
sorted priority keys once at the start of each `do_action()`/
`apply_filters()` call; adding a new priority mid-iteration only affects
a *future* call to that hook, and most hooks (`plugins_loaded`, `init`,
etc.) only fire once per request. There is no error, no warning — the
nested callback's own registration line executes fine, it's the callback
*inside* it that never runs.
**Confirmed real incident, copied into 3 separate plugins before being
caught**: every iWP-branded plugin's bootstrap instantiated its shared
license/update-checker class like this:
```php
private function __construct() {
add_action('plugins_loaded', ['IWP_Cache', 'instance']); // default priority 10
}
// ...inside instance()'s constructor:
add_action('plugins_loaded', function () {
new IWP_Updater([...]);
}, 5); // priority 5 -- LOWER than the 10 already executing -- never runs
```
Verified directly on a live site (`wp eval 'global $wp_filter; var_dump(isset($wp_filter["pre_set_site_transient_update_plugins"]));'` returned `false`) that the updater's own filter registration — and therefore all license validation and update checking — was silently dead on every plugin using this pattern, since the plugin was first built.
**The fix**: don't nest a lower-priority `add_action` inside a callback
already running on that hook at all. If the code you're deferring doesn't
actually need to wait for anything else on that same hook (check: does
its own constructor register hooks on *other*, later-firing actions? If
so, timing within the current hook doesn't matter) — just call it
directly, immediately, inline. Only use a nested `add_action` on the
*same* hook if the target priority is equal-or-later than the one
currently executing (still fragile — prefer restructuring to avoid the
nesting entirely).
## Don't build what WordPress core already gives you
Before writing custom code for: cron scheduling (`wp_schedule_event`),