Repository: WordPress/performance
Pull request #1919: Fire actions before and after Optimization Detective processes a document
Author: login: westonruter
Labels: name: [Type] Enhancement
description: A suggestion for improvement of an existing feature
name: [Plugin] Optimization Detective
description: Issues for the Optimization Detective plugin
Created: 2025-03-12T00:44:10Z | Merged: 2025-03-15T02:22:11Z

This is to address the discussion starting at https://github.com/WordPress/performance/issues/1859#issuecomment-2689790941.

Two new actions are introduced with this PR:

1. `od_start_template_optimization`
2. `od_finish_template_optimization`

The first is fired before walking over the document and invoking all of the tag visitors. The second is fired after the processing of the document is complete. Both actions are passed a new `OD_Template_Optimization_Context` object which contains the following properties:

```
 * @property-read OD_URL_Metric_Group_Collection $url_metric_group_collection URL Metric group collection.
 * @property-read positive-int|null              $url_metrics_id              ID for the od_url_metrics post which provided the URL Metrics in the collection.
 * @property-read array<string, mixed>           $normalized_query_vars       Normalized query vars.
 * @property-read non-empty-string               $url_metrics_slug            Slug for the od_url_metrics post.
 * @property-read OD_Link_Collection             $link_collection             Link collection.
```

~Additionally, two methods are exposed which are wrappers for the corresponding methods in `OD_HTML_Tag_Processor`:~

* ~`::append_head_html( string $html )`~
* ~`::append_body_html( string $html )`~

~Note that the underlying `OD_HTML_Tag_Processor` is _not_ exposed in this context because at `od_start_template_optimization` the iteration over the document has not started and at `od_finish_template_optimization` the iteration is completed. Mutations of specific tags are expected to be done via tag visitors, with the exception of being able to insert links via the supplied `OD_Link_Collection` instance and these two methods which append raw HTML to the `HEAD` and `BODY`.~

These actions will allow plugins to do more things with the URL Metrics, like extend the admin bar with URL Metric information without having to re-construct the `OD_URL_Metric_Group_Collection`. For example, the OD Admin UI plugin can use this to populate the Admin Bar item for URL Metrics without having to re-construct the `OD_URL_Metric_Group_Collection`: https://github.com/westonruter/od-admin-ui/pull/11

Additionally, the `od_start_template_optimization` action allows for tag visitors to do some initialization logic based on the current `OD_URL_Metric_Group_collection` prior to iterating over the document, which otherwise requires this initialization logic to be handled in the tag visitor callback itself, which is awkward. See https://github.com/WordPress/performance/pull/1921 for how this improves the tag visitor for background images in Image Prioritizer.

