From 3242dadc47eb272fe53d2f2160a4f907a0859667 Mon Sep 17 00:00:00 2001 From: Malin Date: Mon, 3 Aug 2026 06:19:50 +0200 Subject: [PATCH] feat: document the nested-add_action-at-lower-priority hook bug (real incident, 3 plugins affected) --- skills/wordpress-plugin-conventions/SKILL.md | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/skills/wordpress-plugin-conventions/SKILL.md b/skills/wordpress-plugin-conventions/SKILL.md index 338d4ae..599f082 100644 --- a/skills/wordpress-plugin-conventions/SKILL.md +++ b/skills/wordpress-plugin-conventions/SKILL.md @@ -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`),