From 5163e84ea6539a6203c67d97a2d018dc9f0df206 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 9 Nov 2024 22:04:36 -0800 Subject: [PATCH 01/11] Include queried object in URL Metric to clean cache when stored --- plugins/embed-optimizer/tests/test-hooks.php | 1 + .../class-od-url-metric.php | 103 ++++++++++++------ plugins/optimization-detective/detect.js | 7 ++ plugins/optimization-detective/detection.php | 20 +++- plugins/optimization-detective/hooks.php | 1 + .../optimization-detective/storage/data.php | 25 +++-- .../storage/rest-api.php | 41 ++++++- .../tests/storage/test-data.php | 42 ++++++- .../tests/storage/test-rest-api.php | 19 +++- .../tests/test-class-od-url-metric.php | 29 ++++- plugins/optimization-detective/types.ts | 6 + 11 files changed, 238 insertions(+), 56 deletions(-) diff --git a/plugins/embed-optimizer/tests/test-hooks.php b/plugins/embed-optimizer/tests/test-hooks.php index b9b55d8e70..2980927888 100644 --- a/plugins/embed-optimizer/tests/test-hooks.php +++ b/plugins/embed-optimizer/tests/test-hooks.php @@ -18,6 +18,7 @@ public function test_embed_optimizer_add_hooks(): void { $this->assertSame( 10, has_action( 'od_init', 'embed_optimizer_init_optimization_detective' ) ); $this->assertSame( 10, has_action( 'wp_head', 'embed_optimizer_render_generator' ) ); $this->assertSame( 10, has_action( 'wp_loaded', 'embed_optimizer_add_non_optimization_detective_hooks' ) ); + $this->assertSame( 10, has_action( 'od_url_metric_stored', 'od_clean_queried_object_cache_for_stored_url_metric' ) ); } /** diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index d76890aa3f..a369e29a9a 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -14,35 +14,40 @@ /** * Representation of the measurements taken from a single client's visit to a specific URL. * - * @phpstan-type ViewportRect array{ - * width: int, - * height: int - * } - * @phpstan-type DOMRect array{ - * width: float, - * height: float, - * x: float, - * y: float, - * top: float, - * right: float, - * bottom: float, - * left: float - * } - * @phpstan-type ElementData array{ - * isLCP: bool, - * isLCPCandidate: bool, - * xpath: non-empty-string, - * intersectionRatio: float, - * intersectionRect: DOMRect, - * boundingClientRect: DOMRect, - * } - * @phpstan-type Data array{ - * uuid: non-empty-string, - * url: non-empty-string, - * timestamp: float, - * viewport: ViewportRect, - * elements: ElementData[] - * } + * @phpstan-type ViewportRect array{ + * width: int, + * height: int + * } + * @phpstan-type QueriedObject array{ + * type: 'post'|'user'|'term', + * id: int + * } + * @phpstan-type DOMRect array{ + * width: float, + * height: float, + * x: float, + * y: float, + * top: float, + * right: float, + * bottom: float, + * left: float + * } + * @phpstan-type ElementData array{ + * isLCP: bool, + * isLCPCandidate: bool, + * xpath: non-empty-string, + * intersectionRatio: float, + * intersectionRect: DOMRect, + * boundingClientRect: DOMRect, + * } + * @phpstan-type Data array{ + * uuid: non-empty-string, + * url: non-empty-string, + * queriedObject: null|QueriedObject, + * timestamp: float, + * viewport: ViewportRect, + * elements: ElementData[] + * } * * @since 0.1.0 * @access private @@ -199,21 +204,40 @@ public static function get_json_schema(): array { 'type' => 'object', 'required' => true, 'properties' => array( - 'uuid' => array( + 'uuid' => array( 'description' => __( 'The UUID for the URL Metric.', 'optimization-detective' ), 'type' => 'string', 'format' => 'uuid', 'required' => true, 'readonly' => true, // Omit from REST API. ), - 'url' => array( + 'url' => array( 'description' => __( 'The URL for which the metric was obtained.', 'optimization-detective' ), 'type' => 'string', 'required' => true, 'format' => 'uri', 'pattern' => '^https?://', ), - 'viewport' => array( + 'queriedObject' => array( + 'type' => 'object', + 'required' => false, // Not required since a query like is_home() will not have any queried object. + 'properties' => array( + 'type' => array( + 'type' => array( 'string' ), + 'description' => __( 'Queried object type.', 'optimization-detective' ), + 'required' => true, + 'enum' => array( 'post', 'term', 'user' ), // TODO: Should post_type be supported? There is no ID in this case, but the post type slug is used. But we don't need it to flush the cache. + ), + 'id' => array( + 'type' => 'integer', + 'description' => __( 'Queried object ID.', 'optimization-detective' ), + 'required' => true, + 'minimum' => 1, + ), + ), + 'additionalProperties' => false, + ), + 'viewport' => array( 'description' => __( 'Viewport dimensions', 'optimization-detective' ), 'type' => 'object', 'required' => true, @@ -231,14 +255,14 @@ public static function get_json_schema(): array { ), 'additionalProperties' => false, ), - 'timestamp' => array( + 'timestamp' => array( 'description' => __( 'Timestamp at which the URL Metric was captured.', 'optimization-detective' ), 'type' => 'number', 'required' => true, 'readonly' => true, // Omit from REST API. 'minimum' => 0, ), - 'elements' => array( + 'elements' => array( 'description' => __( 'Element metrics', 'optimization-detective' ), 'type' => 'array', 'required' => true, @@ -422,6 +446,17 @@ public function get_url(): string { return $this->data['url']; } + /** + * Gets queried object. + * + * @since n.e.x.t + * + * @return QueriedObject|null Queried object. + */ + public function get_queried_object(): ?array { + return $this->data['queriedObject'] ?? null; + } + /** * Gets viewport data. * diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 8784005336..38873a6724 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -2,6 +2,7 @@ * @typedef {import("web-vitals").LCPMetric} LCPMetric * @typedef {import("./types.ts").ElementData} ElementData * @typedef {import("./types.ts").URLMetric} URLMetric + * @typedef {import("./types.ts").QueriedObject} QueriedObject * @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus * @typedef {import("./types.ts").Extension} Extension * @typedef {import("./types.ts").ExtendedRootData} ExtendedRootData @@ -239,6 +240,7 @@ function extendElementData( xpath, properties ) { * @param {string} args.restApiEndpoint URL for where to send the detection data. * @param {string} args.currentUrl Current URL. * @param {string} args.urlMetricSlug Slug for URL Metric. + * @param {QueriedObject} [args.queriedObject] Queried object. * @param {string} args.urlMetricHMAC HMAC for URL Metric storage. * @param {URLMetricGroupStatus[]} args.urlMetricGroupStatuses URL Metric group statuses. * @param {number} args.storageLockTTL The TTL (in seconds) for the URL Metric storage lock. @@ -253,6 +255,7 @@ export default async function detect( { restApiEndpoint, currentUrl, urlMetricSlug, + queriedObject, urlMetricHMAC, urlMetricGroupStatuses, storageLockTTL, @@ -446,6 +449,10 @@ export default async function detect( { elements: [], }; + if ( queriedObject ) { + urlMetric.queriedObject = queriedObject; + } + const lcpMetric = lcpMetricCandidates.at( -1 ); for ( const elementIntersection of elementIntersections ) { diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index 30cd510112..f13dc71add 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -32,6 +32,20 @@ function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $ */ $extension_module_urls = (array) apply_filters( 'od_extension_module_urls', array() ); + // Obtain the queried object so when a URL Metric is stored the endpoint will know which object's cache to clean. + // Note that WP_Post_Type is intentionally excluded here since there is no equivalent to clean_post_cache(), clean_term_cache(), and clean_user_cache(). + $queried_object = get_queried_object(); + if ( $queried_object instanceof WP_Post ) { + $queried_object_type = 'post'; + } elseif ( $queried_object instanceof WP_Term ) { + $queried_object_type = 'term'; + } elseif ( $queried_object instanceof WP_User ) { + $queried_object_type = 'user'; + } else { + $queried_object_type = null; + } + $queried_object_id = null === $queried_object_type ? null : (int) get_queried_object_id(); + $current_url = od_get_current_url(); $detect_args = array( 'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(), @@ -41,7 +55,11 @@ function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $ 'restApiEndpoint' => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ), 'currentUrl' => $current_url, 'urlMetricSlug' => $slug, - 'urlMetricHMAC' => od_get_url_metrics_storage_hmac( $slug, $current_url ), + 'queriedObject' => null === $queried_object_type ? null : array( + 'type' => $queried_object_type, + 'id' => $queried_object_id, + ), + 'urlMetricHMAC' => od_get_url_metrics_storage_hmac( $slug, $current_url, $queried_object_type, $queried_object_id ), 'urlMetricGroupStatuses' => array_map( static function ( OD_URL_Metric_Group $group ): array { return array( diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index c0f94d148c..b8d1073296 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -15,3 +15,4 @@ OD_URL_Metrics_Post_Type::add_hooks(); add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' ); add_action( 'wp_head', 'od_render_generator_meta_tag' ); +add_action( 'od_url_metric_stored', 'od_clean_queried_object_cache_for_stored_url_metric' ); diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index 99708c7af3..59358b2476 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -150,14 +150,16 @@ function od_get_url_metrics_slug( array $query_vars ): string { * * @see od_verify_url_metrics_storage_hmac() * @see od_get_url_metrics_slug() + * @todo This should also include an ETag as a parameter. See . * - * @param string $slug Slug (hash of normalized query vars). - * @param string $url URL. - * + * @param string $slug Slug (hash of normalized query vars). + * @param string $url URL. + * @param string|null $queried_object_type Queried object type. + * @param int|null $queried_object_id Queried object ID. * @return string HMAC. */ -function od_get_url_metrics_storage_hmac( string $slug, string $url ): string { - $action = "store_url_metric:$slug:$url"; +function od_get_url_metrics_storage_hmac( string $slug, string $url, ?string $queried_object_type = null, ?int $queried_object_id = null ): string { + $action = "store_url_metric:$slug:$url:$queried_object_type:$queried_object_id"; return wp_hash( $action, 'nonce' ); } @@ -170,14 +172,15 @@ function od_get_url_metrics_storage_hmac( string $slug, string $url ): string { * @see od_get_url_metrics_storage_hmac() * @see od_get_url_metrics_slug() * - * @param string $hmac HMAC. - * @param string $slug Slug (hash of normalized query vars). - * @param String $url URL. - * + * @param string $hmac HMAC. + * @param string $slug Slug (hash of normalized query vars). + * @param String $url URL. + * @param string|null $queried_object_type Queried object type. + * @param int|null $queried_object_id Queried object ID. * @return bool Whether the HMAC is valid. */ -function od_verify_url_metrics_storage_hmac( string $hmac, string $slug, string $url ): bool { - return hash_equals( od_get_url_metrics_storage_hmac( $slug, $url ), $hmac ); +function od_verify_url_metrics_storage_hmac( string $hmac, string $slug, string $url, ?string $queried_object_type = null, ?int $queried_object_id = null ): bool { + return hash_equals( od_get_url_metrics_storage_hmac( $slug, $url, $queried_object_type, $queried_object_id ), $hmac ); } /** diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 9279c41133..e0e4784b99 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -52,7 +52,7 @@ function od_register_endpoint(): void { 'required' => true, 'pattern' => '^[0-9a-f]+$', 'validate_callback' => static function ( string $hmac, WP_REST_Request $request ) { - if ( ! od_verify_url_metrics_storage_hmac( $hmac, $request->get_param( 'slug' ), $request->get_param( 'url' ) ) ) { + if ( ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['url'], $request['queriedObject']['type'] ?? null, $request['queriedObject']['id'] ?? null ) ) { return new WP_Error( 'invalid_hmac', __( 'URL Metrics HMAC verification failure.', 'optimization-detective' ) ); } return true; @@ -206,6 +206,7 @@ function od_handle_rest_request( WP_REST_Request $request ) { * Fires whenever a URL Metric was successfully stored. * * @since 0.7.0 + * @todo Add this to the README as documentation. * * @param OD_URL_Metric_Store_Request_Context $context Context about the successful URL Metric collection. */ @@ -226,3 +227,41 @@ function od_handle_rest_request( WP_REST_Request $request ) { ) ); } + +/** + * Cleans the cache for the queried object when it has a new URL Metric stored. + * + * This is intended to flush any page cache for the URL after the new URL Metric was submitted so that the optimizations + * which depend on that URL Metric can start to take effect. Furthermore, when a submitted URL Metric results in a full + * sample of URL Metric groups, then flushing the page cache will allow the next request to omit the detection script + * module altogether. When a page cache holds onto a cached page for a long time (e.g. a week), this will result in + * the stored URL Metrics being stale if they have the default freshness TTL of 1 day. Nevertheless, if no changes have + * been applied to a cached page then those stale URL Metrics should continue to result in an optimized page. + * + * This assumes that a page caching plugin flushes the page cache for a queried object via `clean_post_cache`, + * `clean_term_cache`, and `clean_user_cache` actions. Other actions may make sense to trigger as well as can be seen in + * {@link https://github.com/pantheon-systems/pantheon-advanced-page-cache/blob/e3b5552/README.md?plain=1#L314-L356}. + * + * @since n.e.x.t + * + * @param OD_URL_Metric_Store_Request_Context $context Context. + */ +function od_clean_queried_object_cache_for_stored_url_metric( OD_URL_Metric_Store_Request_Context $context ): void { + $queried_object = $context->url_metric->get_queried_object(); + if ( ! is_array( $queried_object ) ) { + return; + } + + // TODO: Should this instead call do_action() directly since we don't actually need to clear the object cache but just want to trigger page caches to flush the page caches? + switch ( $queried_object['type'] ) { + case 'post': + clean_post_cache( $queried_object['id'] ); + break; + case 'term': + clean_term_cache( $queried_object['id'] ); + break; + case 'user': + clean_user_cache( $queried_object['id'] ); + break; + } +} diff --git a/plugins/optimization-detective/tests/storage/test-data.php b/plugins/optimization-detective/tests/storage/test-data.php index 6ef84e7573..140bab51b2 100644 --- a/plugins/optimization-detective/tests/storage/test-data.php +++ b/plugins/optimization-detective/tests/storage/test-data.php @@ -286,18 +286,50 @@ public function test_od_get_url_metrics_slug(): void { } } + /** + * Data provider. + * + * @return array Data. + */ + public function data_provider_to_test_hmac(): array { + return array( + 'home' => array( + 'url' => static function () { + return home_url(); + }, + 'slug' => od_get_url_metrics_slug( array() ), + ), + 'post' => array( + 'url' => static function () { + wp_update_post( + array( + 'ID' => 1, + 'post_type' => 'post', + 'post_title' => 'Hello!', + ) + ); + return get_permalink( 1 ); + }, + 'slug' => od_get_url_metrics_slug( array( 'p' => 1 ) ), + 'queried_object_type' => 'post', + 'queried_object_id' => 1, + ), + ); + } + /** * Test od_get_url_metrics_storage_hmac() and od_verify_url_metrics_storage_hmac(). * + * @dataProvider data_provider_to_test_hmac + * * @covers ::od_get_url_metrics_storage_hmac * @covers ::od_verify_url_metrics_storage_hmac */ - public function test_od_get_url_metrics_storage_hmac_and_od_verify_url_metrics_storage_hmac(): void { - $url = home_url( '/' ); - $slug = od_get_url_metrics_slug( array() ); - $hmac = od_get_url_metrics_storage_hmac( $slug, $url ); + public function test_od_get_url_metrics_storage_hmac_and_od_verify_url_metrics_storage_hmac( Closure $get_url, string $slug, ?string $queried_object_type = null, ?int $queried_object_id = null ): void { + $url = $get_url(); + $hmac = od_get_url_metrics_storage_hmac( $slug, $url, $queried_object_type, $queried_object_id ); $this->assertMatchesRegularExpression( '/^[0-9a-f]+$/', $hmac ); - $this->assertTrue( od_verify_url_metrics_storage_hmac( $hmac, $slug, $url ) ); + $this->assertTrue( od_verify_url_metrics_storage_hmac( $hmac, $slug, $url, $queried_object_type, $queried_object_id ) ); } /** diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index 72f25f6cb4..512c206bc9 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -29,12 +29,24 @@ public function test_od_register_endpoint_hooked(): void { */ public function data_provider_to_test_rest_request_good_params(): array { return array( - 'not_extended' => array( + 'not_extended' => array( 'set_up' => function () { return $this->get_valid_params(); }, ), - 'extended' => array( + 'with_queried_object' => array( + 'set_up' => function () { + $post_id = self::factory()->post->create(); + $valid_params = $this->get_valid_params(); + $valid_params['hmac'] = od_get_url_metrics_storage_hmac( $valid_params['slug'], $valid_params['url'], 'post', $post_id ); + $valid_params['queriedObject'] = array( + 'type' => 'post', + 'id' => $post_id, + ); + return $valid_params; + }, + ), + 'extended' => array( 'set_up' => function () { add_filter( 'od_url_metric_schema_root_additional_properties', @@ -128,6 +140,9 @@ function ( $params ) { 'invalid_hmac' => array( 'hmac' => od_get_url_metrics_storage_hmac( od_get_url_metrics_slug( array( 'different' => 'query vars' ) ), home_url( '/' ) ), ), + 'invalid_hmac_with_queried_object' => array( + 'hmac' => od_get_url_metrics_storage_hmac( od_get_url_metrics_slug( array() ), home_url( '/' ), 'post', 1 ), + ), 'invalid_viewport_type' => array( 'viewport' => '640x480', ), diff --git a/plugins/optimization-detective/tests/test-class-od-url-metric.php b/plugins/optimization-detective/tests/test-class-od-url-metric.php index 71ba5799a6..3c9274b6f2 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metric.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metric.php @@ -48,6 +48,18 @@ public function data_provider_to_test_constructor(): array { ), ), ), + 'valid_with_queried_object' => array( + 'data' => array( + 'url' => home_url( '/' ), + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + 'queriedObject' => array( + 'type' => 'post', + 'id' => 1, + ), + ), + ), // This tests that sanitization converts values into their expected PHP types. 'valid_but_props_are_strings' => array( 'data' => array( @@ -110,6 +122,19 @@ static function ( $value ) { ), 'error' => 'OD_URL_Metric[viewport][height] is not of type integer.', ), + 'bad_queried_object' => array( + 'data' => array( + 'url' => home_url( '/' ), + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + 'queriedObject' => array( + 'type' => 'story', + 'id' => 1, + ), + ), + 'error' => 'OD_URL_Metric[queriedObject][type] is not one of post, term, and user', + ), 'viewport_aspect_ratio_too_small' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), @@ -715,14 +740,14 @@ public function test_get_json_schema_extensibility( Closure $set_up, Closure $as */ protected function check_schema_subset( array $schema, string $path, bool $extended = false ): void { $this->assertArrayHasKey( 'required', $schema, $path ); - if ( ! $extended ) { + if ( ! $extended && ! str_starts_with( $path, 'root/queriedObject' ) ) { $this->assertTrue( $schema['required'], $path ); } $this->assertArrayHasKey( 'type', $schema, $path ); if ( 'object' === $schema['type'] ) { $this->assertArrayHasKey( 'properties', $schema, $path ); $this->assertArrayHasKey( 'additionalProperties', $schema, $path ); - if ( 'root/viewport' === $path || 'root/elements/items/intersectionRect' === $path || 'root/elements/items/boundingClientRect' === $path ) { + if ( 'root/viewport' === $path || 'root/queriedObject' === $path || 'root/elements/items/intersectionRect' === $path || 'root/elements/items/boundingClientRect' === $path ) { $this->assertFalse( $schema['additionalProperties'], "Path: $path" ); } else { $this->assertTrue( $schema['additionalProperties'], "Path: $path" ); diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts index fc4e375b60..bb877276a6 100644 --- a/plugins/optimization-detective/types.ts +++ b/plugins/optimization-detective/types.ts @@ -12,8 +12,14 @@ export interface ElementData { export type ExtendedElementData = ExcludeProps< ElementData >; +export interface QueriedObject { + type: 'post' | 'term' | 'user'; + id: number; +} + export interface URLMetric { url: string; + queriedObject?: QueriedObject; viewport: { width: number; height: number; From c97cc07d30f0b9e2162652a4df511c4bc54ac706 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 9 Nov 2024 22:17:07 -0800 Subject: [PATCH 02/11] Add missing since tags --- .../class-od-url-metric.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index a369e29a9a..9cdf774104 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -159,6 +159,8 @@ public function set_group( OD_URL_Metric_Group $group ): void { /** * Gets JSON schema for URL Metric. * + * @since 0.1.0 + * * @todo Cache the return value? * * @return array Schema. @@ -431,6 +433,8 @@ public function get( string $key ) { /** * Gets UUID. * + * @since 0.6.0 + * * @return string UUID. */ public function get_uuid(): string { @@ -440,6 +444,8 @@ public function get_uuid(): string { /** * Gets URL. * + * @since 0.1.0 + * * @return string URL. */ public function get_url(): string { @@ -460,6 +466,8 @@ public function get_queried_object(): ?array { /** * Gets viewport data. * + * @since 0.1.0 + * * @return ViewportRect Viewport data. */ public function get_viewport(): array { @@ -469,6 +477,8 @@ public function get_viewport(): array { /** * Gets viewport width. * + * @since 0.1.0 + * * @return int Viewport width. */ public function get_viewport_width(): int { @@ -478,6 +488,8 @@ public function get_viewport_width(): int { /** * Gets timestamp. * + * @since 0.1.0 + * * @return float Timestamp. */ public function get_timestamp(): float { @@ -487,6 +499,8 @@ public function get_timestamp(): float { /** * Gets elements. * + * @since 0.1.0 + * * @return OD_Element[] Elements. */ public function get_elements(): array { @@ -504,6 +518,8 @@ function ( array $element ): OD_Element { /** * Specifies data which should be serialized to JSON. * + * @since 0.1.0 + * * @return Data Exports to be serialized by json_encode(). */ public function jsonSerialize(): array { From 137246d0f3e6b84cc3f9c6e3c63f78a4c97ff1a6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 Nov 2024 14:45:36 -0800 Subject: [PATCH 03/11] Fire action to clean the cache for the queried object when URL Metric stored --- .../storage/rest-api.php | 20 ++- .../tests/storage/test-rest-api.php | 133 ++++++++++++++++-- 2 files changed, 134 insertions(+), 19 deletions(-) diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index e0e4784b99..b902141c31 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -252,16 +252,28 @@ function od_clean_queried_object_cache_for_stored_url_metric( OD_URL_Metric_Stor return; } - // TODO: Should this instead call do_action() directly since we don't actually need to clear the object cache but just want to trigger page caches to flush the page caches? + // Fire actions that page caching plugins listen to flush caches. switch ( $queried_object['type'] ) { case 'post': - clean_post_cache( $queried_object['id'] ); + $post = get_post( $queried_object['id'] ); + if ( $post instanceof WP_Post ) { + /** This action is documented in wp-includes/post.php. */ + do_action( 'clean_post_cache', $post->ID, $post ); + } break; case 'term': - clean_term_cache( $queried_object['id'] ); + $term = get_term( $queried_object['id'] ); + if ( $term instanceof WP_Term ) { + /** This action is documented in wp-includes/taxonomy.php. */ + do_action( 'clean_term_cache', array( $term->term_id ), $term->taxonomy, false ); + } break; case 'user': - clean_user_cache( $queried_object['id'] ); + $user = get_user_by( 'ID', $queried_object['id'] ); + if ( $user instanceof WP_User ) { + /** This action is documented in wp-includes/user.php. */ + do_action( 'clean_user_cache', $user->ID, $user ); + } break; } } diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index 512c206bc9..526268b429 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -29,24 +29,12 @@ public function test_od_register_endpoint_hooked(): void { */ public function data_provider_to_test_rest_request_good_params(): array { return array( - 'not_extended' => array( + 'not_extended' => array( 'set_up' => function () { return $this->get_valid_params(); }, ), - 'with_queried_object' => array( - 'set_up' => function () { - $post_id = self::factory()->post->create(); - $valid_params = $this->get_valid_params(); - $valid_params['hmac'] = od_get_url_metrics_storage_hmac( $valid_params['slug'], $valid_params['url'], 'post', $post_id ); - $valid_params['queriedObject'] = array( - 'type' => 'post', - 'id' => $post_id, - ); - return $valid_params; - }, - ), - 'extended' => array( + 'extended' => array( 'set_up' => function () { add_filter( 'od_url_metric_schema_root_additional_properties', @@ -63,6 +51,45 @@ static function ( array $properties ): array { return $params; }, ), + 'with_post_queried_object' => array( + 'set_up' => function () { + $post_id = self::factory()->post->create(); + $valid_params = $this->get_valid_params(); + $valid_params['url'] = get_permalink( $post_id ); + $valid_params['hmac'] = od_get_url_metrics_storage_hmac( $valid_params['slug'], $valid_params['url'], 'post', $post_id ); + $valid_params['queriedObject'] = array( + 'type' => 'post', + 'id' => $post_id, + ); + return $valid_params; + }, + ), + 'with_term_queried_object' => array( + 'set_up' => function () { + $term_id = self::factory()->term->create(); + $valid_params = $this->get_valid_params(); + $valid_params['url'] = get_term_link( $term_id ); + $valid_params['hmac'] = od_get_url_metrics_storage_hmac( $valid_params['slug'], $valid_params['url'], 'term', $term_id ); + $valid_params['queriedObject'] = array( + 'type' => 'term', + 'id' => $term_id, + ); + return $valid_params; + }, + ), + 'with_user_queried_object' => array( + 'set_up' => function () { + $user_id = self::factory()->user->create(); + $valid_params = $this->get_valid_params(); + $valid_params['url'] = get_author_posts_url( $user_id ); + $valid_params['hmac'] = od_get_url_metrics_storage_hmac( $valid_params['slug'], $valid_params['url'], 'user', $user_id ); + $valid_params['queriedObject'] = array( + 'type' => 'user', + 'id' => $user_id, + ); + return $valid_params; + }, + ), ); } @@ -73,16 +100,49 @@ static function ( array $properties ): array { * * @covers ::od_register_endpoint * @covers ::od_handle_rest_request + * @covers ::od_clean_queried_object_cache_for_stored_url_metric */ public function test_rest_request_good_params( Closure $set_up ): void { + $clean_post_cache_callback_args = array(); + add_action( + 'clean_post_cache', + static function () use ( &$clean_post_cache_callback_args ): void { + $clean_post_cache_callback_args[] = func_get_args(); + }, + 10, + PHP_INT_MAX + ); + + $clean_term_cache_callback_args = array(); + add_action( + 'clean_term_cache', + static function () use ( &$clean_term_cache_callback_args ): void { + $clean_term_cache_callback_args[] = func_get_args(); + }, + 10, + PHP_INT_MAX + ); + + $clean_user_cache_callback_args = array(); + add_action( + 'clean_user_cache', + static function () use ( &$clean_user_cache_callback_args ): void { + $clean_user_cache_callback_args[] = func_get_args(); + }, + 10, + PHP_INT_MAX + ); + + $stored_context = null; add_action( 'od_url_metric_stored', - function ( OD_URL_Metric_Store_Request_Context $context ): void { + function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context ): void { $this->assertInstanceOf( OD_URL_Metric_Group_Collection::class, $context->url_metric_group_collection ); $this->assertInstanceOf( OD_URL_Metric_Group::class, $context->url_metric_group ); $this->assertInstanceOf( OD_URL_Metric::class, $context->url_metric ); $this->assertInstanceOf( WP_REST_Request::class, $context->request ); $this->assertIsInt( $context->post_id ); + $stored_context = $context; } ); @@ -111,6 +171,49 @@ function ( OD_URL_Metric_Store_Request_Context $context ): void { wp_array_slice_assoc( $url_metrics[0]->jsonSerialize(), array_keys( $expected_data ) ) ); $this->assertSame( 1, did_action( 'od_url_metric_stored' ) ); + + $this->assertInstanceOf( OD_URL_Metric_Store_Request_Context::class, $stored_context ); + + // Now check that od_clean_queried_object_cache_for_stored_url_metric() cleaned caches as expected. + $this->assertSame( $url_metrics[0]->jsonSerialize(), $stored_context->url_metric->jsonSerialize() ); + if ( null !== $stored_context->url_metric->get_queried_object() ) { + switch ( $stored_context->url_metric->get_queried_object()['type'] ) { + case 'post': + $found = false; + foreach ( $clean_post_cache_callback_args as $args ) { + if ( $args[0] === $stored_context->url_metric->get_queried_object()['id'] ) { + $this->assertInstanceOf( WP_Post::class, $args[1] ); + $this->assertSame( $stored_context->url_metric->get_queried_object()['id'], $args[1]->ID ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected clean_post_cache to have been fired for the post queried object.' ); + break; + case 'term': + $found = false; + foreach ( $clean_term_cache_callback_args as $args ) { + if ( array( $stored_context->url_metric->get_queried_object()['id'] ) === $args[0] ) { + $term = get_term( $stored_context->url_metric->get_queried_object()['id'] ); + $this->assertInstanceOf( WP_Term::class, $term ); + $this->assertSame( $term->taxonomy, $args[1] ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected clean_term_cache to have been fired for the term queried object.' ); + break; + case 'user': + $found = false; + foreach ( $clean_user_cache_callback_args as $args ) { + if ( $args[0] === $stored_context->url_metric->get_queried_object()['id'] ) { + $this->assertInstanceOf( WP_User::class, $args[1] ); + $this->assertSame( $stored_context->url_metric->get_queried_object()['id'], $args[1]->ID ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected clean_user_cache to have been fired for the user queried object.' ); + break; + } + } } /** From c0b8758b77af9da8e1100d71ae915e3e0376749a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 Nov 2024 19:54:00 -0800 Subject: [PATCH 04/11] Trigger save_post action for queried object when URL Metric stored --- .../storage/rest-api.php | 23 ++++++++- .../tests/storage/test-rest-api.php | 48 ++++++++----------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index b902141c31..ef8d64beb4 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -257,20 +257,41 @@ function od_clean_queried_object_cache_for_stored_url_metric( OD_URL_Metric_Stor case 'post': $post = get_post( $queried_object['id'] ); if ( $post instanceof WP_Post ) { + /* + * The clean_post_cache action is used to flush page caches by: + * - Pantheon Advanced Cache + * - WP Super Cache + */ /** This action is documented in wp-includes/post.php. */ do_action( 'clean_post_cache', $post->ID, $post ); + + /* + * The clean_post_cache action is used to flush page caches by: + * - W3 Total Cache + * - WP Rocket + */ + /** This action is documented in wp-includes/post.php. */ + do_action( 'save_post', $post->ID, $post, /* $update */ true ); } break; case 'term': $term = get_term( $queried_object['id'] ); if ( $term instanceof WP_Term ) { + /* + * The clean_term_cache action is used to flush page caches by: + * - Pantheon Advanced Cache + */ /** This action is documented in wp-includes/taxonomy.php. */ - do_action( 'clean_term_cache', array( $term->term_id ), $term->taxonomy, false ); + do_action( 'clean_term_cache', array( $term->term_id ), $term->taxonomy, /* $clean_taxonomy */ false ); } break; case 'user': $user = get_user_by( 'ID', $queried_object['id'] ); if ( $user instanceof WP_User ) { + /* + * The clean_post_cache action is used to flush page caches by: + * - Pantheon Advanced Cache + */ /** This action is documented in wp-includes/user.php. */ do_action( 'clean_user_cache', $user->ID, $user ); } diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index 526268b429..5188f8ee28 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -103,31 +103,11 @@ static function ( array $properties ): array { * @covers ::od_clean_queried_object_cache_for_stored_url_metric */ public function test_rest_request_good_params( Closure $set_up ): void { - $clean_post_cache_callback_args = array(); + $all_hook_callback_args = array(); add_action( - 'clean_post_cache', - static function () use ( &$clean_post_cache_callback_args ): void { - $clean_post_cache_callback_args[] = func_get_args(); - }, - 10, - PHP_INT_MAX - ); - - $clean_term_cache_callback_args = array(); - add_action( - 'clean_term_cache', - static function () use ( &$clean_term_cache_callback_args ): void { - $clean_term_cache_callback_args[] = func_get_args(); - }, - 10, - PHP_INT_MAX - ); - - $clean_user_cache_callback_args = array(); - add_action( - 'clean_user_cache', - static function () use ( &$clean_user_cache_callback_args ): void { - $clean_user_cache_callback_args[] = func_get_args(); + 'all', + static function ( string $hook, ...$args ) use ( &$all_hook_callback_args ): void { + $all_hook_callback_args[ $hook ][] = $args; }, 10, PHP_INT_MAX @@ -179,8 +159,9 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context if ( null !== $stored_context->url_metric->get_queried_object() ) { switch ( $stored_context->url_metric->get_queried_object()['type'] ) { case 'post': + $this->assertArrayHasKey( 'clean_post_cache', $all_hook_callback_args ); $found = false; - foreach ( $clean_post_cache_callback_args as $args ) { + foreach ( $all_hook_callback_args['clean_post_cache'] as $args ) { if ( $args[0] === $stored_context->url_metric->get_queried_object()['id'] ) { $this->assertInstanceOf( WP_Post::class, $args[1] ); $this->assertSame( $stored_context->url_metric->get_queried_object()['id'], $args[1]->ID ); @@ -188,10 +169,22 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context } } $this->assertTrue( $found, 'Expected clean_post_cache to have been fired for the post queried object.' ); + + $this->assertArrayHasKey( 'save_post', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['save_post'] as $args ) { + if ( $args[0] === $stored_context->url_metric->get_queried_object()['id'] ) { + $this->assertInstanceOf( WP_Post::class, $args[1] ); + $this->assertSame( $stored_context->url_metric->get_queried_object()['id'], $args[1]->ID ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected save_post to have been fired for the post queried object.' ); break; case 'term': + $this->assertArrayHasKey( 'clean_term_cache', $all_hook_callback_args ); $found = false; - foreach ( $clean_term_cache_callback_args as $args ) { + foreach ( $all_hook_callback_args['clean_term_cache'] as $args ) { if ( array( $stored_context->url_metric->get_queried_object()['id'] ) === $args[0] ) { $term = get_term( $stored_context->url_metric->get_queried_object()['id'] ); $this->assertInstanceOf( WP_Term::class, $term ); @@ -202,8 +195,9 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context $this->assertTrue( $found, 'Expected clean_term_cache to have been fired for the term queried object.' ); break; case 'user': + $this->assertArrayHasKey( 'clean_user_cache', $all_hook_callback_args ); $found = false; - foreach ( $clean_user_cache_callback_args as $args ) { + foreach ( $all_hook_callback_args['clean_user_cache'] as $args ) { if ( $args[0] === $stored_context->url_metric->get_queried_object()['id'] ) { $this->assertInstanceOf( WP_User::class, $args[1] ); $this->assertSame( $stored_context->url_metric->get_queried_object()['id'], $args[1]->ID ); From faf6882a2d33eb757de618083169d4abe307ce99 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 10 Nov 2024 20:58:14 -0800 Subject: [PATCH 05/11] Fire transition_post_status action when URL Metric updated for queried post object --- .../storage/rest-api.php | 9 +++++ .../tests/storage/test-rest-api.php | 33 +++++++++++++------ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index ef8d64beb4..ada4f715d7 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -261,10 +261,19 @@ function od_clean_queried_object_cache_for_stored_url_metric( OD_URL_Metric_Stor * The clean_post_cache action is used to flush page caches by: * - Pantheon Advanced Cache * - WP Super Cache + * - Batcache */ /** This action is documented in wp-includes/post.php. */ do_action( 'clean_post_cache', $post->ID, $post ); + /* + * The transition_post_status action is used to flush page caches by: + * - Jetpack Boost + * - WP Super Cache + */ + /** This action is documented in wp-includes/post.php. */ + do_action( 'transition_post_status', $post->post_status, $post->post_status, $post ); + /* * The clean_post_cache action is used to flush page caches by: * - W3 Total Cache diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index 5188f8ee28..8385cc3668 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -103,16 +103,6 @@ static function ( array $properties ): array { * @covers ::od_clean_queried_object_cache_for_stored_url_metric */ public function test_rest_request_good_params( Closure $set_up ): void { - $all_hook_callback_args = array(); - add_action( - 'all', - static function ( string $hook, ...$args ) use ( &$all_hook_callback_args ): void { - $all_hook_callback_args[ $hook ][] = $args; - }, - 10, - PHP_INT_MAX - ); - $stored_context = null; add_action( 'od_url_metric_stored', @@ -127,6 +117,17 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context ); $valid_params = $set_up(); + + $all_hook_callback_args = array(); + add_action( + 'all', + static function ( string $hook, ...$args ) use ( &$all_hook_callback_args ): void { + $all_hook_callback_args[ $hook ][] = $args; + }, + 10, + PHP_INT_MAX + ); + $this->assertCount( 0, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); $request = $this->create_request( $valid_params ); $response = rest_get_server()->dispatch( $request ); @@ -170,6 +171,18 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context } $this->assertTrue( $found, 'Expected clean_post_cache to have been fired for the post queried object.' ); + $this->assertArrayHasKey( 'transition_post_status', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['transition_post_status'] as $args ) { + $this->assertInstanceOf( WP_Post::class, $args[2] ); + if ( $args[2]->ID === $stored_context->url_metric->get_queried_object()['id'] ) { + $this->assertSame( $args[2]->post_status, $args[0] ); + $this->assertSame( $args[2]->post_status, $args[1] ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected transition_post_status to have been fired for the post queried object.' ); + $this->assertArrayHasKey( 'save_post', $all_hook_callback_args ); $found = false; foreach ( $all_hook_callback_args['save_post'] as $args ) { From d8483bb666247447199d5efae381e6687e15ce07 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 12 Nov 2024 15:45:01 -0800 Subject: [PATCH 06/11] Add reference to LightSpeed Cache --- plugins/optimization-detective/storage/rest-api.php | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index ada4f715d7..f03ffd5d91 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -270,6 +270,7 @@ function od_clean_queried_object_cache_for_stored_url_metric( OD_URL_Metric_Stor * The transition_post_status action is used to flush page caches by: * - Jetpack Boost * - WP Super Cache + * - LightSpeed Cache */ /** This action is documented in wp-includes/post.php. */ do_action( 'transition_post_status', $post->post_status, $post->post_status, $post ); From 39df98bcb120966a58b927f99891d0bb2456c3c7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 14 Nov 2024 09:47:59 -0800 Subject: [PATCH 07/11] Replace queriedObject with cachePurgePostId --- .../class-od-url-metric.php | 103 ++++-------- plugins/optimization-detective/detect.js | 15 +- plugins/optimization-detective/detection.php | 69 +++++--- .../optimization-detective/optimization.php | 5 +- .../optimization-detective/storage/data.php | 24 ++- .../storage/rest-api.php | 102 +++++------- .../tests/storage/test-data.php | 40 ++--- .../tests/storage/test-rest-api.php | 153 ++++++------------ .../tests/test-class-od-url-metric.php | 29 +--- plugins/optimization-detective/types.ts | 6 - 10 files changed, 221 insertions(+), 325 deletions(-) diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index 9cdf774104..d2f772d213 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -14,40 +14,35 @@ /** * Representation of the measurements taken from a single client's visit to a specific URL. * - * @phpstan-type ViewportRect array{ - * width: int, - * height: int - * } - * @phpstan-type QueriedObject array{ - * type: 'post'|'user'|'term', - * id: int - * } - * @phpstan-type DOMRect array{ - * width: float, - * height: float, - * x: float, - * y: float, - * top: float, - * right: float, - * bottom: float, - * left: float - * } - * @phpstan-type ElementData array{ - * isLCP: bool, - * isLCPCandidate: bool, - * xpath: non-empty-string, - * intersectionRatio: float, - * intersectionRect: DOMRect, - * boundingClientRect: DOMRect, - * } - * @phpstan-type Data array{ - * uuid: non-empty-string, - * url: non-empty-string, - * queriedObject: null|QueriedObject, - * timestamp: float, - * viewport: ViewportRect, - * elements: ElementData[] - * } + * @phpstan-type ViewportRect array{ + * width: int, + * height: int + * } + * @phpstan-type DOMRect array{ + * width: float, + * height: float, + * x: float, + * y: float, + * top: float, + * right: float, + * bottom: float, + * left: float + * } + * @phpstan-type ElementData array{ + * isLCP: bool, + * isLCPCandidate: bool, + * xpath: non-empty-string, + * intersectionRatio: float, + * intersectionRect: DOMRect, + * boundingClientRect: DOMRect, + * } + * @phpstan-type Data array{ + * uuid: non-empty-string, + * url: non-empty-string, + * timestamp: float, + * viewport: ViewportRect, + * elements: ElementData[] + * } * * @since 0.1.0 * @access private @@ -206,40 +201,21 @@ public static function get_json_schema(): array { 'type' => 'object', 'required' => true, 'properties' => array( - 'uuid' => array( + 'uuid' => array( 'description' => __( 'The UUID for the URL Metric.', 'optimization-detective' ), 'type' => 'string', 'format' => 'uuid', 'required' => true, 'readonly' => true, // Omit from REST API. ), - 'url' => array( + 'url' => array( 'description' => __( 'The URL for which the metric was obtained.', 'optimization-detective' ), 'type' => 'string', 'required' => true, 'format' => 'uri', 'pattern' => '^https?://', ), - 'queriedObject' => array( - 'type' => 'object', - 'required' => false, // Not required since a query like is_home() will not have any queried object. - 'properties' => array( - 'type' => array( - 'type' => array( 'string' ), - 'description' => __( 'Queried object type.', 'optimization-detective' ), - 'required' => true, - 'enum' => array( 'post', 'term', 'user' ), // TODO: Should post_type be supported? There is no ID in this case, but the post type slug is used. But we don't need it to flush the cache. - ), - 'id' => array( - 'type' => 'integer', - 'description' => __( 'Queried object ID.', 'optimization-detective' ), - 'required' => true, - 'minimum' => 1, - ), - ), - 'additionalProperties' => false, - ), - 'viewport' => array( + 'viewport' => array( 'description' => __( 'Viewport dimensions', 'optimization-detective' ), 'type' => 'object', 'required' => true, @@ -257,14 +233,14 @@ public static function get_json_schema(): array { ), 'additionalProperties' => false, ), - 'timestamp' => array( + 'timestamp' => array( 'description' => __( 'Timestamp at which the URL Metric was captured.', 'optimization-detective' ), 'type' => 'number', 'required' => true, 'readonly' => true, // Omit from REST API. 'minimum' => 0, ), - 'elements' => array( + 'elements' => array( 'description' => __( 'Element metrics', 'optimization-detective' ), 'type' => 'array', 'required' => true, @@ -452,17 +428,6 @@ public function get_url(): string { return $this->data['url']; } - /** - * Gets queried object. - * - * @since n.e.x.t - * - * @return QueriedObject|null Queried object. - */ - public function get_queried_object(): ?array { - return $this->data['queriedObject'] ?? null; - } - /** * Gets viewport data. * diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 38873a6724..2cdb488ec4 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -2,7 +2,6 @@ * @typedef {import("web-vitals").LCPMetric} LCPMetric * @typedef {import("./types.ts").ElementData} ElementData * @typedef {import("./types.ts").URLMetric} URLMetric - * @typedef {import("./types.ts").QueriedObject} QueriedObject * @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus * @typedef {import("./types.ts").Extension} Extension * @typedef {import("./types.ts").ExtendedRootData} ExtendedRootData @@ -240,7 +239,7 @@ function extendElementData( xpath, properties ) { * @param {string} args.restApiEndpoint URL for where to send the detection data. * @param {string} args.currentUrl Current URL. * @param {string} args.urlMetricSlug Slug for URL Metric. - * @param {QueriedObject} [args.queriedObject] Queried object. + * @param {number|null} args.cachePurgePostId Cache purge post ID. * @param {string} args.urlMetricHMAC HMAC for URL Metric storage. * @param {URLMetricGroupStatus[]} args.urlMetricGroupStatuses URL Metric group statuses. * @param {number} args.storageLockTTL The TTL (in seconds) for the URL Metric storage lock. @@ -255,7 +254,7 @@ export default async function detect( { restApiEndpoint, currentUrl, urlMetricSlug, - queriedObject, + cachePurgePostId, urlMetricHMAC, urlMetricGroupStatuses, storageLockTTL, @@ -449,10 +448,6 @@ export default async function detect( { elements: [], }; - if ( queriedObject ) { - urlMetric.queriedObject = queriedObject; - } - const lcpMetric = lcpMetricCandidates.at( -1 ); for ( const elementIntersection of elementIntersections ) { @@ -539,6 +534,12 @@ export default async function detect( { const url = new URL( restApiEndpoint ); url.searchParams.set( 'slug', urlMetricSlug ); + if ( typeof cachePurgePostId === 'number' ) { + url.searchParams.set( + 'cache_purge_post_id', + cachePurgePostId.toString() + ); + } url.searchParams.set( 'hmac', urlMetricHMAC ); navigator.sendBeacon( url, diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index f13dc71add..b52f407deb 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -10,6 +10,54 @@ exit; // Exit if accessed directly. } +/** + * Obtains the ID for a post related to this response so that page caches can be told to invalidate their cache. + * + * If the queried object for the response is a post, then that post's ID is used. Otherwise, it uses the ID of the first + * post in The Loop. + * + * When the queried object is a post (e.g. is_singular, is_posts_page, is_front_page w/ show_on_front=page), then this + * is the perfect match. A page caching plugin will be able to most reliably invalidate the cache for a URL via + * this ID if the relevant actions are triggered for the post (e.g. clean_post_cache, save_post, transition_post_status). + * + * Otherwise, if the response is an archive page or the front page where show_on_front=posts (i.e. is_home), then + * there is no singular post object that represents the URL. In this case, we obtain the first post in the main + * loop. By triggering the relevant actions for this post ID, page caches will have their best shot at invalidating + * the related URLs. Page caching plugins which leverage surrogate keys will be the most reliable here. Otherwise, + * caching plugins may just resort to automatically purging the cache for the homepage whenever any post is edited, + * which is better than nothing. + * + * There should not be any situation by default in which a page optimized with Optimization Detective does not have such + * a post available for cache purging. As seen in {@see od_can_optimize_response()}, when such a post ID is not + * available for cache purging then it returns false, as it also does in another case like if is_404(). + * + * @since n.e.x.t + * @access private + * + * @return int|null Post ID or null if none found. + */ +function od_get_cache_purge_post_id(): ?int { + $queried_object = get_queried_object(); + if ( $queried_object instanceof WP_Post ) { + return $queried_object->ID; + } + + global $wp_query; + if ( + $wp_query instanceof WP_Query + && + $wp_query->post_count > 0 + && + isset( $wp_query->posts[0] ) + && + $wp_query->posts[0] instanceof WP_Post + ) { + return $wp_query->posts[0]->ID; + } + + return null; +} + /** * Prints the script for detecting loaded images and the LCP element. * @@ -32,19 +80,7 @@ function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $ */ $extension_module_urls = (array) apply_filters( 'od_extension_module_urls', array() ); - // Obtain the queried object so when a URL Metric is stored the endpoint will know which object's cache to clean. - // Note that WP_Post_Type is intentionally excluded here since there is no equivalent to clean_post_cache(), clean_term_cache(), and clean_user_cache(). - $queried_object = get_queried_object(); - if ( $queried_object instanceof WP_Post ) { - $queried_object_type = 'post'; - } elseif ( $queried_object instanceof WP_Term ) { - $queried_object_type = 'term'; - } elseif ( $queried_object instanceof WP_User ) { - $queried_object_type = 'user'; - } else { - $queried_object_type = null; - } - $queried_object_id = null === $queried_object_type ? null : (int) get_queried_object_id(); + $cache_purge_post_id = od_get_cache_purge_post_id(); $current_url = od_get_current_url(); $detect_args = array( @@ -55,11 +91,8 @@ function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $ 'restApiEndpoint' => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ), 'currentUrl' => $current_url, 'urlMetricSlug' => $slug, - 'queriedObject' => null === $queried_object_type ? null : array( - 'type' => $queried_object_type, - 'id' => $queried_object_id, - ), - 'urlMetricHMAC' => od_get_url_metrics_storage_hmac( $slug, $current_url, $queried_object_type, $queried_object_id ), + 'cachePurgePostId' => od_get_cache_purge_post_id(), + 'urlMetricHMAC' => od_get_url_metrics_storage_hmac( $slug, $current_url, $cache_purge_post_id ), 'urlMetricGroupStatuses' => array_map( static function ( OD_URL_Metric_Group $group ): array { return array( diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index ffb97c8a41..0bf7878eb0 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -120,7 +120,10 @@ function od_can_optimize_response(): bool { // users, additional elements will be present like the script from wp_customize_support_script() which will // interfere with the XPath indices. Note that od_get_normalized_query_vars() is varied by is_user_logged_in() // so membership sites and e-commerce sites will still be able to be optimized for their normal visitors. - current_user_can( 'customize' ) + current_user_can( 'customize' ) || + // Page caching plugins can only reliably be told to invalidate a cached page when a post is available to trigger + // the relevant actions on. + null !== od_get_cache_purge_post_id() ); /** diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index 59358b2476..ea2b3caa45 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -152,14 +152,13 @@ function od_get_url_metrics_slug( array $query_vars ): string { * @see od_get_url_metrics_slug() * @todo This should also include an ETag as a parameter. See . * - * @param string $slug Slug (hash of normalized query vars). - * @param string $url URL. - * @param string|null $queried_object_type Queried object type. - * @param int|null $queried_object_id Queried object ID. + * @param string $slug Slug (hash of normalized query vars). + * @param string $url URL. + * @param int|null $cache_purge_post_id Cache purge post ID. * @return string HMAC. */ -function od_get_url_metrics_storage_hmac( string $slug, string $url, ?string $queried_object_type = null, ?int $queried_object_id = null ): string { - $action = "store_url_metric:$slug:$url:$queried_object_type:$queried_object_id"; +function od_get_url_metrics_storage_hmac( string $slug, string $url, ?int $cache_purge_post_id = null ): string { + $action = "store_url_metric:$slug:$url:$cache_purge_post_id"; return wp_hash( $action, 'nonce' ); } @@ -172,15 +171,14 @@ function od_get_url_metrics_storage_hmac( string $slug, string $url, ?string $qu * @see od_get_url_metrics_storage_hmac() * @see od_get_url_metrics_slug() * - * @param string $hmac HMAC. - * @param string $slug Slug (hash of normalized query vars). - * @param String $url URL. - * @param string|null $queried_object_type Queried object type. - * @param int|null $queried_object_id Queried object ID. + * @param string $hmac HMAC. + * @param string $slug Slug (hash of normalized query vars). + * @param String $url URL. + * @param int|null $cache_purge_post_id Cache purge post ID. * @return bool Whether the HMAC is valid. */ -function od_verify_url_metrics_storage_hmac( string $hmac, string $slug, string $url, ?string $queried_object_type = null, ?int $queried_object_id = null ): bool { - return hash_equals( od_get_url_metrics_storage_hmac( $slug, $url, $queried_object_type, $queried_object_id ), $hmac ); +function od_verify_url_metrics_storage_hmac( string $hmac, string $slug, string $url, ?int $cache_purge_post_id = null ): bool { + return hash_equals( od_get_url_metrics_storage_hmac( $slug, $url, $cache_purge_post_id ), $hmac ); } /** diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index f03ffd5d91..b510837177 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -37,22 +37,28 @@ */ function od_register_endpoint(): void { + // The slug and cache_purge_post_id args are further validated via the validate_callback for the 'hmac' parameter, + // they are provided as input with the 'url' argument to create the HMAC by the server. $args = array( - 'slug' => array( + 'slug' => array( 'type' => 'string', 'description' => __( 'An MD5 hash of the query args.', 'optimization-detective' ), 'required' => true, 'pattern' => '^[0-9a-f]{32}$', - // This is further validated via the validate_callback for the 'hmac' parameter, as it is provided as input - // with the 'url' argument to create the HMAC by the server. which then is verified to match in the REST API request. ), - 'hmac' => array( + 'cache_purge_post_id' => array( + 'type' => 'integer', + 'description' => __( 'Cache purge post ID.', 'optimization-detective' ), + 'required' => false, + 'minimum' => 1, + ), + 'hmac' => array( 'type' => 'string', 'description' => __( 'HMAC originally computed by server required to authorize the request.', 'optimization-detective' ), 'required' => true, 'pattern' => '^[0-9a-f]+$', 'validate_callback' => static function ( string $hmac, WP_REST_Request $request ) { - if ( ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['url'], $request['queriedObject']['type'] ?? null, $request['queriedObject']['id'] ?? null ) ) { + if ( ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['url'], $request['cache_purge_post_id'] ?? null ) ) { return new WP_Error( 'invalid_hmac', __( 'URL Metrics HMAC verification failure.', 'optimization-detective' ) ); } return true; @@ -247,64 +253,42 @@ function od_handle_rest_request( WP_REST_Request $request ) { * @param OD_URL_Metric_Store_Request_Context $context Context. */ function od_clean_queried_object_cache_for_stored_url_metric( OD_URL_Metric_Store_Request_Context $context ): void { - $queried_object = $context->url_metric->get_queried_object(); - if ( ! is_array( $queried_object ) ) { + + $cache_purge_post_id = $context->request->get_param( 'cache_purge_post_id' ); + if ( ! is_int( $cache_purge_post_id ) ) { + return; + } + + $post = get_post( $cache_purge_post_id ); + if ( ! ( $post instanceof WP_Post ) ) { return; } // Fire actions that page caching plugins listen to flush caches. - switch ( $queried_object['type'] ) { - case 'post': - $post = get_post( $queried_object['id'] ); - if ( $post instanceof WP_Post ) { - /* - * The clean_post_cache action is used to flush page caches by: - * - Pantheon Advanced Cache - * - WP Super Cache - * - Batcache - */ - /** This action is documented in wp-includes/post.php. */ - do_action( 'clean_post_cache', $post->ID, $post ); - /* - * The transition_post_status action is used to flush page caches by: - * - Jetpack Boost - * - WP Super Cache - * - LightSpeed Cache - */ - /** This action is documented in wp-includes/post.php. */ - do_action( 'transition_post_status', $post->post_status, $post->post_status, $post ); + /* + * The clean_post_cache action is used to flush page caches by: + * - Pantheon Advanced Cache + * - WP Super Cache + * - Batcache + */ + /** This action is documented in wp-includes/post.php. */ + do_action( 'clean_post_cache', $post->ID, $post ); - /* - * The clean_post_cache action is used to flush page caches by: - * - W3 Total Cache - * - WP Rocket - */ - /** This action is documented in wp-includes/post.php. */ - do_action( 'save_post', $post->ID, $post, /* $update */ true ); - } - break; - case 'term': - $term = get_term( $queried_object['id'] ); - if ( $term instanceof WP_Term ) { - /* - * The clean_term_cache action is used to flush page caches by: - * - Pantheon Advanced Cache - */ - /** This action is documented in wp-includes/taxonomy.php. */ - do_action( 'clean_term_cache', array( $term->term_id ), $term->taxonomy, /* $clean_taxonomy */ false ); - } - break; - case 'user': - $user = get_user_by( 'ID', $queried_object['id'] ); - if ( $user instanceof WP_User ) { - /* - * The clean_post_cache action is used to flush page caches by: - * - Pantheon Advanced Cache - */ - /** This action is documented in wp-includes/user.php. */ - do_action( 'clean_user_cache', $user->ID, $user ); - } - break; - } + /* + * The transition_post_status action is used to flush page caches by: + * - Jetpack Boost + * - WP Super Cache + * - LightSpeed Cache + */ + /** This action is documented in wp-includes/post.php. */ + do_action( 'transition_post_status', $post->post_status, $post->post_status, $post ); + + /* + * The clean_post_cache action is used to flush page caches by: + * - W3 Total Cache + * - WP Rocket + */ + /** This action is documented in wp-includes/post.php. */ + do_action( 'save_post', $post->ID, $post, /* $update */ true ); } diff --git a/plugins/optimization-detective/tests/storage/test-data.php b/plugins/optimization-detective/tests/storage/test-data.php index 140bab51b2..2e6e3c17c2 100644 --- a/plugins/optimization-detective/tests/storage/test-data.php +++ b/plugins/optimization-detective/tests/storage/test-data.php @@ -293,26 +293,25 @@ public function test_od_get_url_metrics_slug(): void { */ public function data_provider_to_test_hmac(): array { return array( - 'home' => array( - 'url' => static function () { - return home_url(); + 'is_home' => array( + 'set_up' => static function (): array { + $post_id = self::factory()->post->create(); + return array( + home_url(), + od_get_url_metrics_slug( array() ), + $post_id, + ); }, - 'slug' => od_get_url_metrics_slug( array() ), ), - 'post' => array( - 'url' => static function () { - wp_update_post( - array( - 'ID' => 1, - 'post_type' => 'post', - 'post_title' => 'Hello!', - ) + 'is_single' => array( + 'set_up' => static function (): array { + $post_id = self::factory()->post->create(); + return array( + get_permalink( $post_id ), + od_get_url_metrics_slug( array( 'p' => $post_id ) ), + $post_id, ); - return get_permalink( 1 ); }, - 'slug' => od_get_url_metrics_slug( array( 'p' => 1 ) ), - 'queried_object_type' => 'post', - 'queried_object_id' => 1, ), ); } @@ -325,11 +324,12 @@ public function data_provider_to_test_hmac(): array { * @covers ::od_get_url_metrics_storage_hmac * @covers ::od_verify_url_metrics_storage_hmac */ - public function test_od_get_url_metrics_storage_hmac_and_od_verify_url_metrics_storage_hmac( Closure $get_url, string $slug, ?string $queried_object_type = null, ?int $queried_object_id = null ): void { - $url = $get_url(); - $hmac = od_get_url_metrics_storage_hmac( $slug, $url, $queried_object_type, $queried_object_id ); + public function test_od_get_url_metrics_storage_hmac_and_od_verify_url_metrics_storage_hmac( Closure $set_up ): void { + list( $url, $slug, $cache_purge_post_id ) = $set_up(); + $this->go_to( $url ); + $hmac = od_get_url_metrics_storage_hmac( $slug, $url, $cache_purge_post_id ); $this->assertMatchesRegularExpression( '/^[0-9a-f]+$/', $hmac ); - $this->assertTrue( od_verify_url_metrics_storage_hmac( $hmac, $slug, $url, $queried_object_type, $queried_object_id ) ); + $this->assertTrue( od_verify_url_metrics_storage_hmac( $hmac, $slug, $url, $cache_purge_post_id ) ); } /** diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index 8385cc3668..b5ac38dd5a 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -30,12 +30,12 @@ public function test_od_register_endpoint_hooked(): void { public function data_provider_to_test_rest_request_good_params(): array { return array( 'not_extended' => array( - 'set_up' => function () { + 'set_up' => function (): array { return $this->get_valid_params(); }, ), 'extended' => array( - 'set_up' => function () { + 'set_up' => function (): array { add_filter( 'od_url_metric_schema_root_additional_properties', static function ( array $properties ): array { @@ -51,43 +51,14 @@ static function ( array $properties ): array { return $params; }, ), - 'with_post_queried_object' => array( - 'set_up' => function () { - $post_id = self::factory()->post->create(); - $valid_params = $this->get_valid_params(); - $valid_params['url'] = get_permalink( $post_id ); - $valid_params['hmac'] = od_get_url_metrics_storage_hmac( $valid_params['slug'], $valid_params['url'], 'post', $post_id ); - $valid_params['queriedObject'] = array( - 'type' => 'post', - 'id' => $post_id, - ); - return $valid_params; - }, - ), - 'with_term_queried_object' => array( - 'set_up' => function () { - $term_id = self::factory()->term->create(); - $valid_params = $this->get_valid_params(); - $valid_params['url'] = get_term_link( $term_id ); - $valid_params['hmac'] = od_get_url_metrics_storage_hmac( $valid_params['slug'], $valid_params['url'], 'term', $term_id ); - $valid_params['queriedObject'] = array( - 'type' => 'term', - 'id' => $term_id, - ); - return $valid_params; - }, - ), - 'with_user_queried_object' => array( - 'set_up' => function () { - $user_id = self::factory()->user->create(); - $valid_params = $this->get_valid_params(); - $valid_params['url'] = get_author_posts_url( $user_id ); - $valid_params['hmac'] = od_get_url_metrics_storage_hmac( $valid_params['slug'], $valid_params['url'], 'user', $user_id ); - $valid_params['queriedObject'] = array( - 'type' => 'user', - 'id' => $user_id, - ); - return $valid_params; + 'with_cache_purge_post_id' => array( + 'set_up' => function (): array { + $params = $this->get_valid_params(); + $params['cache_purge_post_id'] = self::factory()->post->create(); + $params['url'] = get_permalink( $params['cache_purge_post_id'] ); + $params['slug'] = od_get_url_metrics_slug( array( 'p' => $params['cache_purge_post_id'] ) ); + $params['hmac'] = od_get_url_metrics_storage_hmac( $params['slug'], $params['url'], $params['cache_purge_post_id'] ); + return $params; }, ), ); @@ -146,7 +117,7 @@ static function ( string $hook, ...$args ) use ( &$all_hook_callback_args ): voi $this->assertSame( $valid_params['viewport']['width'], $url_metrics[0]->get_viewport_width() ); $expected_data = $valid_params; - unset( $expected_data['hmac'], $expected_data['slug'] ); + unset( $expected_data['hmac'], $expected_data['slug'], $expected_data['cache_purge_post_id'] ); $this->assertSame( $expected_data, wp_array_slice_assoc( $url_metrics[0]->jsonSerialize(), array_keys( $expected_data ) ) @@ -157,69 +128,41 @@ static function ( string $hook, ...$args ) use ( &$all_hook_callback_args ): voi // Now check that od_clean_queried_object_cache_for_stored_url_metric() cleaned caches as expected. $this->assertSame( $url_metrics[0]->jsonSerialize(), $stored_context->url_metric->jsonSerialize() ); - if ( null !== $stored_context->url_metric->get_queried_object() ) { - switch ( $stored_context->url_metric->get_queried_object()['type'] ) { - case 'post': - $this->assertArrayHasKey( 'clean_post_cache', $all_hook_callback_args ); - $found = false; - foreach ( $all_hook_callback_args['clean_post_cache'] as $args ) { - if ( $args[0] === $stored_context->url_metric->get_queried_object()['id'] ) { - $this->assertInstanceOf( WP_Post::class, $args[1] ); - $this->assertSame( $stored_context->url_metric->get_queried_object()['id'], $args[1]->ID ); - $found = true; - } - } - $this->assertTrue( $found, 'Expected clean_post_cache to have been fired for the post queried object.' ); - - $this->assertArrayHasKey( 'transition_post_status', $all_hook_callback_args ); - $found = false; - foreach ( $all_hook_callback_args['transition_post_status'] as $args ) { - $this->assertInstanceOf( WP_Post::class, $args[2] ); - if ( $args[2]->ID === $stored_context->url_metric->get_queried_object()['id'] ) { - $this->assertSame( $args[2]->post_status, $args[0] ); - $this->assertSame( $args[2]->post_status, $args[1] ); - $found = true; - } - } - $this->assertTrue( $found, 'Expected transition_post_status to have been fired for the post queried object.' ); - - $this->assertArrayHasKey( 'save_post', $all_hook_callback_args ); - $found = false; - foreach ( $all_hook_callback_args['save_post'] as $args ) { - if ( $args[0] === $stored_context->url_metric->get_queried_object()['id'] ) { - $this->assertInstanceOf( WP_Post::class, $args[1] ); - $this->assertSame( $stored_context->url_metric->get_queried_object()['id'], $args[1]->ID ); - $found = true; - } - } - $this->assertTrue( $found, 'Expected save_post to have been fired for the post queried object.' ); - break; - case 'term': - $this->assertArrayHasKey( 'clean_term_cache', $all_hook_callback_args ); - $found = false; - foreach ( $all_hook_callback_args['clean_term_cache'] as $args ) { - if ( array( $stored_context->url_metric->get_queried_object()['id'] ) === $args[0] ) { - $term = get_term( $stored_context->url_metric->get_queried_object()['id'] ); - $this->assertInstanceOf( WP_Term::class, $term ); - $this->assertSame( $term->taxonomy, $args[1] ); - $found = true; - } - } - $this->assertTrue( $found, 'Expected clean_term_cache to have been fired for the term queried object.' ); - break; - case 'user': - $this->assertArrayHasKey( 'clean_user_cache', $all_hook_callback_args ); - $found = false; - foreach ( $all_hook_callback_args['clean_user_cache'] as $args ) { - if ( $args[0] === $stored_context->url_metric->get_queried_object()['id'] ) { - $this->assertInstanceOf( WP_User::class, $args[1] ); - $this->assertSame( $stored_context->url_metric->get_queried_object()['id'], $args[1]->ID ); - $found = true; - } - } - $this->assertTrue( $found, 'Expected clean_user_cache to have been fired for the user queried object.' ); - break; + $cache_purge_post_id = $stored_context->request->get_param( 'cache_purge_post_id' ); + if ( null !== $cache_purge_post_id ) { + $this->assertArrayHasKey( 'clean_post_cache', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['clean_post_cache'] as $args ) { + if ( $args[0] === $cache_purge_post_id ) { + $this->assertInstanceOf( WP_Post::class, $args[1] ); + $this->assertSame( $cache_purge_post_id, $args[1]->ID ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected clean_post_cache to have been fired for the post queried object.' ); + + $this->assertArrayHasKey( 'transition_post_status', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['transition_post_status'] as $args ) { + $this->assertInstanceOf( WP_Post::class, $args[2] ); + if ( $args[2]->ID === $cache_purge_post_id ) { + $this->assertSame( $args[2]->post_status, $args[0] ); + $this->assertSame( $args[2]->post_status, $args[1] ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected transition_post_status to have been fired for the post queried object.' ); + + $this->assertArrayHasKey( 'save_post', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['save_post'] as $args ) { + if ( $args[0] === $cache_purge_post_id ) { + $this->assertInstanceOf( WP_Post::class, $args[1] ); + $this->assertSame( $cache_purge_post_id, $args[1]->ID ); + $found = true; + } } + $this->assertTrue( $found, 'Expected save_post to have been fired for the post queried object.' ); } } @@ -251,7 +194,7 @@ function ( $params ) { 'hmac' => od_get_url_metrics_storage_hmac( od_get_url_metrics_slug( array( 'different' => 'query vars' ) ), home_url( '/' ) ), ), 'invalid_hmac_with_queried_object' => array( - 'hmac' => od_get_url_metrics_storage_hmac( od_get_url_metrics_slug( array() ), home_url( '/' ), 'post', 1 ), + 'hmac' => od_get_url_metrics_storage_hmac( od_get_url_metrics_slug( array() ), home_url( '/' ), 1 ), ), 'invalid_viewport_type' => array( 'viewport' => '640x480', @@ -757,9 +700,9 @@ private function create_request( array $params ): WP_REST_Request { */ $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_header( 'Content-Type', 'application/json' ); - $request->set_query_params( wp_array_slice_assoc( $params, array( 'hmac', 'slug' ) ) ); + $request->set_query_params( wp_array_slice_assoc( $params, array( 'hmac', 'slug', 'cache_purge_post_id' ) ) ); $request->set_header( 'Origin', home_url() ); - unset( $params['hmac'], $params['slug'] ); + unset( $params['hmac'], $params['slug'], $params['cache_purge_post_id'] ); $request->set_body( wp_json_encode( $params ) ); return $request; } diff --git a/plugins/optimization-detective/tests/test-class-od-url-metric.php b/plugins/optimization-detective/tests/test-class-od-url-metric.php index 3c9274b6f2..71ba5799a6 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metric.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metric.php @@ -48,18 +48,6 @@ public function data_provider_to_test_constructor(): array { ), ), ), - 'valid_with_queried_object' => array( - 'data' => array( - 'url' => home_url( '/' ), - 'viewport' => $viewport, - 'timestamp' => microtime( true ), - 'elements' => array(), - 'queriedObject' => array( - 'type' => 'post', - 'id' => 1, - ), - ), - ), // This tests that sanitization converts values into their expected PHP types. 'valid_but_props_are_strings' => array( 'data' => array( @@ -122,19 +110,6 @@ static function ( $value ) { ), 'error' => 'OD_URL_Metric[viewport][height] is not of type integer.', ), - 'bad_queried_object' => array( - 'data' => array( - 'url' => home_url( '/' ), - 'viewport' => $viewport, - 'timestamp' => microtime( true ), - 'elements' => array(), - 'queriedObject' => array( - 'type' => 'story', - 'id' => 1, - ), - ), - 'error' => 'OD_URL_Metric[queriedObject][type] is not one of post, term, and user', - ), 'viewport_aspect_ratio_too_small' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), @@ -740,14 +715,14 @@ public function test_get_json_schema_extensibility( Closure $set_up, Closure $as */ protected function check_schema_subset( array $schema, string $path, bool $extended = false ): void { $this->assertArrayHasKey( 'required', $schema, $path ); - if ( ! $extended && ! str_starts_with( $path, 'root/queriedObject' ) ) { + if ( ! $extended ) { $this->assertTrue( $schema['required'], $path ); } $this->assertArrayHasKey( 'type', $schema, $path ); if ( 'object' === $schema['type'] ) { $this->assertArrayHasKey( 'properties', $schema, $path ); $this->assertArrayHasKey( 'additionalProperties', $schema, $path ); - if ( 'root/viewport' === $path || 'root/queriedObject' === $path || 'root/elements/items/intersectionRect' === $path || 'root/elements/items/boundingClientRect' === $path ) { + if ( 'root/viewport' === $path || 'root/elements/items/intersectionRect' === $path || 'root/elements/items/boundingClientRect' === $path ) { $this->assertFalse( $schema['additionalProperties'], "Path: $path" ); } else { $this->assertTrue( $schema['additionalProperties'], "Path: $path" ); diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts index bb877276a6..fc4e375b60 100644 --- a/plugins/optimization-detective/types.ts +++ b/plugins/optimization-detective/types.ts @@ -12,14 +12,8 @@ export interface ElementData { export type ExtendedElementData = ExcludeProps< ElementData >; -export interface QueriedObject { - type: 'post' | 'term' | 'user'; - id: number; -} - export interface URLMetric { url: string; - queriedObject?: QueriedObject; viewport: { width: number; height: number; From 41a1526d2cabf23988bbdb31b1a56e690a63da33 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 14 Nov 2024 09:51:50 -0800 Subject: [PATCH 08/11] Work around minor static analysis complaint due to differing Element definitions --- plugins/optimization-detective/detect.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 2cdb488ec4..e5e6cc8ab3 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -459,16 +459,21 @@ export default async function detect( { continue; } - const isLCP = - elementIntersection.target === lcpMetric?.entries[ 0 ]?.element; + const element = /** @type {Element|null} */ ( + lcpMetric?.entries[ 0 ]?.element + ); + const isLCP = elementIntersection.target === element; /** @type {ElementData} */ const elementData = { isLCP, isLCPCandidate: !! lcpMetricCandidates.find( - ( lcpMetricCandidate ) => - lcpMetricCandidate.entries[ 0 ]?.element === - elementIntersection.target + ( lcpMetricCandidate ) => { + const candidateElement = /** @type {Element|null} */ ( + lcpMetricCandidate.entries[ 0 ]?.element + ); + return candidateElement === elementIntersection.target; + } ), xpath, intersectionRatio: elementIntersection.intersectionRatio, From eee476116ddda4595021273e58397d72bfcffd1b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 14 Nov 2024 12:12:46 -0800 Subject: [PATCH 09/11] Switch to triggering invalidation of page cache via scheduled event --- plugins/embed-optimizer/tests/test-hooks.php | 1 - plugins/optimization-detective/hooks.php | 1 - .../storage/rest-api.php | 33 +++--- .../tests/storage/test-rest-api.php | 110 +++++++++++------- 4 files changed, 80 insertions(+), 65 deletions(-) diff --git a/plugins/embed-optimizer/tests/test-hooks.php b/plugins/embed-optimizer/tests/test-hooks.php index 2980927888..b9b55d8e70 100644 --- a/plugins/embed-optimizer/tests/test-hooks.php +++ b/plugins/embed-optimizer/tests/test-hooks.php @@ -18,7 +18,6 @@ public function test_embed_optimizer_add_hooks(): void { $this->assertSame( 10, has_action( 'od_init', 'embed_optimizer_init_optimization_detective' ) ); $this->assertSame( 10, has_action( 'wp_head', 'embed_optimizer_render_generator' ) ); $this->assertSame( 10, has_action( 'wp_loaded', 'embed_optimizer_add_non_optimization_detective_hooks' ) ); - $this->assertSame( 10, has_action( 'od_url_metric_stored', 'od_clean_queried_object_cache_for_stored_url_metric' ) ); } /** diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index b8d1073296..c0f94d148c 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -15,4 +15,3 @@ OD_URL_Metrics_Post_Type::add_hooks(); add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' ); add_action( 'wp_head', 'od_render_generator_meta_tag' ); -add_action( 'od_url_metric_stored', 'od_clean_queried_object_cache_for_stored_url_metric' ); diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index b510837177..2b505e14fc 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -208,6 +208,16 @@ function od_handle_rest_request( WP_REST_Request $request ) { } $post_id = $result; + // Schedule an event in 10 minutes to trigger an invalidation of the page cache (hopefully). + $cache_purge_post_id = $request->get_param( 'cache_purge_post_id' ); + if ( is_int( $cache_purge_post_id ) && false === wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $cache_purge_post_id ) ) ) { + wp_schedule_single_event( + time() + 10 * MINUTE_IN_SECONDS, + 'od_trigger_page_cache_invalidation', + array( $cache_purge_post_id ) + ); + } + /** * Fires whenever a URL Metric was successfully stored. * @@ -235,30 +245,17 @@ function od_handle_rest_request( WP_REST_Request $request ) { } /** - * Cleans the cache for the queried object when it has a new URL Metric stored. + * Triggers actions for page caches to invalidate their caches related to the supplied cache purge post ID. * * This is intended to flush any page cache for the URL after the new URL Metric was submitted so that the optimizations - * which depend on that URL Metric can start to take effect. Furthermore, when a submitted URL Metric results in a full - * sample of URL Metric groups, then flushing the page cache will allow the next request to omit the detection script - * module altogether. When a page cache holds onto a cached page for a long time (e.g. a week), this will result in - * the stored URL Metrics being stale if they have the default freshness TTL of 1 day. Nevertheless, if no changes have - * been applied to a cached page then those stale URL Metrics should continue to result in an optimized page. - * - * This assumes that a page caching plugin flushes the page cache for a queried object via `clean_post_cache`, - * `clean_term_cache`, and `clean_user_cache` actions. Other actions may make sense to trigger as well as can be seen in - * {@link https://github.com/pantheon-systems/pantheon-advanced-page-cache/blob/e3b5552/README.md?plain=1#L314-L356}. + * which depend on that URL Metric can start to take effect. * * @since n.e.x.t + * @access private * - * @param OD_URL_Metric_Store_Request_Context $context Context. + * @param int $cache_purge_post_id Cache purge post ID. */ -function od_clean_queried_object_cache_for_stored_url_metric( OD_URL_Metric_Store_Request_Context $context ): void { - - $cache_purge_post_id = $context->request->get_param( 'cache_purge_post_id' ); - if ( ! is_int( $cache_purge_post_id ) ) { - return; - } - +function od_trigger_page_cache_invalidation( int $cache_purge_post_id ): void { $post = get_post( $cache_purge_post_id ); if ( ! ( $post instanceof WP_Post ) ) { return; diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index b5ac38dd5a..1d112b4337 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -71,7 +71,7 @@ static function ( array $properties ): array { * * @covers ::od_register_endpoint * @covers ::od_handle_rest_request - * @covers ::od_clean_queried_object_cache_for_stored_url_metric + * @covers ::od_trigger_page_cache_invalidation */ public function test_rest_request_good_params( Closure $set_up ): void { $stored_context = null; @@ -89,15 +89,9 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context $valid_params = $set_up(); - $all_hook_callback_args = array(); - add_action( - 'all', - static function ( string $hook, ...$args ) use ( &$all_hook_callback_args ): void { - $all_hook_callback_args[ $hook ][] = $args; - }, - 10, - PHP_INT_MAX - ); + if ( isset( $valid_params['cache_purge_post_id'] ) ) { + $this->assertFalse( wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $valid_params['cache_purge_post_id'] ) ) ); + } $this->assertCount( 0, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); $request = $this->create_request( $valid_params ); @@ -126,43 +120,14 @@ static function ( string $hook, ...$args ) use ( &$all_hook_callback_args ): voi $this->assertInstanceOf( OD_URL_Metric_Store_Request_Context::class, $stored_context ); - // Now check that od_clean_queried_object_cache_for_stored_url_metric() cleaned caches as expected. + // Now check that od_trigger_page_cache_invalidation() cleaned caches as expected. $this->assertSame( $url_metrics[0]->jsonSerialize(), $stored_context->url_metric->jsonSerialize() ); $cache_purge_post_id = $stored_context->request->get_param( 'cache_purge_post_id' ); - if ( null !== $cache_purge_post_id ) { - $this->assertArrayHasKey( 'clean_post_cache', $all_hook_callback_args ); - $found = false; - foreach ( $all_hook_callback_args['clean_post_cache'] as $args ) { - if ( $args[0] === $cache_purge_post_id ) { - $this->assertInstanceOf( WP_Post::class, $args[1] ); - $this->assertSame( $cache_purge_post_id, $args[1]->ID ); - $found = true; - } - } - $this->assertTrue( $found, 'Expected clean_post_cache to have been fired for the post queried object.' ); - - $this->assertArrayHasKey( 'transition_post_status', $all_hook_callback_args ); - $found = false; - foreach ( $all_hook_callback_args['transition_post_status'] as $args ) { - $this->assertInstanceOf( WP_Post::class, $args[2] ); - if ( $args[2]->ID === $cache_purge_post_id ) { - $this->assertSame( $args[2]->post_status, $args[0] ); - $this->assertSame( $args[2]->post_status, $args[1] ); - $found = true; - } - } - $this->assertTrue( $found, 'Expected transition_post_status to have been fired for the post queried object.' ); - - $this->assertArrayHasKey( 'save_post', $all_hook_callback_args ); - $found = false; - foreach ( $all_hook_callback_args['save_post'] as $args ) { - if ( $args[0] === $cache_purge_post_id ) { - $this->assertInstanceOf( WP_Post::class, $args[1] ); - $this->assertSame( $cache_purge_post_id, $args[1]->ID ); - $found = true; - } - } - $this->assertTrue( $found, 'Expected save_post to have been fired for the post queried object.' ); + + if ( isset( $valid_params['cache_purge_post_id'] ) ) { + $scheduled = wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $valid_params['cache_purge_post_id'] ) ); + $this->assertIsInt( $scheduled ); + $this->assertGreaterThan( time(), $scheduled ); } } @@ -618,6 +583,61 @@ static function () use ( $breakpoint_width ): array { $this->assertSame( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response->get_data() ) ); } + /** + * Test od_trigger_page_cache_invalidation(). + * + * @covers ::od_trigger_page_cache_invalidation + */ + public function test_od_trigger_page_cache_invalidation(): void { + $cache_purge_post_id = self::factory()->post->create(); + + $all_hook_callback_args = array(); + add_action( + 'all', + static function ( string $hook, ...$args ) use ( &$all_hook_callback_args ): void { + $all_hook_callback_args[ $hook ][] = $args; + }, + 10, + PHP_INT_MAX + ); + + od_trigger_page_cache_invalidation( $cache_purge_post_id ); + + $this->assertArrayHasKey( 'clean_post_cache', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['clean_post_cache'] as $args ) { + if ( $args[0] === $cache_purge_post_id ) { + $this->assertInstanceOf( WP_Post::class, $args[1] ); + $this->assertSame( $cache_purge_post_id, $args[1]->ID ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected clean_post_cache to have been fired for the post queried object.' ); + + $this->assertArrayHasKey( 'transition_post_status', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['transition_post_status'] as $args ) { + $this->assertInstanceOf( WP_Post::class, $args[2] ); + if ( $args[2]->ID === $cache_purge_post_id ) { + $this->assertSame( $args[2]->post_status, $args[0] ); + $this->assertSame( $args[2]->post_status, $args[1] ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected transition_post_status to have been fired for the post queried object.' ); + + $this->assertArrayHasKey( 'save_post', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['save_post'] as $args ) { + if ( $args[0] === $cache_purge_post_id ) { + $this->assertInstanceOf( WP_Post::class, $args[1] ); + $this->assertSame( $cache_purge_post_id, $args[1]->ID ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected save_post to have been fired for the post queried object.' ); + } + /** * Populate URL Metrics. * From 28a649116ca19177865776b273a85fdfcd8e4080 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 14 Nov 2024 12:43:25 -0800 Subject: [PATCH 10/11] Add test for od_get_cache_purge_post_id() --- .../tests/test-detection.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/plugins/optimization-detective/tests/test-detection.php b/plugins/optimization-detective/tests/test-detection.php index 7242b456cc..6756cd80b0 100644 --- a/plugins/optimization-detective/tests/test-detection.php +++ b/plugins/optimization-detective/tests/test-detection.php @@ -7,6 +7,76 @@ class Test_OD_Detection extends WP_UnitTestCase { + /** + * Data provider. + * + * @return array + */ + public function data_provider_od_get_cache_purge_post_id(): array { + return array( + 'singular' => array( + 'set_up' => function () { + $post_id = self::factory()->post->create(); + $this->go_to( get_permalink( $post_id ) ); + return $post_id; + }, + 'expected_is_query_object' => true, + 'expected_query_object_class' => WP_Post::class, + ), + 'home' => array( + 'set_up' => function () { + $post_id = self::factory()->post->create(); + $this->go_to( home_url() ); + return $post_id; + }, + 'expected_is_query_object' => false, + 'expected_query_object_class' => null, + ), + 'category' => array( + 'set_up' => function () { + $cat_id = self::factory()->category->create(); + $post_id = self::factory()->post->create(); + wp_set_post_categories( $post_id, array( $cat_id ) ); + $this->go_to( get_category_link( $cat_id ) ); + return $post_id; + }, + 'expected_is_query_object' => false, + 'expected_query_object_class' => WP_Term::class, + ), + 'not_found' => array( + 'set_up' => function () { + $this->go_to( '/this-page-does-not-exist' ); + return null; + }, + 'expected_is_query_object' => false, + 'expected_query_object_class' => null, + ), + ); + } + + /** + * Tests od_get_cache_purge_post_id(). + * + * @covers ::od_get_cache_purge_post_id + * + * @dataProvider data_provider_od_get_cache_purge_post_id + */ + public function test_od_get_cache_purge_post_id( Closure $set_up, bool $expected_is_query_object, ?string $expected_query_object_class ): void { + $expected = $set_up(); + $this->assertSame( $expected, od_get_cache_purge_post_id() ); + if ( $expected_is_query_object ) { + $this->assertSame( $expected, get_queried_object_id() ); + } else { + $this->assertNotSame( $expected, get_queried_object_id() ); + } + + if ( null === $expected_query_object_class ) { + $this->assertNull( get_queried_object() ); + } else { + $this->assertSame( $expected_query_object_class, get_class( get_queried_object() ) ); + } + } + /** * Data provider. * From b76d1d366a529252d88291f96b2bd36ccef9fe00 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 14 Nov 2024 12:56:00 -0800 Subject: [PATCH 11/11] Add docs for the od_url_metric_stored action --- plugins/optimization-detective/readme.txt | 12 ++++++++++++ plugins/optimization-detective/storage/rest-api.php | 1 - 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/optimization-detective/readme.txt b/plugins/optimization-detective/readme.txt index 1e16f2c8bf..c04aea14b0 100644 --- a/plugins/optimization-detective/readme.txt +++ b/plugins/optimization-detective/readme.txt @@ -219,6 +219,18 @@ add_filter( See also [example usage](https://github.com/WordPress/performance/blob/6bb8405c5c446e3b66c2bfa3ae03ba61b188bca2/plugins/embed-optimizer/hooks.php#L128-L144) in Embed Optimizer. Note in particular the structure of the plugin’s [detect.js](https://github.com/WordPress/performance/blob/trunk/plugins/embed-optimizer/detect.js) script module, how it exports `initialize` and `finalize` functions which Optimization Detective then calls when the page loads and when the page unloads, at which time the URL Metric is constructed and sent to the server for storage. Refer also to the [TypeScript type definitions](https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/types.ts). +**Action:** `od_url_metric_stored` (argument: `OD_URL_Metric_Store_Request_Context`) + +Fires whenever a URL Metric was successfully stored. + +The supplied context object includes these properties: + +* `$request`: The `WP_REST_Request` for storing the URL Metric. +* `$post_id`: The post ID for the `od_url_metric` post. +* `$url_metric`: The newly-stored URL Metric. +* `$url_metric_group`: The viewport group that the URL Metric was added to. +* `$url_metric_group_collection`: The `OD_URL_Metric_Group_Collection` instance to which the URL Metric was added. + == Installation == = Installation from within WordPress = diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 2b505e14fc..9392d9ed49 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -222,7 +222,6 @@ function od_handle_rest_request( WP_REST_Request $request ) { * Fires whenever a URL Metric was successfully stored. * * @since 0.7.0 - * @todo Add this to the README as documentation. * * @param OD_URL_Metric_Store_Request_Context $context Context about the successful URL Metric collection. */