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 twice from two different code paths." Don't assume that guard is doing
more than it actually does. 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 ## Don't build what WordPress core already gives you
Before writing custom code for: cron scheduling (`wp_schedule_event`), Before writing custom code for: cron scheduling (`wp_schedule_event`),