This also allows plugins to conditionally opt-out of caching a pre-optimized page, in particular by allowing them to access the constructed `OD_URL_Metric_Group_Collection`. For example, WP Super Cache allows plugins to prevent storing a page in cache by [defining](https://developer.jetpack.com/docs/customization/wp-super-cache/wp-super-cache-developer-documentation/#do-not-cache-this-page) a `DONOTCACHEPAGE` constant. So if you wanted to prevent caching a page until all viewport groups are populated, you could do:

```php
add_action(
	'od_finish_template_optimization',
	static function ( OD_Template_Optimization_Context $context ) {
		if (
			! $context->url_metric_group_collection->is_every_group_populated()
		) {
			if ( ! defined( 'DONOTCACHEPAGE' ) ) {
				define( 'DONOTCACHEPAGE', true );
			}
			// TODO: Not supported yet: $context->append_body_html( '<!-- Page prevented from being cached since URL Metrics are not populating every group. -->' );
		} else {
			// TODO: Not supported yet: $context->append_body_html( '<!-- All URL Metric groups are populated! -->' );
		}
	}
);
```

Or assuming another caching layer like Varnish, you could send the `Cache-Control: private` header if all groups are not complete, and you want to :

```php
add_action(
	'od_finish_template_optimization',
	static function ( OD_Template_Optimization_Context $context ) {
		if ( ! $context->url_metric_group_collection->is_every_group_complete() ) {
			header( 'Cache-Control: private', true );

			$incomplete_groups = array();
			foreach ( $context->url_metric_group_collection as $group ) {
				if ( ! $group->is_complete() ) {
					$incomplete_groups[] = od_generate_media_query( $group->get_minimum_viewport_width(), $group->get_maximum_viewport_width() );
				}
			}
			// TODO: Not supported yet: $context->append_body_html( '<!-- Incomplete URL Metric groups: ' . join( ', ', $incomplete_groups ) . '-->' );
		}
	}
);
``` 

These essentially implement #1912.

Originally I tried to move the initialization of the Optimization Detective objects just before the template was being rendered, but there was a tradeoff here. On one hand, it would allow all of the `OD_URL_Metric_Group_Collection` to be available earlier so the template could be rendered with information from the collection (e.g. to populate the admin bar). However, the downside here is that this would prevent the ability to register tag visitors conditionally when the page is rendered. For example, it doesn't make a lot of sense to register a tag visitor for a block that doesn't exist in the page. So you could do something like this to conditionally register a tag visitor to set `fetchpriority` on a `SCRIPT` tag for embeds, with a value of `high` if it appears in the initial viewport and `low` if it does not:

```php
add_filter(
	'render_block_core/embed',
	function ( $content ) {
		if ( ! has_action( 'od_register_tag_visitors', 'register_embed_script_defer_tag_visitor' ) ) {
			add_action( 'od_register_tag_visitors', 'register_embed_script_defer_tag_visitor' );
		}
		return $content;
	}
);

/**
 * Register tag visitor that adds fetchpriority to SCRIPT tags for embeds based on whether they appear in the viewport.
 * 
 * Scripts for embeds in the viewport get fetchpriority=high and those outside the viewport get fetchpriority=low.
 * 
 * @param OD_Tag_Visitor_Registry $tag_visitor_registry
 *
 * @return void
 */
function register_embed_script_defer_tag_visitor( OD_Tag_Visitor_Registry $tag_visitor_registry ) {
	$tag_visitor_registry->register( 'embed_script_lower_priority', static function ( OD_Tag_Visitor_Context $context ) {
		$processor = $context->processor;
		if ( ! $processor->has_class( 'wp-block-embed' ) ) {
			return;
		}

		$context->track_tag();

		// Abort if URL Metrics aren't collected for mobile and desktop yet.
		if (
			$context->url_metric_group_collection->get_first_group()->count() === 0
			&&
			$context->url_metric_group_collection->get_last_group()->count() === 0
		) {
			return;
		}

		$is_visible = $context->url_metric_group_collection->get_element_max_intersection_ratio( $processor->get_xpath() ) > PHP_FLOAT_EPSILON;

		$initial_depth = $processor->get_current_depth();
		while ( $processor->next_tag( array( 'tag_closers' => 'skip' ) ) ) {
			if ( $processor->get_tag() === 'SCRIPT' ) {
				$processor->set_attribute( 'fetchpriority', $is_visible ? 'high' : 'low' );
			}
			if ( $initial_depth === $processor->get_current_depth() ) {
				// Stop when we reach the end of the tag.
				break;
			}
		}
	} );
}
```

- [x] Add tests.


Diff:
diff --git a/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php
index bd455f1974..12dfb8f9f9 100644
--- a/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php
+++ b/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php
@@ -143,6 +143,7 @@ private function get_common_lcp_element_external_background_image( OD_URL_Metric
 	private function maybe_preload_external_lcp_background_image( OD_Tag_Visitor_Context $context ): void {
 		// Gather the tuples of URL Metric group and the common LCP element external background image.
 		// Note the groups of URL Metrics do not change across invocations, we just need to compute this once for all.
+		// TODO: Instead of populating this here, it could be done once per invocation during the od_start_template_optimization action since the page's OD_URL_Metric_Group_Collection is available there.
 		if ( ! is_array( $this->group_common_lcp_element_external_background_images ) ) {
 			$this->group_common_lcp_element_external_background_images = array();
 			foreach ( $context->url_metric_group_collection as $group ) {
diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php
index d96e4eea8a..bb775441f4 100644
--- a/plugins/optimization-detective/class-od-html-tag-processor.php
+++ b/plugins/optimization-detective/class-od-html-tag-processor.php
@@ -702,29 +702,33 @@ public function is_admin_bar(): bool {
 	}
 
 	/**
-	 * Append HTML to the HEAD.
+	 * Appends raw HTML to the HEAD.
 	 *
-	 * The provided HTML must be valid! No validation is performed.
+	 *  The provided HTML must be valid for insertion in the HEAD. No validation is currently performed. However, in the
+	 *  future the HTML Processor may be used to ensure the validity of the provided HTML. At that time, when invalid
+	 *  HTML is provided, this method may emit a `_doing_it_wrong()` warning.
 	 *
 	 * @since 0.4.0
 	 *
-	 * @param string $html HTML to inject.
+	 * @param string $raw_html Raw HTML to inject.
 	 */
-	public function append_head_html( string $html ): void {
-		$this->buffered_text_replacements[ self::END_OF_HEAD_BOOKMARK ][] = $html;
+	public function append_head_html( string $raw_html ): void {
+		$this->buffered_text_replacements[ self::END_OF_HEAD_BOOKMARK ][] = $raw_html;
 	}
 
 	/**
-	 * Append HTML to the BODY.
+	 * Appends raw HTML to the BODY.
 	 *
-	 * The provided HTML must be valid! No validation is performed.
+	 * The provided HTML must be valid for insertion in the BODY. No validation is currently performed. However, in the
+	 * future the HTML Processor may be used to ensure the validity of the provided HTML. At that time, when invalid
+	 * HTML is provided, this method may emit a `_doing_it_wrong()` warning.
 	 *
 	 * @since 0.4.0
 	 *
-	 * @param string $html HTML to inject.
+	 * @param string $raw_html Raw HTML to inject.
 	 */
-	public function append_body_html( string $html ): void {
-		$this->buffered_text_replacements[ self::END_OF_BODY_BOOKMARK ][] = $html;
+	public function append_body_html( string $raw_html ): void {
+		$this->buffered_text_replacements[ self::END_OF_BODY_BOOKMARK ][] = $raw_html;
 	}
 
 	/**
diff --git a/plugins/optimization-detective/class-od-tag-visitor-context.php b/plugins/optimization-detective/class-od-tag-visitor-context.php
index fae07d74c2..920ed35442 100644
--- a/plugins/optimization-detective/class-od-tag-visitor-context.php
+++ b/plugins/optimization-detective/class-od-tag-visitor-context.php
@@ -110,7 +110,7 @@ public function track_tag(): void {
 	 * @throws Error When property is unknown.
 	 */
 	public function __get( string $name ) {
-		// Note that there is intentionally not a case for 'visited_tag_state'.
+		// Note: There is intentionally not a 'visited_tag_state' case to expose $this->visited_tag_state.
 		switch ( $name ) {
 			case 'processor':
 				return $this->processor;
diff --git a/plugins/optimization-detective/class-od-template-optimization-context.php b/plugins/optimization-detective/class-od-template-optimization-context.php
new file mode 100644
index 0000000000..66f6fe2ce6
--- /dev/null
+++ b/plugins/optimization-detective/class-od-template-optimization-context.php
@@ -0,0 +1,125 @@
+<?php
+/**
+ * Optimization Detective: OD_Template_Optimization_Context class
+ *
+ * @package optimization-detective
+ * @since n.e.x.t
+ */
+
+// @codeCoverageIgnoreStart
+if ( ! defined( 'ABSPATH' ) ) {
+	exit; // Exit if accessed directly.
+}
+// @codeCoverageIgnoreEnd
+
+/**
+ * Context for optimizing a template.
+ *
+ * @since n.e.x.t
+ *
+ * @property-read OD_URL_Metric_Group_Collection $url_metric_group_collection URL Metric group collection.
+ * @property-read positive-int|null              $url_metrics_id              ID for the od_url_metrics post which provided the URL Metrics in the collection.
+ * @property-read array<string, mixed>           $normalized_query_vars       Normalized query vars.
+ * @property-read non-empty-string               $url_metrics_slug            Slug for the od_url_metrics post.
+ * @property-read OD_Link_Collection             $link_collection             Link collection.
+ */
+final class OD_Template_Optimization_Context {
+
+	/**
+	 * URL Metric group collection.
+	 *
+	 * @since n.e.x.t
+	 * @var OD_URL_Metric_Group_Collection
+	 */
+	private $url_metric_group_collection;
+
+	/**
+	 * ID for the od_url_metrics post which provided the URL Metrics in the collection.
+	 *
+	 * May be null if no post has been created yet.
+	 *
+	 * @since n.e.x.t
+	 * @var positive-int|null
+	 */
+	private $url_metrics_id;
+
+	/**
+	 * Normalized query vars.
+	 *
+	 * @since n.e.x.t
+	 * @var array<string, mixed>
+	 */
+	private $normalized_query_vars;
+
+	/**
+	 * Slug for the od_url_metrics post.
+	 *
+	 * @since n.e.x.t
+	 * @var non-empty-string
+	 */
+	private $url_metrics_slug;
+
+	/**
+	 * Link collection.
+	 *
+	 * @since n.e.x.t
+	 * @var OD_Link_Collection
+	 */
+	private $link_collection;
+
+	/**
+	 * Constructor.
+	 *
+	 * @since n.e.x.t
+	 * @access private
+	 *
+	 * @param OD_URL_Metric_Group_Collection $url_metric_group_collection URL Metric group collection.
+	 * @param OD_Link_Collection             $link_collection             Link collection.
+	 * @param array<string, mixed>           $normalized_query_vars       Normalized query vars.
+	 * @param non-empty-string               $url_metrics_slug            Slug for the od_url_metrics post.
+	 * @param positive-int|null              $url_metrics_id              ID for the od_url_metrics post which provided the URL Metrics in the collection. May be null if no post has been created yet.
+	 */
+	public function __construct( OD_URL_Metric_Group_Collection $url_metric_group_collection, OD_Link_Collection $link_collection, array $normalized_query_vars, string $url_metrics_slug, ?int $url_metrics_id ) {
+		$this->url_metric_group_collection = $url_metric_group_collection;
+		$this->link_collection             = $link_collection;
+		$this->normalized_query_vars       = $normalized_query_vars;
+		$this->url_metrics_slug            = $url_metrics_slug;
+		$this->url_metrics_id              = $url_metrics_id;
+	}
+
+	/**
+	 * Gets a property.
+	 *
+	 * @since n.e.x.t
+	 *
+	 * @param string $name Property name.
+	 * @return mixed Property value.
+	 *
+	 * @throws Error When property is unknown.
+	 */
+	public function __get( string $name ) {
+		// Note: There is intentionally not a 'processor' case to expose $this->processor.
+		switch ( $name ) {
+			case 'url_metrics_id':
+				return $this->url_metrics_id;
+			case 'url_metric_group_collection':
+				return $this->url_metric_group_collection;
+			case 'normalized_query_vars':
+				return $this->normalized_query_vars;
+			case 'url_metrics_slug':
+				return $this->url_metrics_slug;
+			case 'link_collection':
+				return $this->link_collection;
+			default:
+				throw new Error(
+					esc_html(
+						sprintf(
+							/* translators: %s is class member variable name */
+							__( 'Unknown property %s.', 'optimization-detective' ),
+							__CLASS__ . '::$' . $name
+						)
+					)
+				);
+		}
+	}
+}
diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php
index 3d69d6fa92..b396667797 100644
--- a/plugins/optimization-detective/load.php
+++ b/plugins/optimization-detective/load.php
@@ -124,6 +124,7 @@ class_alias( OD_URL_Metric_Group_Collection::class, 'OD_URL_Metrics_Group_Collec
 		require_once __DIR__ . '/detection.php';
 
 		// Optimization logic.
+		require_once __DIR__ . '/class-od-template-optimization-context.php';
 		require_once __DIR__ . '/class-od-link-collection.php';
 		require_once __DIR__ . '/class-od-tag-visitor-registry.php';
 		require_once __DIR__ . '/class-od-visited-tag-state.php';
diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php
index e9240c05df..508f1cafc8 100644
--- a/plugins/optimization-detective/optimization.php
+++ b/plugins/optimization-detective/optimization.php
@@ -245,8 +245,16 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 		return $buffer;
 	}
 
-	$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
-	$post = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$query_vars = od_get_normalized_query_vars();
+	$slug       = od_get_url_metrics_slug( $query_vars );
+	$post       = OD_URL_Metrics_Post_Type::get_post( $slug );
+
+	/**
+	 * Post ID.
+	 *
+	 * @var positive-int|null $post_id
+	 */
+	$post_id = $post instanceof WP_Post ? $post->ID : null;
 
 	$tag_visitor_registry = new OD_Tag_Visitor_Registry();
 
@@ -259,22 +267,40 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 	 */
 	do_action( 'od_register_tag_visitors', $tag_visitor_registry );
 
-	$current_etag         = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
-	$group_collection     = new OD_URL_Metric_Group_Collection(
+	$current_etag     = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
+	$group_collection = new OD_URL_Metric_Group_Collection(
 		$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
 		$current_etag,
 		od_get_breakpoint_max_widths(),
 		od_get_url_metrics_breakpoint_sample_size(),
 		od_get_url_metric_freshness_ttl()
 	);
-	$link_collection      = new OD_Link_Collection();
+	$link_collection  = new OD_Link_Collection();
+
+	$template_optimization_context = new OD_Template_Optimization_Context(
+		$group_collection,
+		$link_collection,
+		$query_vars,
+		$slug,
+		$post_id
+	);
+
+	/**
+	 * Fires before Optimization Detective starts optimizing the template.
+	 *
+	 * @since n.e.x.t
+	 *
+	 * @param OD_Template_Optimization_Context $template_optimization_context Template optimization context.
+	 */
+	do_action( 'od_start_template_optimization', $template_optimization_context );
+
 	$visited_tag_state    = new OD_Visited_Tag_State();
 	$tag_visitor_context  = new OD_Tag_Visitor_Context(
 		$processor,
 		$group_collection,
 		$link_collection,
 		$visited_tag_state,
-		$post instanceof WP_Post && $post->ID > 0 ? $post->ID : null
+		$post_id
 	);
 	$current_tag_bookmark = 'optimization_detective_current_tag';
 	$visitors             = iterator_to_array( $tag_visitor_registry );
@@ -322,7 +348,23 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 		$visited_tag_state->reset();
 	} while ( $processor->next_tag( array( 'tag_closers' => 'skip' ) ) );
 
+	// Inject detection script.
+	// TODO: When optimizing above, if we find that there is a stored LCP element but it fails to match, it should perhaps set $needs_detection to true and send the request with an override nonce. However, this would require backtracking and adding the data-od-xpath attributes.
+	if ( $needs_detection ) {
+		$processor->append_body_html( od_get_detection_script( $slug, $group_collection ) );
+	}
+
+	/**
+	 * Fires after Optimization Detective finishes optimizing the template.
+	 *
+	 * @since n.e.x.t
+	 *
+	 * @param OD_Template_Optimization_Context $template_optimization_context Template optimization context.
+	 */
+	do_action( 'od_finish_template_optimization', $template_optimization_context );
+
 	// Send any preload links in a Link response header and in a LINK tag injected at the end of the HEAD.
+	// Additional links may have been added at the od_finish_template_optimization action, so this must come after.
 	if ( count( $link_collection ) > 0 ) {
 		$response_header_links = $link_collection->get_response_header();
 		if ( ! is_null( $response_header_links ) && ! headers_sent() ) {
@@ -331,11 +373,5 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 		$processor->append_head_html( $link_collection->get_html() );
 	}
 
-	// Inject detection script.
-	// TODO: When optimizing above, if we find that there is a stored LCP element but it fails to match, it should perhaps set $needs_detection to true and send the request with an override nonce. However, this would require backtracking and adding the data-od-xpath attributes.
-	if ( $needs_detection ) {
-		$processor->append_body_html( od_get_detection_script( $slug, $group_collection ) );
-	}
-
 	return $processor->get_updated_html();
 }
diff --git a/plugins/optimization-detective/tests/test-cases/admin-bar/expected.html b/plugins/optimization-detective/tests/test-cases/admin-bar/expected.html
index 532d16658f..cef1b2bb25 100644
--- a/plugins/optimization-detective/tests/test-cases/admin-bar/expected.html
+++ b/plugins/optimization-detective/tests/test-cases/admin-bar/expected.html
@@ -2,7 +2,8 @@
 	<head>
 		<meta charset="utf-8" />
 		<title>...</title>
-	</head>
+	<link data-od-added-tag rel="preconnect" href="https://inserted-at-finish-template-optimization-action.example.net/">
+</head>
 	<body>
 		<div id="wpadminbar" class="nojq nojs">
 			<div class="quicklinks" id="wp-toolbar" role="navigation" aria-label="Toolbar">
diff --git a/plugins/optimization-detective/tests/test-cases/complete-url-metrics/expected.html b/plugins/optimization-detective/tests/test-cases/complete-url-metrics/expected.html
index 9d17b1daaa..1b3f152d57 100644
--- a/plugins/optimization-detective/tests/test-cases/complete-url-metrics/expected.html
+++ b/plugins/optimization-detective/tests/test-cases/complete-url-metrics/expected.html
@@ -2,7 +2,8 @@
 	<head>
 		<meta charset="utf-8">
 		<title>...</title>
-	</head>
+	<link data-od-added-tag rel="preconnect" href="https://inserted-at-finish-template-optimization-action.example.net/">
+</head>
 	<body>
 		<div id="page">
 			<img src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" loading="lazy">
diff --git a/plugins/optimization-detective/tests/test-cases/many-images/expected.html b/plugins/optimization-detective/tests/test-cases/many-images/expected.html
index 9e3201dfff..cd8f9d4259 100644
--- a/plugins/optimization-detective/tests/test-cases/many-images/expected.html
+++ b/plugins/optimization-detective/tests/test-cases/many-images/expected.html
@@ -2,7 +2,8 @@
 	<head>
 		<meta charset="utf-8">
 		<title>...</title>
-	</head>
+	<link data-od-added-tag rel="preconnect" href="https://inserted-at-finish-template-optimization-action.example.net/">
+</head>
 	<body>
 		<div id="page">
 			<img data-od-xpath="/HTML/BODY/DIV[@id=&#039;page&#039;]/*[1][self::IMG]" src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" loading="lazy">
diff --git a/plugins/optimization-detective/tests/test-cases/no-url-metrics/expected.html b/plugins/optimization-detective/tests/test-cases/no-url-metrics/expected.html
index 1a7bdccd47..6d21c8825d 100644
--- a/plugins/optimization-detective/tests/test-cases/no-url-metrics/expected.html
+++ b/plugins/optimization-detective/tests/test-cases/no-url-metrics/expected.html
@@ -2,7 +2,8 @@
 	<head>
 		<meta charset="utf-8">
 		<title>...</title>
-	</head>
+	<link data-od-added-tag rel="preconnect" href="https://inserted-at-finish-template-optimization-action.example.net/">
+</head>
 	<body>
 		<div id="page">
 			<img data-od-xpath="/HTML/BODY/DIV[@id=&#039;page&#039;]/*[1][self::IMG]" src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" loading="lazy">
diff --git a/plugins/optimization-detective/tests/test-cases/noscript/expected.html b/plugins/optimization-detective/tests/test-cases/noscript/expected.html
index b2d736475d..e0c2fa8bca 100644
--- a/plugins/optimization-detective/tests/test-cases/noscript/expected.html
+++ b/plugins/optimization-detective/tests/test-cases/noscript/expected.html
@@ -2,7 +2,8 @@
 	<head>
 		<meta charset="utf-8">
 		<title>...</title>
-	</head>
+	<link data-od-added-tag rel="preconnect" href="https://inserted-at-finish-template-optimization-action.example.net/">
+</head>
 	<body>
 		<div id="page">
 			<noscript>
diff --git a/plugins/optimization-detective/tests/test-cases/preload-link/expected.html b/plugins/optimization-detective/tests/test-cases/preload-link/expected.html
index ba358c6ce6..e6fdf03462 100644
--- a/plugins/optimization-detective/tests/test-cases/preload-link/expected.html
+++ b/plugins/optimization-detective/tests/test-cases/preload-link/expected.html
@@ -3,6 +3,7 @@
 		<meta charset="utf-8">
 		<title>...</title>
 	<link data-od-added-tag rel="preload" as="image" href="https://example.com/image.jpg">
+<link data-od-added-tag rel="preconnect" href="https://inserted-at-finish-template-optimization-action.example.net/">
 </head>
 	<body>
 		<div id="page">
diff --git a/plugins/optimization-detective/tests/test-cases/tag-track-opt-in/expected.html b/plugins/optimization-detective/tests/test-cases/tag-track-opt-in/expected.html
index 38e8f34613..146cd940a5 100644
--- a/plugins/optimization-detective/tests/test-cases/tag-track-opt-in/expected.html
+++ b/plugins/optimization-detective/tests/test-cases/tag-track-opt-in/expected.html
@@ -2,7 +2,8 @@
 	<head>
 		<meta charset="utf-8">
 		<title>...</title>
-	</head>
+	<link data-od-added-tag rel="preconnect" href="https://inserted-at-finish-template-optimization-action.example.net/">
+</head>
 	<body>
 		<div id="page">
 			<div id="not-tracked-by-any"></div>
diff --git a/plugins/optimization-detective/tests/test-cases/video/expected.html b/plugins/optimization-detective/tests/test-cases/video/expected.html
index a6c2ac4a39..599ae9ded4 100644
--- a/plugins/optimization-detective/tests/test-cases/video/expected.html
+++ b/plugins/optimization-detective/tests/test-cases/video/expected.html
@@ -2,7 +2,8 @@
 	<head>
 		<meta charset="utf-8">
 		<title>...</title>
-	</head>
+	<link data-od-added-tag rel="preconnect" href="https://inserted-at-finish-template-optimization-action.example.net/">
+</head>
 	<body>
 		<div id="page">
 			<video data-od-xpath="/HTML/BODY/DIV[@id=&#039;page&#039;]/*[1][self::VIDEO]" width="620" controls poster="https://upload.wikimedia.org/wikipedia/commons/e/e8/Elephants_Dream_s5_both.jpg">
diff --git a/plugins/optimization-detective/tests/test-cases/xhtml-response/expected.html b/plugins/optimization-detective/tests/test-cases/xhtml-response/expected.html
index 19591e005f..975f527017 100644
--- a/plugins/optimization-detective/tests/test-cases/xhtml-response/expected.html
+++ b/plugins/optimization-detective/tests/test-cases/xhtml-response/expected.html
@@ -4,7 +4,8 @@
 	<head>
 	  <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
 	  <title>XHTML 1.0 Strict Example</title>
-	</head>
+	<link data-od-added-tag rel="preconnect" href="https://inserted-at-finish-template-optimization-action.example.net/">
+</head>
 	<body>
 		<div id="page">
 			<p><img data-od-xpath="/HTML/BODY/DIV[@id=&#039;page&#039;]/*[1][self::P]/*[1][self::IMG]" src="https://example.com/foo.jpg" alt="Foo" width="1200" height="800" /></p>
diff --git a/plugins/optimization-detective/tests/test-optimization.php b/plugins/optimization-detective/tests/test-optimization.php
index 3347317866..121cbe2f3b 100644
--- a/plugins/optimization-detective/tests/test-optimization.php
+++ b/plugins/optimization-detective/tests/test-optimization.php
@@ -377,6 +377,8 @@ public function data_provider_test_od_optimize_template_output_buffer(): array {
 	 * @covers OD_Visited_Tag_State::is_tag_tracked
 	 * @covers OD_Visited_Tag_State::reset
 	 * @covers OD_HTML_Tag_Processor::is_admin_bar
+	 * @covers OD_Template_Optimization_Context::__construct
+	 * @covers OD_Template_Optimization_Context::__get
 	 *
 	 * @dataProvider data_provider_test_od_optimize_template_output_buffer
 	 *
@@ -385,6 +387,18 @@ public function data_provider_test_od_optimize_template_output_buffer(): array {
 	 * @noinspection PhpDocMissingThrowsInspection
 	 */
 	public function test_od_optimize_template_output_buffer( string $directory ): void {
+		$this->assertSame( 0, did_action( 'od_register_tag_visitors' ) );
+		$this->assertSame( 0, did_action( 'od_start_template_optimization' ) );
+		$this->assertSame( 0, did_action( 'od_finish_template_optimization' ) );
+
+		$did_initialize = false;
+		add_action(
+			'od_register_tag_visitors',
+			static function () use ( &$did_initialize ): void {
+				$did_initialize = true;
+			}
+		);
+
 		add_action(
 			'od_register_tag_visitors',
 			function ( OD_Tag_Visitor_Registry $tag_visitor_registry ): void {
@@ -431,6 +445,53 @@ function ( OD_Tag_Visitor_Context $context ): void {
 			}
 		);
 
+		$template_optimization_context = null;
+		add_action(
+			'od_start_template_optimization',
+			function ( OD_Template_Optimization_Context $context ) use ( &$template_optimization_context ): void {
+				$this->assertInstanceOf( OD_URL_Metric_Group_Collection::class, $context->url_metric_group_collection );
+				$this->assertTrue( is_int( $context->url_metrics_id ) || is_null( $context->url_metrics_id ) );
+				$this->assertIsArray( $context->normalized_query_vars );
+				$this->assertIsString( $context->url_metrics_slug );
+				$this->assertSame( od_get_url_metrics_slug( $context->normalized_query_vars ), $context->url_metrics_slug );
+				if ( is_int( $context->url_metrics_id ) ) {
+					$post = get_post( $context->url_metrics_id );
+					$this->assertInstanceOf( WP_Post::class, $post );
+					$this->assertSame( $post->post_name, $context->url_metrics_slug );
+				}
+				$this->assertInstanceOf( OD_Link_Collection::class, $context->link_collection );
+
+				$error = null;
+				$value = '';
+				try {
+					$value = $context->__get( 'invalid_param' );
+				} catch ( Error $e ) {
+					$error = $e;
+				}
+				$this->assertInstanceOf( Error::class, $error );
+				$this->assertSame( '', $value );
+
+				$template_optimization_context = $context;
+			}
+		);
+
+		add_action(
+			'od_finish_template_optimization',
+			function ( OD_Template_Optimization_Context $context ) use ( &$template_optimization_context ): void {
+				$this->assertSame( $template_optimization_context, $context );
+				$context->link_collection->add_link(
+					array(
+						'rel'  => 'preconnect',
+						'href' => 'https://inserted-at-finish-template-optimization-action.example.net/',
+					)
+				);
+			}
+		);
+
 		$this->assert_snapshot_equals( $directory );
+
+		$this->assertSame( $did_initialize ? 1 : 0, did_action( 'od_register_tag_visitors' ) );
+		$this->assertSame( $did_initialize ? 1 : 0, did_action( 'od_start_template_optimization' ) );
+		$this->assertSame( $did_initialize ? 1 : 0, did_action( 'od_finish_template_optimization' ) );
 	}
 }


Reviews:
user: login: westonruter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T01:48:36Z
user: login: westonruter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T17:46:22Z
user: login: westonruter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T18:15:40Z
user: login: felixarntz
body: @westonruter Overall this looks good to me. A few questions and notes:
* I don't fully understand what part of the conversation starting in https://github.com/WordPress/performance/issues/1859#issuecomment-2689790941 this is supposed to address. Can you clarify?
* To confirm, the main purpose of these actions is to only allow _reading_ relevant data for the OD optimization process right? In other words, they are not meant to modify/perform any actual optimizations?
* I'm not sure about the `append_head_html` and `append_body_html` methods in their current implementation. Other than appending body comments, what are real use-cases for the two methods?
* I know they're documented to not validate and that one needs to be careful for that reason, but (partly dependent on your response to my previous point) we may want to come up with something better. For example, there could be an `append_body_comment` method that expects a string that is HTML escaped and wrapped in the HTML comment markup. This would be much safer than allowing arbitrary HTML.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T21:40:12Z
user: login: westonruter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T21:50:02Z
user: login: westonruter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T21:53:55Z
user: login: felixarntz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T22:13:55Z
user: login: westonruter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T23:10:58Z
user: login: westonruter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T23:11:34Z
user: login: felixarntz
body: Thanks @westonruter, LGTM!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-15T02:00:59Z

Review comments:
diff_hunk: @@ -112,7 +114,53 @@ static function () use ( $reasons ): void {
 		return;
 	}
 
-	$callback = 'od_optimize_template_output_buffer';
+	$slug    = od_get_url_metrics_slug( od_get_normalized_query_vars() );
+	$post    = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$post_id = $post instanceof WP_Post && $post->ID > 0 ? $post->ID : null;
+
+	$tag_visitor_registry = new OD_Tag_Visitor_Registry();
+
+	/**
+	 * Fires to register tag visitors before walking over the document to perform optimizations.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_Tag_Visitor_Registry $tag_visitor_registry Tag visitor registry.
+	 */
+	do_action( 'od_register_tag_visitors', $tag_visitor_registry );
+
+	global $wp_the_query;
+	$current_etag     = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() ); // TODO: Make sure the template is set!!
path: plugins/optimization-detective/optimization.php
user: login: westonruter
body: I think this may be too early. The `wp` action runs before the template is determined.
created_at: 2025-03-12T01:48:36Z
author_association: MEMBER
diff_hunk: @@ -112,7 +114,53 @@ static function () use ( $reasons ): void {
 		return;
 	}
 
-	$callback = 'od_optimize_template_output_buffer';
+	$slug    = od_get_url_metrics_slug( od_get_normalized_query_vars() );
+	$post    = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$post_id = $post instanceof WP_Post && $post->ID > 0 ? $post->ID : null;
+
+	$tag_visitor_registry = new OD_Tag_Visitor_Registry();
+
+	/**
+	 * Fires to register tag visitors before walking over the document to perform optimizations.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_Tag_Visitor_Registry $tag_visitor_registry Tag visitor registry.
+	 */
+	do_action( 'od_register_tag_visitors', $tag_visitor_registry );
+
+	global $wp_the_query;
+	$current_etag     = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() ); // TODO: Make sure the template is set!!
path: plugins/optimization-detective/optimization.php
user: login: westonruter
body: Indeed. It is too early. The `od_get_current_theme_template()` function returns `null` at this point. It will only be able to return the current template reliably _after_ the `template_include` filter has run, but there is no action that runs between then and the template actually rendering.
created_at: 2025-03-12T17:46:22Z
author_association: MEMBER
diff_hunk: @@ -112,7 +114,53 @@ static function () use ( $reasons ): void {
 		return;
 	}
 
-	$callback = 'od_optimize_template_output_buffer';
+	$slug    = od_get_url_metrics_slug( od_get_normalized_query_vars() );
+	$post    = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$post_id = $post instanceof WP_Post && $post->ID > 0 ? $post->ID : null;
+
+	$tag_visitor_registry = new OD_Tag_Visitor_Registry();
+
+	/**
+	 * Fires to register tag visitors before walking over the document to perform optimizations.
+	 *
+	 * @since 0.3.0
+	 *
+	 * @param OD_Tag_Visitor_Registry $tag_visitor_registry Tag visitor registry.
+	 */
+	do_action( 'od_register_tag_visitors', $tag_visitor_registry );
+
+	global $wp_the_query;
+	$current_etag     = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() ); // TODO: Make sure the template is set!!
path: plugins/optimization-detective/optimization.php
user: login: westonruter
body: This works: f973719a5
created_at: 2025-03-12T18:15:39Z
author_association: MEMBER
diff_hunk: @@ -245,8 +245,10 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 		return $buffer;
 	}
 
-	$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
-	$post = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$query_vars = od_get_normalized_query_vars();
+	$slug       = od_get_url_metrics_slug( $query_vars );
+	$post       = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$post_id    = $post instanceof WP_Post && $post->ID > 0 ? $post->ID : null;
path: plugins/optimization-detective/optimization.php
user: login: felixarntz
body: I find this a bit strange - I assume without it PHPStan complains. But in reality a `WP_Post` should never have a non-positive `ID`. Obviously an additional check for it is not a real problem, but if we then set the `$post_id` to `null` if not, we should also set the `$post` to `null` in that same situation.

In other words: If somehow the `WP_Post` is invalid, we shouldn't allow the post object to get passed through either, if we don't accept its `ID` value.
created_at: 2025-03-13T21:29:46Z
author_association: MEMBER
diff_hunk: @@ -259,22 +261,41 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 	 */
 	do_action( 'od_register_tag_visitors', $tag_visitor_registry );
 
-	$current_etag         = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
-	$group_collection     = new OD_URL_Metric_Group_Collection(
+	$current_etag     = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
+	$group_collection = new OD_URL_Metric_Group_Collection(
 		$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
 		$current_etag,
 		od_get_breakpoint_max_widths(),
 		od_get_url_metrics_breakpoint_sample_size(),
 		od_get_url_metric_freshness_ttl()
 	);
-	$link_collection      = new OD_Link_Collection();
+	$link_collection  = new OD_Link_Collection();
+
+	$context = new OD_Template_Optimization_Context(
path: plugins/optimization-detective/optimization.php
user: login: felixarntz
body: Maybe better call the variable something like `$template_optimization_context` to clearly separate it from `$tag_visitor_context`.
created_at: 2025-03-13T21:31:41Z
author_association: MEMBER
diff_hunk: @@ -259,22 +261,41 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 	 */
 	do_action( 'od_register_tag_visitors', $tag_visitor_registry );
 
-	$current_etag         = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
-	$group_collection     = new OD_URL_Metric_Group_Collection(
+	$current_etag     = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() );
+	$group_collection = new OD_URL_Metric_Group_Collection(
 		$post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(),
 		$current_etag,
 		od_get_breakpoint_max_widths(),
 		od_get_url_metrics_breakpoint_sample_size(),
 		od_get_url_metric_freshness_ttl()
 	);
-	$link_collection      = new OD_Link_Collection();
+	$link_collection  = new OD_Link_Collection();
+
+	$context = new OD_Template_Optimization_Context(
path: plugins/optimization-detective/optimization.php
user: login: westonruter
body: Great point. Done in 09d7af77f.
created_at: 2025-03-13T21:50:01Z
author_association: MEMBER
diff_hunk: @@ -245,8 +245,10 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 		return $buffer;
 	}
 
-	$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
-	$post = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$query_vars = od_get_normalized_query_vars();
+	$slug       = od_get_url_metrics_slug( $query_vars );
+	$post       = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$post_id    = $post instanceof WP_Post && $post->ID > 0 ? $post->ID : null;
path: plugins/optimization-detective/optimization.php
user: login: westonruter
body: Yes, it's for the sake of PHPStan. The condition was present before when constructing `OD_Tag_Visitor_Context` but just moved up here.

Technically `WP_Post` can have a negative integer _sometimes_. It was a hack I used in the Customizer to denote `nav_menu_item` posts which were in a changeset but not yet published to the database 😅 

Otherwise, the `$post` is only used in `OD_URL_Metrics_Post_Type::get_url_metrics_from_post()` below, which only looks at the `post_content` of the object, so in that case it doesn't matter if the `ID` is not valid.
created_at: 2025-03-13T21:53:55Z
author_association: MEMBER
diff_hunk: @@ -245,8 +245,10 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 		return $buffer;
 	}
 
-	$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
-	$post = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$query_vars = od_get_normalized_query_vars();
+	$slug       = od_get_url_metrics_slug( $query_vars );
+	$post       = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$post_id    = $post instanceof WP_Post && $post->ID > 0 ? $post->ID : null;
path: plugins/optimization-detective/optimization.php
user: login: felixarntz
body: But if a post doesn't have an ID, wouldn't it be considered irrelevant for Optimization Detective too? I think it would make more sense to then nullify it too for a consistent experience to avoid a weird edge-case.
created_at: 2025-03-13T22:13:55Z
author_association: MEMBER
diff_hunk: @@ -245,8 +245,10 @@ function od_optimize_template_output_buffer( string $buffer ): string {
 		return $buffer;
 	}
 
-	$slug = od_get_url_metrics_slug( od_get_normalized_query_vars() );
-	$post = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$query_vars = od_get_normalized_query_vars();
+	$slug       = od_get_url_metrics_slug( $query_vars );
+	$post       = OD_URL_Metrics_Post_Type::get_post( $slug );
+	$post_id    = $post instanceof WP_Post && $post->ID > 0 ? $post->ID : null;
path: plugins/optimization-detective/optimization.php
user: login: westonruter
body: Since in practice it will never not be a `positive-int`, I've opted for phpdoc to declare it as such: 0d8cb6540.
created_at: 2025-03-13T23:10:58Z
author_association: MEMBER
diff_hunk: @@ -143,6 +143,7 @@ private function get_common_lcp_element_external_background_image( OD_URL_Metric
 	private function maybe_preload_external_lcp_background_image( OD_Tag_Visitor_Context $context ): void {
 		// Gather the tuples of URL Metric group and the common LCP element external background image.
 		// Note the groups of URL Metrics do not change across invocations, we just need to compute this once for all.
+		// TODO: Instead of populating this here, it could be done once per invocation during the od_start_template_optimization action since the page's OD_URL_Metric_Group_Collection is available there.
path: plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php
user: login: westonruter
body: See https://github.com/WordPress/performance/pull/1921
created_at: 2025-03-13T23:11:34Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-13T20:37:49Z
body: ## [Codecov](https://app.codecov.io/gh/WordPress/performance/pull/1919?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress) Report
Attention: Patch coverage is `97.95918%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 71.39%. Comparing base [(`ab0b99a`)](https://app.codecov.io/gh/WordPress/performance/commit/ab0b99ad48f904dee864ed5db64dbc17b1ac74de?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress) to head [(`a01d760`)](https://app.codecov.io/gh/WordPress/performance/commit/a01d760ace668ff48cb6c319e94c7a37e2e36e8d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress).
> Report is 17 commits behind head on trunk.

| [Files with missing lines](https://app.codecov.io/gh/WordPress/performance/pull/1919?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress) | Patch % | Lines |
|---|---|---|
| [plugins/optimization-detective/load.php](https://app.codecov.io/gh/WordPress/performance/pull/1919?src=pr&el=tree&filepath=plugins%2Foptimization-detective%2Fload.php&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress#diff-cGx1Z2lucy9vcHRpbWl6YXRpb24tZGV0ZWN0aXZlL2xvYWQucGhw) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/WordPress/performance/pull/1919?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##            trunk    #1919      +/-   ##
==========================================
+ Coverage   71.25%   71.39%   +0.13%     
==========================================
  Files          85       86       +1     
  Lines        7006     7043      +37     
==========================================
+ Hits         4992     5028      +36     
- Misses       2014     2015       +1     
```

| [Flag](https://app.codecov.io/gh/WordPress/performance/pull/1919/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress) | Coverage Δ | |
|---|---|---|
| [multisite](https://app.codecov.io/gh/WordPress/performance/pull/1919/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress) | `71.39% <97.95%> (+0.13%)` | :arrow_up: |
| [single](https://app.codecov.io/gh/WordPress/performance/pull/1919/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress) | `40.43% <0.00%> (-0.22%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/WordPress/performance/pull/1919?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WordPress).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-13T21:23:50Z
body: The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the `props-bot` label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

```
Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: felixarntz <flixos90@git.wordpress.org>
```

**To understand the WordPress project's expectations around crediting contributors, please [review the Contributor Attribution page in the Core Handbook](https://make.wordpress.org/core/handbook/best-practices/contributor-attribution-props/).**

author_association: NONE
user: login: westonruter
created_at: 2025-03-13T21:47:45Z
body: @felixarntz:

> * I don't fully understand what part of the conversation starting in [REST API logic in Optimization Detective can be encapsulated into a ~static~ class #1859 (comment)](https://github.com/WordPress/performance/issues/1859#issuecomment-2689790941) this is supposed to address. Can you clarify?

It addresses it by eliminating the need for global variables or singletons, where the objects are exposed as part of actions instead.

> * To confirm, the main purpose of these actions is to only allow _reading_ relevant data for the OD optimization process right? In other words, they are not meant to modify/perform any actual optimizations?

Correct, although they can be used to add new preload/preconnect links or append HTML to the `HEAD` and `BODY`, which could be relevant for optimization. 

> * I'm not sure about the `append_head_html` and `append_body_html` methods in their current implementation. Other than appending body comments, what are real use-cases for the two methods?

See usage in https://github.com/westonruter/od-admin-ui/pull/11. It is used to grab URL Metric data for the plugin to add to the admin bar. You can see it is used to add a stylesheet to the `HEAD` and a script module to the `BODY`.

> * I know they're documented to not validate and that one needs to be careful for that reason, but (partly dependent on your response to my previous point) we may want to come up with something better. For example, there could be an `append_body_comment` method that expects a string that is HTML escaped and wrapped in the HTML comment markup. This would be much safer than allowing arbitrary HTML.

See the previous point. Validation could be added later via fragments in the HTML Processor, but these methods are exposed now already for tag visitors to append arbitrary HTML to the `HEAD` and `BODY` already (e.g. Embed Optimizer uses these to inject styles and scripts as well).
author_association: MEMBER
user: login: felixarntz
created_at: 2025-03-13T22:18:23Z
body: @westonruter 
> It addresses it by eliminating the need for global variables or singletons, where the objects are exposed as part of actions instead.

Makes sense, thanks 👍 

> Correct, although they can be used to add new preload/preconnect links or append HTML to the `HEAD` and `BODY`, which could be relevant for optimization.

But that doesn't seem to be the purpose of this action? Otherwise I would expect the other plugins like Image Prioritizer to use it for example. I would advise caution against allowing to do things in an action just because they're possible when that's not the main intent of the action.

> See usage in [westonruter/od-admin-ui#11](https://github.com/westonruter/od-admin-ui/pull/11). It is used to grab URL Metric data for the plugin to add to the admin bar. You can see it is used to add a stylesheet to the `HEAD` and a script module to the `BODY`.
> 
> [...]
> 
> See the previous point. Validation could be added later via fragments in the HTML Processor, but these methods are exposed now already for tag visitors to append arbitrary HTML to the `HEAD` and `BODY` already (e.g. Embed Optimizer uses these to inject styles and scripts as well).

Why don't we instead add specific methods for adding script tags, style tags, and HTML comments instead? I think that would be a better solution. I don't like the idea of now allowing any HTML and later adding validation, as that would effectively become a breaking change. Since this is going to be a public API, I think we should go for more specific intents and have validation from the start.

Alternatively, we leave those methods out for now and focus only on the "read" / "access" aspect of this action. Then we can discuss in a follow up issue which write-actions would make sense via this action. This discussion would also go back to my above point - if it's possible via another action, why should it also be possible with this action?

author_association: MEMBER
user: login: westonruter
created_at: 2025-03-13T23:07:26Z
body: @felixarntz

> But that doesn't seem to be the purpose of this action? Otherwise I would expect the other plugins like Image Prioritizer to use it for example. I would advise caution against allowing to do things in an action just because they're possible when that's not the main intent of the action.

Image Prioritizer and Embed Optimizer could indeed use these actions, but they haven't been available. Allowing insertion of HTML once via the `od_finish_template_optimization` avoids the need for tag visitors to keep track of whether they inserted or not. They can use the tag visitor callbacks to get a "lay of the land" by looking at all of the tags, and then at the `od_finish_template_optimization` action they can finalize what they need to insert in the `HEAD` or the `BODY`.

This could, for example, allow tag visitors to better optimize stylesheets they insert into the document. Instead of Embed Optimizer inserting a separate `STYLE` for each embed to reserve space to reduce layout shifts, it could instead insert a single `STYLE` at `od_finish_template_optimization` which combines all the style rules in one stylesheet. For example, this would allow Embed Optimizer to better group styles by media query instead of having to output `@media (width <= 480px) {}` for each embed on the page. Currently for each embed it inserts a `STYLE` like:

```html
<style>
@media (width <= 480px) { #embed-optimizer-6040306707fb51ccaafa915c2da8f412 { min-height: 500px; } }
@media (480px < width <= 600px) { #embed-optimizer-6040306707fb51ccaafa915c2da8f412 { min-height: 500px; } }
@media (600px < width <= 782px) { #embed-optimizer-6040306707fb51ccaafa915c2da8f412 { min-height: 500px; } }
@media (782px < width) { #embed-optimizer-6040306707fb51ccaafa915c2da8f412 { min-height: 500px; } }
</style>
```

With the `od_finish_template_optimization` action, it could just print the `@media` at-rules once for each viewport group rather than duplicating them.

> Why don't we instead add specific methods for adding script tags, style tags, and HTML comments instead? I think that would be a better solution. I don't like the idea of now allowing any HTML and later adding validation, as that would effectively become a breaking change. Since this is going to be a public API, I think we should go for more specific intents and have validation from the start.

That could be done, but note that `::append_head_html()` and `::append_body_html()` are already exposed on `OD_HTML_Tag_Processor` and are used by tag visitors.

The current use cases for `::append_head_html()` are:

* Optimization Detective injecting the `LINK` HTML markup returned by `OD_Link_Collection::get_html()`.
* Image Prioritizer adding a `STYLE` tag via a tag visitor to add a style for lazy loaded background images. This could be done via the `od_finish_template_optimization` action instead, since otherwise the tag visitor needs to keep track with a `added_lazy_assets` class member variable for whether it inserted the script yet.
* Embed Optimizer adding a `STYLE` tag via a tag visitor to reduce layout shifts.
* Content Visibility adding a `STYLE` tag via a tag visitor for CV styles.

The current use cases for `::append_body_html()` are:

* Optimization Detective uses this to insert the `detect.js` script to the page.
* Embed Optimizer adding a `SCRIPT` to the end of the `BODY` when there is a lazy-loaded embed on the page. This could be done via the `od_finish_template_optimization` action instead, since otherwise the tag visitor needs to keep track with a `added_lazy_script` class member variable for whether it inserted the script yet.
* Image Prioritizer adding a `SCRIPT` tag via a tag visitor to lazy load background images. This could be done via the `od_finish_template_optimization` action instead, since otherwise the tag visitor needs to keep track with a `added_lazy_assets` class member variable for whether it inserted the script yet.
* Image Prioritizer adding a `SCRIPT` tag via a tag visitor to lazy load videos. This could be done via the `od_finish_template_optimization` action instead, since otherwise the tag visitor needs to keep track with a `added_lazy_script` class member variable for whether it inserted the script yet. Example: https://github.com/WordPress/performance/pull/1922.

So there would need to be new methods like:

* `::append_head_style()` to append an inline stylesheet
* `::append_head_link()` (or as `::append_head_links()` it could be passed an `OD_Link_Collection`)
* `::append_head_script()` (but also differentiate between inline vs non-inline?)
* `::append_body_script()` (ditto)
* `::append_body_link()` (not used yet, but useful for adding non-blocking external stylesheets)

I think it's useful to have methods that allow inserting arbitrary raw HTML in the `HEAD` and `BODY`, for example to insert a `META` tag in the `HEAD` or other use cases I haven't thought of. Adding all of these methods now I think would add too much scope to this PR. Perhaps the phpdoc for the methods can say that it is the responsibility of the extension to ensure well-formed HTML is supplied, and that in the future validation may be enforced with the HTML Processor.

> Alternatively, we leave those methods out for now and focus only on the "read" / "access" aspect of this action. Then we can discuss in a follow up issue which write-actions would make sense via this action. This discussion would also go back to my above point - if it's possible via another action, why should it also be possible with this action?

Because not all document modifications are directly related to a tag visitor. For example, with the OD Admin UI plugin, it inserts markup based on the current collection of URL Metrics, regardless of any tags in the page.
author_association: MEMBER
user: login: felixarntz
created_at: 2025-03-14T15:04:13Z
body: @westonruter Thanks for outlining some more use-cases for this. As far as I understand though, several of them are already possible via the tag visitors today - which goes back to my concern about also allowing it here.

But my main question is: Why are none of these use-cases being implemented here? That would provide a better foundation to discuss the implementation of the "write" methods. I'm still not convinced about the openness of these methods and whether a "context" object should have them.

At the end of the day, this needs more discussion with real examples to consider how these methods would be used. For example, based on your comment that the extension plugins could use these actions to append HTML once instead of doing it in the tag visitor, maybe this should rather go hand in hand with another change to _disallow_ doing so in the tag visitor.

Given this is milestoned for Monday's release, we have almost no time left. I think it would be best to downscope this PR to focus only on introducing the actions and the template context class for context awareness, but leave out any write methods. Since there is no usage of them in neither of the plugins, I don't see a reason these have to be added in _now_. We could discuss them in a follow-up issue with more time on our hands.
author_association: MEMBER
user: login: westonruter
created_at: 2025-03-14T15:39:23Z
body: @felixarntz:

> But my main question is: Why are none of these use-cases being implemented here? That would provide a better foundation to discuss the implementation of the "write" methods. I'm still not convinced about the openness of these methods and whether a "context" object should have them.

The reason is that since Image Prioritizer and Embed Optimizer depend on Optimization Detective. If they were part of this release, then all plugins would have to be updated at the same time. I'd rather get the new fundamental API out as part of this 1.0.0-beta3 release so that for the 1.0.0 final we can safely update Image Prioritizer and Embed Optimizer to make use of them. You can see there are use cases being implemented:

* https://github.com/WordPress/performance/pull/1922
* https://github.com/westonruter/od-admin-ui/pull/11
* https://github.com/WordPress/performance/pull/1923 (new, based on what I described above)

This seems better than introducing a new fundamental API in 1.0.0 without a beta first.

If the due date is the problem we can just move it back, as we discussed these releases being more ad hoc anyway.
author_association: MEMBER
user: login: felixarntz
created_at: 2025-03-14T16:03:24Z
body: @westonruter 
> The reason is that since Image Prioritizer and Embed Optimizer depend on Optimization Detective. If they were part of this release, then all plugins would have to be updated at the same time.

I don't think that's a good reason to not make the change at the same time. If they _need_ to be updated at the same time, the problem would be backward compatibility, not timing.

> This seems better than introducing a new fundamental API in 1.0.0 without a beta first.

The fundamental API would be introduced here - the hooks and the context object. Adding more methods to it would be an enhancement, and that is totally reasonable for post-1.0.0 IMO.

> If the due date is the problem we can just move it back, as we discussed these releases being more ad hoc anyway.

Per the above, I don't see why that's necessary. There are sufficient changes in the plugins already to do a release now as we had planned. If you think we are not ready for Optimization Detective 1.0.0 yet as the next release, we could also have another 1.0.0-beta4 release.
author_association: MEMBER
user: login: westonruter
created_at: 2025-03-14T16:11:20Z
body: @felixarntz 

> I don't think that's a good reason to not make the change at the same time. If they _need_ to be updated at the same time, the problem would be backward compatibility, not timing.

Right, it's not a back-compat problem. Image Prioritizer and Embed Optimizer will still work as expected without updating to make use of this new API. I was confused.

> The fundamental API would be introduced here - the hooks and the context object. Adding more methods to it would be an enhancement, and that is totally reasonable for post-1.0.0 IMO.

Still, since there is a clear need for these hooks I want to include them prior to the 1.0.0 release. It address something that always nagged at me about the API but I didn't have a solution for it until now.
author_association: MEMBER
user: login: felixarntz
created_at: 2025-03-14T16:14:43Z
body: @westonruter 
> Still, since there is a clear need for these hooks I want to include them prior to the 1.0.0 release. It address something that always nagged at me about the API but I didn't have a solution for it until now.

Then how about this? We could
* remove the write methods from this PR
* ship the remainder (basically actions and context object) in 1.0.0-beta3 next week
* focus on figuring out the write methods, plus their usage in the extensions for 1.0.0-beta4 (and the respective latest extension betas)
* then do a cycle of only bug fixes (if any) before 1.0.0 stable
author_association: MEMBER
user: login: westonruter
created_at: 2025-03-14T16:26:59Z
body: @felixarntz 

> Right, it's not a back-compat problem. Image Prioritizer and Embed Optimizer will still work as expected without updating to make use of this new API. I was confused.

Oh nevermind, I wasn't confused. The problem is that if someone were to update Image Prioritizer and Embed Optimizer which used the new hooks, but didn't update Optimization Detective. Then they would stop working.

> focus on figuring out the write methods, plus their usage in the extensions for 1.0.0-beta4 (and the respective latest extension betas)

Given the above, I think we'll need to be sure to release the hooks earlier so ensure users update OD so they are available.

I don't see the need to omit `append_head_html()` and `append_body_html()` from this beta, given these are already exposed on the context provided to tag visitors. If they are problematic then they'll need to be deprecated everywhere and not now in beta. It makes more sense to me to introduce tailored HTML insertion methods for a 1.1.0 release.
author_association: MEMBER
user: login: felixarntz
created_at: 2025-03-14T18:31:00Z
body: @westonruter 
> Oh nevermind, I wasn't confused. The problem is that if someone were to update Image Prioritizer and Embed Optimizer which used the new hooks, but didn't update Optimization Detective. Then they would stop working.

But then this is a BC break, which shouldn't happen in the first place. It should be possible to have (at least temporarily) conditionals in Image Prioritizer and Embed Optimizer so that they use the most suitable way that's available via the Optimization Detective version used.

> Given the above, I think we'll need to be sure to release the hooks earlier so ensure users update OD so they are available.

See above, I don't think this is a good idea. We can't rely on timing, there should be BC measures in place instead.

> I don't see the need to omit `append_head_html()` and `append_body_html()` from this beta, given these are already exposed on the context provided to tag visitors. If they are problematic then they'll need to be deprecated everywhere and not now in beta.

I disagree. It's not just about whether direct HTML modification is generally the right way, it's also about whether it should happen in this hook vs in the other hook. So it requires a more nuanced conversation where we consider the whole API, not just this new action hook.

> It makes more sense to me to introduce tailored HTML insertion methods for a 1.1.0 release.

That would work for me.
author_association: MEMBER
user: login: westonruter
created_at: 2025-03-14T18:33:43Z
body: @felixarntz 

> But then this is a BC break, which shouldn't happen in the first place. It should be possible to have (at least temporarily) conditionals in Image Prioritizer and Embed Optimizer so that they use the most suitable way that's available via the Optimization Detective version used.

This is why I'm not including the changes to Embed Optimizer and Image Prioritizer in this PR.

> I disagree. It's not just about whether direct HTML modification is generally the right way, it's also about whether it should happen in this hook vs in the other hook. So it requires a more nuanced conversation where we consider the whole API, not just this new action hook.

I think we've had a pretty nuanced conversation here!
author_association: MEMBER
user: login: felixarntz
created_at: 2025-03-14T18:42:42Z
body: @westonruter 
> > But then this is a BC break, which shouldn't happen in the first place. It should be possible to have (at least temporarily) conditionals in Image Prioritizer and Embed Optimizer so that they use the most suitable way that's available via the Optimization Detective version used.
> 
> This is why I'm not including the changes to Embed Optimizer and Image Prioritizer in this PR.

But that doesn't make it a non-breaking change then. If we need to break BC, there should always be at least 1-2 releases where both the old and new way are supported, with the old one triggering deprecation warnings, or (in this case possibly more likely) add a warning to update to the newer OD version. Alternatively, we would need to bump the minimum requirement for the OD version in each extension right away, which means there's no need for temporary workarounds, but the feature would be disabled (temporarily) if a site only updates the extensions but not OD.

> > I disagree. It's not just about whether direct HTML modification is generally the right way, it's also about whether it should happen in this hook vs in the other hook. So it requires a more nuanced conversation where we consider the whole API, not just this new action hook.
> 
> I think we've had a pretty nuanced conversation here!

True, but we haven't discussed yet for example why injecting head and/or body HTML should be possible both via the tag visitor context and the template context - to me that's confusing from an API perspective.

I think we should continue discussing that, but the more time-sensitive decision is what to do about this upcoming release.  I still don't understand what is the rush in including the write methods in this releases, rather than figuring this out "calmly" without the time pressure and implement what we decide on for a 1.0.0-beta4 (ideally also accompanied by the usage of it in the extension plugins).
author_association: MEMBER
user: login: westonruter
created_at: 2025-03-14T18:59:39Z
body: @felixarntz 

> But that doesn't make it a non-breaking change then. If we need to break BC, there should always be at least 1-2 releases where both the old and new way are supported, with the old one triggering deprecation warnings, or (in this case possibly more likely) add a warning to update to the newer OD version. Alternatively, we would need to bump the minimum requirement for the OD version in each extension right away, which means there's no need for temporary workarounds, but the feature would be disabled (temporarily) if a site only updates the extensions but not OD.

Yes, Image Prioritizer and Embed Optimizer can be updated to short-circuit at the `od_init` action and show an upgrade notice once they have been updated to use the new hooks.

> True, but we haven't discussed yet for example why injecting head and/or body HTML should be possible both via the tag visitor context and the template context - to me that's confusing from an API perspective.

There are three scenarios for inserting markup:

1. Before the document has processed at `od_start_template_optimization`. This is where a plugin like OD Admin UI can add styles or scripts that just require information about the `OD_URL_Metric_Group_Collection` and don't depend on the outcome of tag visitors being invoked (https://github.com/westonruter/od-admin-ui/pull/11). However, this could just as well be done at `od_finish_template_optimization` which is passed the same context. The more important use case for `od_start_template_optimization` is for tag visitors to do some pre-processing on the URL Metrics to gather up the information they'll need later when walking over the tags, e.g. https://github.com/WordPress/performance/pull/1921
2. During a tag visitor's callback, which before now has been the only option. This remains a fine option to insert an additional script in the page, for example when a tag visitor comes across a video that should be lazy-loaded, it can append a `SCRIPT` to the `BODY` and then set a flag to prevent adding a second script when another lazy-loaded video is encountered. Alternatively, the `od_finish_template_optimization` action can be used for this instead, although it essentially needs the same flag regardless: https://github.com/WordPress/performance/pull/1922.
3. After the document has been processed at `od_finish_template_optimization`. At this point, all tag visitors have run and extensions have an overall lay of the land for what is in the document. They can now compile a single stylesheet for insertion in the `HEAD` based on the information gathered by the tag visitors, for example, as seen in https://github.com/WordPress/performance/pull/1923.

> I think we should continue discussing that, but the more time-sensitive decision is what to do about this upcoming release. I still don't understand what is the rush in including the write methods in this releases, rather than figuring this out "calmly" without the time pressure and implement what we decide on for a 1.0.0-beta4 (ideally also accompanied by the usage of it in the extension plugins).

I was hoping to be able to avoid a beta4. If this is included in beta3, then extension plugins can make use of the new APIs at the time of 1.0.0. Even if we do have a beta4, then this being included now will allow us to have more time to make sure the it is able to be leveraged by extensions effectively, but I think this has been already demonstrated with the above PRs.
author_association: MEMBER
user: login: felixarntz
created_at: 2025-03-14T19:32:17Z
body: @westonruter 
> Yes, Image Prioritizer and Embed Optimizer can be updated to short-circuit at the od_init action and show an upgrade notice once they have been updated to use the new hooks.

👍 So I think this would mean we can implement the API enhancements and their usage at the same time, and they can be released at the same time.

> There are three scenarios for inserting markup:
>
> [...]

Thanks for outlining those. Particularly the 2nd one I find fuzzy. Some thoughts:
* There seems to be no benefit in doing it in one or the other way. Given the other scenarios can work with one of the new actions, this leads me to think that we should deprecate adding HTML on the tag visitor context then, to have a clear API on how this should be done.
* Since only adding a certain script or stylesheet once is a real concern, this is ideally something the Optimization Detective APIs allow to handle more intuitively. More specific methods for external scripts, inline scripts, external stylesheets, and inline stylesheets (as we discussed earlier) would come in handy for that. We could have these methods require identifiers and then they would behave more like "enqueue" rather than "print", so that each identifier will only be included once even if the method is called for it 20 times.

> I was hoping to be able to avoid a beta4.

Not sure why, the reasons you provide seem a bit like a moot point. I think it's totally fine to have a beta4. WordPress Core has had beta4s before too, and FWIW I don't think there needs to be any arbitrary limit on the number of betas a product has. Technically speaking, we've been still refining the API throughout the betas, which isn't something a regular beta would allow for anyway (beyond bug fixes). I'd rather have a 1.0.0 people can trust than introducing methods in the last 1.0.0 beta that may be deprecated again already in a 1.1.0 shortly after.

I think I'm aligned on allowing write methods on the template context, but per my above and previous feedback this requires more thought on how it should be implemented exactly (I'm still not a fan of allowing arbitrary HTML in those methods until there is a realistic use-case for requiring that) and what it means for the tag visitor context (we shouldn't allow to do the same thing on either).

Based on that, I still think the best decision would be to only include the read methods in this release, and continue discussing the above in a follow-up issue which we'll milestone for 1.0.0-beta4.
author_association: MEMBER
user: login: westonruter
created_at: 2025-03-14T20:07:35Z
body: @felixarntz 

> 👍 So I think this would mean we can implement the API enhancements and their usage at the same time, and they can be released at the same time.

Yes, but if someone updates Image Prioritizer or Embed Optimizer without Optimization Detective then the functionality won't work and they'll get an admin notice. That's fine I guess.

> * There seems to be no benefit in doing it in one or the other way. Given the other scenarios can work with one of the new actions, this leads me to think that we should deprecate adding HTML on the tag visitor context then, to have a clear API on how this should be done.

The intended design of the tag visitors is to be able to allow one to be written as simply as possible, just by registering a single tag visitor callback. This is possible if there is a way to write to the document from the tag visitor. For example:

```php
add_action( 'od_register_tag_visitors', static function ( OD_Tag_Visitor_Registry $registry ) {
	$registry->register( 'video-lazy-load', static function ( OD_Tag_Visitor_Context $context ) {
		static $added_lazy_load_script = false;

		$processor = $context->processor;
		if ( $processor->get_tag() !== 'VIDEO' ) {
			return;
		}
		$context->track_tag();

		// Abort if URL Metrics aren't collected for mobile and desktop yet.
		if (
			$context->url_metric_group_collection->get_first_group()->count() === 0
			&&
			$context->url_metric_group_collection->get_last_group()->count() === 0
		) {
			return;
		}

		if ( $context->url_metric_group_collection->get_element_max_intersection_ratio( $processor->get_xpath() ) < PHP_FLOAT_EPSILON ) {
			$processor->set_attribute( 'preload', 'none' );
			if ( ! $added_lazy_load_script ) {
				$processor->append_body_html( wp_get_script_tag( array( 'src' => plugins_url( 'lazy-load.js', __FILE__ ) ) ) );
				$added_lazy_load_script = true;
			}
		}
	} );
} );
```

If writing to the document isn't possible from the tag visitor, then you have to add higher level abstractions, like a class or else a global variable, and then to also add a `od_finish_template_optimization` action handler to do the insertion. I want it to be possible to keep the logic all self-contained in a single tag visitor, but to also have the option for more refined implementations to use a higher level abstraction (e.g. https://github.com/WordPress/performance/pull/1923).

> Not sure why, the reasons you provide seem a bit like a moot point. I think it's totally fine to have a beta4. WordPress Core has had beta4s before too, and FWIW I don't think there needs to be any arbitrary limit on the number of betas a product has. Technically speaking, we've been still refining the API throughout the betas, which isn't something a regular beta would allow for anyway (beyond bug fixes). I'd rather have a 1.0.0 people can trust than introducing methods in the last 1.0.0 beta that may be deprecated again already in a 1.1.0 shortly after.

This goes back to the chat during the previous release, where there was confusion about having betas. So I had intended to get away from beta releases, but since this next release is a beta which I hadn't intended either, I guess my goals here are awash. We can add more betas. But perhaps the next beta can be in two weeks rather than waiting a whole month, as we discussed.

> Based on that, I still think the best decision would be to only include the read methods in this release, and continue discussing the above in a follow-up issue which we'll milestone for 1.0.0-beta4.

I'd rather not but I will if you insist.
author_association: MEMBER
user: login: felixarntz
created_at: 2025-03-14T20:41:52Z
body: @westonruter 
> If writing to the document isn't possible from the tag visitor, then you have to add higher level abstractions, like a class or else a global variable, and then to also add a `od_finish_template_optimization` action handler to do the insertion. I want it to be possible to keep the logic all self-contained in a single tag visitor, but to also have the option for more refined implementations to use a higher level abstraction (e.g. https://github.com/WordPress/performance/pull/1923).

Thanks, it makes sense to be able to do it in both ways then. Still, I think there may be a cleaner implementation for that than to expose the same methods on multiple API classes. Maybe we can have those shared write methods on one of the classes that is used across both actions, or on a new class that is then used across both actions, e.g. as a child property of the respective main context class, like `$tag_visitor_context->html_writer->append_*()` and `$template_context->html_writer->append_*()` (please discard the naming, this is just to visualize).

My other remaining concern is about allowing to inject raw HTML. This is of course already available via the tag visitor today (which I'm not sure we should allow either), but since this PR would be adding it to another class, I think we should revisit before committing to it for 1.0.0.

> We can add more betas. But perhaps the next beta can be in two weeks rather than waiting a whole month, as we discussed.

Yeah, I would prefer to continue the above discussion over the next week and for now merge this without the append methods for beta3. And definitely, no need to wait a month for the following beta4 based on our new approach.
author_association: MEMBER
user: login: westonruter
created_at: 2025-03-17T00:46:44Z
body: @felixarntz 

> I would prefer to continue the above discussion over the next week and for now merge this without the append methods for beta3

See https://github.com/WordPress/performance/issues/1931 for the follow-up.
author_association: MEMBER