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 5df0991056..df4fc13651 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,15 @@ public function get_url(): string { return $this->data['url']; } + /** + * Gets queried object. + * + * @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 2ebea969fc..bd9949d12b 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 572ae06403..e2c0886747 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 e3dfc00b54..3741f6ff52 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -151,14 +151,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' ); } @@ -172,14 +174,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 b3e2f5574f..2278611b33 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; @@ -221,6 +221,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. */ @@ -241,3 +242,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;