diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ddef3d0..9107201d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,8 @@ * Fixed a fatal error in the Elementor editor on Elementor versions older than 3.19. * Fixed snippet descriptions staying truncated after the "Truncate long snippet names and descriptions" screen option was switched off. +* Fixed saving, activating or deactivating a snippet reporting its previous state, which could leave a deactivated + snippet running under file-based execution. ## [3.10.2] (2026-09-01) diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index 379050d71..8f6dc441b 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -455,6 +455,11 @@ function activate_snippet( int $id, ?bool $network = null ) { return __( 'Could not activate snippet.', 'code-snippets' ); } + // Read back over a cleared cache, so what follows sees the snippet as + // active rather than as it was fetched for validation above. + clean_snippets_cache( $table_name ); + $snippet = get_snippet( $id, $network ); + update_shared_network_snippets( [ $snippet ] ); do_action( 'code_snippets/activate_snippet', $snippet, $network ); clean_snippets_cache( $table_name ); @@ -527,6 +532,8 @@ function activate_snippets( array $ids, ?bool $network = null ): ?array { return null; } + clean_snippets_cache( $table_name ); + update_shared_network_snippets( $valid_snippets ); do_action( 'code_snippets/activate_snippets', $valid_snippets, $table_name ); clean_snippets_cache( $table_name ); @@ -562,8 +569,12 @@ function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet { return null; } + // Read back over a cleared cache, so the snippet is seen as inactive by + // everything below rather than as it stood before the write. + clean_snippets_cache( $table ); + $snippet = get_snippet( $id, $network ); + // Update the recently active list. - $snippet = get_snippet( $id ); $recently_active = get_self_option( $network, 'recently_active_snippets', [] ); $recently_active[ $id ] = time(); update_self_option( $network, 'recently_active_snippets', $recently_active ); @@ -605,8 +616,8 @@ function delete_snippet( int $id, ?bool $network = null ): bool { ); if ( $result ) { - do_action( 'code_snippets/delete_snippet', $snippet, $network ); clean_snippets_cache( $table ); + do_action( 'code_snippets/delete_snippet', $snippet, $network ); $recently_active = get_self_option( $network, 'recently_active_snippets', [] ); @@ -644,8 +655,8 @@ function trash_snippet( int $id, ?bool $network = null ): bool { $wpdb->update( $table, [ 'active' => '-1' ], [ 'id' => $id ], [ '%d' ] ); - do_action( 'code_snippets/trash_snippet', $snippet, $network ); clean_snippets_cache( $table ); + do_action( 'code_snippets/trash_snippet', $snippet, $network ); return true; } @@ -669,8 +680,8 @@ function restore_snippet( int $id, ?bool $network = null ): bool { $result = $wpdb->update( $table, [ 'active' => '0' ], [ 'id' => $id ], [ '%d' ] ); if ( $result ) { - do_action( 'code_snippets/restore_snippet', $id, $network ); clean_snippets_cache( $table ); + do_action( 'code_snippets/restore_snippet', $id, $network ); } return (bool) $result; @@ -810,6 +821,12 @@ function save_snippet( $snippet ): ?Snippet { set_snippet_locked( $snippet->id, $snippet->locked, $snippet->network ); $wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] ); + // The row has changed, so the cached list no longer describes it. It is + // dropped before the snippet is read back, because everything below — + // the value this returns and what its hooks are handed — has to be the + // saved snippet rather than the one that was there beforehand. + clean_snippets_cache( $table ); + $updated = get_snippet( $snippet->id, $snippet->network ); $updated->code_error = $snippet->code_error; $updated->code_error_trace = $snippet->code_error_trace; diff --git a/tests/unit/Snippets/Cache_Invalidation_Order_Test.php b/tests/unit/Snippets/Cache_Invalidation_Order_Test.php new file mode 100644 index 000000000..01009ae3b --- /dev/null +++ b/tests/unit/Snippets/Cache_Invalidation_Order_Test.php @@ -0,0 +1,219 @@ + 'Cache ordering fixture', + 'code' => '// ORIGINAL', + 'scope' => 'global', + 'active' => false, + ], + $fields + ) + ); + + return save_snippet( $snippet )->id; + } + + /** + * Populate the cached snippet list, as any earlier read in the request would. + * + * @return void + */ + private function warm_cache(): void { + get_snippets(); + } + + /** + * Read a field straight from the database, bypassing every cache. + * + * @param int $id Snippet identifier. + * @param string $field Column to read. + * + * @return string + */ + private function read_from_database( int $id, string $field ): string { + global $wpdb; + $table = code_snippets()->db->table; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- reading one column of a known table to bypass the cache under test. + return (string) $wpdb->get_var( $wpdb->prepare( "SELECT `$field` FROM $table WHERE id = %d", $id ) ); + } + + /** + * An edited snippet is returned as it was saved, not as it was beforehand. + * + * The snippet is cloned out of the cache first. A persistent object cache + * serialises, so a caller holds its own instance; the default cache hands + * back the very object it stores, and mutating that would quietly update + * the cached copy too and hide the fault this covers. + * + * @return void + */ + public function test_saving_returns_the_saved_snippet(): void { + $id = $this->create_snippet(); + $this->warm_cache(); + + $edit = clone get_snippet( $id ); + $edit->code = '// EDITED'; + $returned = save_snippet( $edit ); + + $this->assertSame( '// EDITED', $this->read_from_database( $id, 'code' ), 'the edit should reach the database' ); + $this->assertSame( '// EDITED', $returned->code, 'saving should return the snippet as saved, not as it was before' ); + } + + /** + * An edited snippet reaches its hook as it was saved. + * + * @return void + */ + public function test_saving_hands_the_saved_snippet_to_its_hook(): void { + $id = $this->create_snippet(); + $this->warm_cache(); + + $received = null; + add_action( + 'code_snippets/update_snippet', + function ( Snippet $updated ) use ( &$received ) { + $received = $updated->code; + } + ); + + $edit = clone get_snippet( $id ); + $edit->code = '// EDITED'; + save_snippet( $edit ); + + $this->assertSame( '// EDITED', $received, 'the hook should receive the snippet as saved' ); + } + + /** + * Activating reports the snippet as active. + * + * @return void + */ + public function test_activating_returns_an_active_snippet(): void { + $id = $this->create_snippet(); + $this->warm_cache(); + + $activated = activate_snippet( $id ); + + $this->assertInstanceOf( Snippet::class, $activated, 'activation should succeed' ); + $this->assertSame( '1', $this->read_from_database( $id, 'active' ), 'activation should reach the database' ); + $this->assertTrue( $activated->active, 'activation should return the snippet as active' ); + } + + /** + * A snippet reaches the activation hook already marked active. + * + * @return void + */ + public function test_activating_hands_an_active_snippet_to_its_hook(): void { + $id = $this->create_snippet(); + $this->warm_cache(); + + $received = null; + add_action( + 'code_snippets/activate_snippet', + function ( Snippet $snippet ) use ( &$received ) { + $received = $snippet->active; + } + ); + + activate_snippet( $id ); + + $this->assertTrue( $received, 'the hook should receive the snippet as active' ); + } + + /** + * Deactivating reports the snippet as inactive. + * + * @return void + */ + public function test_deactivating_returns_an_inactive_snippet(): void { + $id = $this->create_snippet( [ 'active' => true ] ); + $this->warm_cache(); + + $deactivated = deactivate_snippet( $id ); + + $this->assertInstanceOf( Snippet::class, $deactivated, 'deactivation should succeed' ); + $this->assertSame( '0', $this->read_from_database( $id, 'active' ), 'deactivation should reach the database' ); + $this->assertFalse( $deactivated->active, 'deactivation should return the snippet as inactive' ); + } + + /** + * A listener on the deactivation hook no longer sees an active snippet. + * + * The hook passes an identifier rather than the snippet, so a listener has + * to read it back. File-based execution does exactly this, and wrote the + * snippet to disk still marked active. + * + * @return void + */ + public function test_deactivation_hook_reads_back_an_inactive_snippet(): void { + $id = $this->create_snippet( [ 'active' => true ] ); + $this->warm_cache(); + + $received = null; + add_action( + 'code_snippets/deactivate_snippet', + function ( int $snippet_id, bool $network ) use ( &$received ) { + $received = get_snippet( $snippet_id, $network )->active; + }, + 10, + 2 + ); + + deactivate_snippet( $id ); + + $this->assertFalse( $received, 'a listener reading the snippet back should see it inactive' ); + } + + /** + * A listener on the deletion hook no longer finds the deleted snippet. + * + * @return void + */ + public function test_deletion_hook_no_longer_finds_the_snippet(): void { + $id = $this->create_snippet(); + $this->warm_cache(); + + $still_listed = null; + add_action( + 'code_snippets/delete_snippet', + function () use ( $id, &$still_listed ) { + $still_listed = in_array( $id, wp_list_pluck( get_snippets(), 'id' ), true ); + } + ); + + delete_snippet( $id ); + + $this->assertFalse( $still_listed, 'the deleted snippet should be gone from the list a listener reads' ); + } +}