Skip to content

Commit 9c6ef19

Browse files
committed
fix: preserve deferred WordPress hook lifecycle
1 parent e57a542 commit 9c6ef19

2 files changed

Lines changed: 93 additions & 1 deletion

File tree

packages/runtime-playground/src/phpunit-command-handlers.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -537,16 +537,34 @@ function pg_defer_new_wordpress_hook_callbacks(string $hook_name, array $before)
537537
}
538538
539539
function pg_run_deferred_wordpress_hook_callbacks(array $deferred, array $args = array(), ?string $hook_name = null): void {
540-
global $wp_current_filter;
540+
global $wp_filter, $wp_current_filter;
541541
$pushed_hook = false;
542+
$original_hook = null;
543+
$replay_hook = null;
542544
if (is_string($hook_name) && $hook_name !== '') {
543545
if (!is_array($wp_current_filter)) {
544546
$wp_current_filter = array();
545547
}
546548
$wp_current_filter[] = $hook_name;
547549
$pushed_hook = true;
550+
$original_hook = $wp_filter[$hook_name] ?? null;
551+
$replay_hook = new WP_Hook();
552+
$wp_filter[$hook_name] = $replay_hook;
553+
foreach ($deferred as $entry) {
554+
$callback = $entry['callback'] ?? null;
555+
if (!is_array($callback) || !isset($callback['function'])) {
556+
continue;
557+
}
558+
$priority = isset($entry['priority']) ? (int) $entry['priority'] : 10;
559+
$accepted_args = isset($callback['accepted_args']) ? (int) $callback['accepted_args'] : count($args);
560+
add_filter($hook_name, $callback['function'], $priority, $accepted_args);
561+
}
548562
}
549563
try {
564+
if ($replay_hook instanceof WP_Hook) {
565+
$replay_hook->do_action($args);
566+
return;
567+
}
550568
foreach ($deferred as $entry) {
551569
$callback = $entry['callback'] ?? null;
552570
if (!is_array($callback) || !isset($callback['function'])) {
@@ -556,6 +574,21 @@ function pg_run_deferred_wordpress_hook_callbacks(array $deferred, array $args =
556574
call_user_func_array($callback['function'], array_slice($args, 0, $accepted_args));
557575
}
558576
} finally {
577+
if ($replay_hook instanceof WP_Hook && is_string($hook_name)) {
578+
$retained_callbacks = $replay_hook->callbacks;
579+
if ($original_hook instanceof WP_Hook) {
580+
$wp_filter[$hook_name] = $original_hook;
581+
} else {
582+
unset($wp_filter[$hook_name]);
583+
}
584+
foreach ($retained_callbacks as $priority => $callbacks) {
585+
foreach ($callbacks as $callback) {
586+
if (isset($callback['function'])) {
587+
add_filter($hook_name, $callback['function'], (int) $priority, (int) ($callback['accepted_args'] ?? 0));
588+
}
589+
}
590+
}
591+
}
559592
if ($pushed_hook) {
560593
array_pop($wp_current_filter);
561594
}

tests/phpunit-project-autoload.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,63 @@ function phpString(value: string): string {
6565
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`
6666
}
6767

68+
function assertDeferredHookReplayUsesWordPressLifecycle(source: string): void {
69+
const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-deferred-hook-"))
70+
const scriptPath = join(tempDir, "assert-deferred-hook.php")
71+
writeFileSync(scriptPath, `<?php
72+
class WP_Hook {
73+
public array $callbacks = array();
74+
public function add_filter($hook, $callback, $priority, $accepted_args): void {
75+
$id = is_string($callback) ? $callback : spl_object_hash($callback);
76+
$this->callbacks[$priority][$id] = array('function' => $callback, 'accepted_args' => $accepted_args);
77+
}
78+
public function do_action($args): void {
79+
$completed = array();
80+
while (true) {
81+
ksort($this->callbacks);
82+
$next = null;
83+
foreach ($this->callbacks as $priority => $callbacks) {
84+
foreach ($callbacks as $id => $callback) {
85+
$key = $priority . ':' . $id;
86+
if (!isset($completed[$key])) {
87+
$next = array($key, $callback);
88+
break 2;
89+
}
90+
}
91+
}
92+
if ($next === null) return;
93+
$completed[$next[0]] = true;
94+
call_user_func_array($next[1]['function'], array_slice($args, 0, $next[1]['accepted_args']));
95+
}
96+
}
97+
}
98+
function add_filter($hook, $callback, $priority = 10, $accepted_args = 1): bool {
99+
global $wp_filter;
100+
if (!isset($wp_filter[$hook])) $wp_filter[$hook] = new WP_Hook();
101+
$wp_filter[$hook]->add_filter($hook, $callback, $priority, $accepted_args);
102+
return true;
103+
}
104+
function add_action($hook, $callback, $priority = 10, $accepted_args = 1): bool {
105+
return add_filter($hook, $callback, $priority, $accepted_args);
106+
}
107+
${extractPhpFunction(source, "pg_run_deferred_wordpress_hook_callbacks")}
108+
$events = array();
109+
$wp_current_filter = array();
110+
$wp_filter = array('init' => new WP_Hook());
111+
add_action('init', function() use (&$events) { $events[] = 'core-must-not-replay'; }, 5, 0);
112+
$early = function() use (&$events) {
113+
$events[] = 'early';
114+
add_action('init', function() use (&$events) { $events[] = 'late'; }, 15, 0);
115+
};
116+
pg_run_deferred_wordpress_hook_callbacks(array(array('priority' => 0, 'callback' => array('function' => $early, 'accepted_args' => 0))), array(), 'init');
117+
if ($events !== array('early', 'late')) throw new RuntimeException('deferred init order was not faithful: ' . json_encode($events));
118+
if (count($wp_filter['init']->callbacks, COUNT_RECURSIVE) < 6) throw new RuntimeException('replayed callbacks were not retained');
119+
if ($wp_current_filter !== array()) throw new RuntimeException('current filter stack leaked');
120+
echo "ok\n";
121+
`)
122+
assert.equal(execFileSync("php", [scriptPath], { encoding: "utf8" }), "ok\n")
123+
}
124+
68125
function assertPhpunitConfigurationAndDiscoveryFailures(source: string, functionName: string, discoveryFunctionName: string, logFunctionName: string, supportsImplicitFallback: boolean): void {
69126
const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-config-"))
70127
const malformedXml = join(tempDir, "phpunit.xml.dist")
@@ -663,6 +720,8 @@ const managedModeCode = phpunitRunCode({
663720
databaseType: "sqlite",
664721
})
665722

723+
assertDeferredHookReplayUsesWordPressLifecycle(managedModeCode)
724+
666725
assert.ok(managedModeCode.includes("configured PHPUnit harness autoload file is not readable"))
667726
assert.ok(managedModeCode.includes("define('DB_NAME', ':memory:');"), "default managed PHPUnit remains on SQLite")
668727
assert.ok(managedModeCode.includes("'cacheResult' => false"))

0 commit comments

Comments
 (0)