Repository: Automattic/jetpack
Pull request #42374: Add Speculation Rules toggle to Cornerstone pages
Author: login: donnchawp
Labels: name: Admin Page
description: React-powered dashboard under the Jetpack menu
name: [Plugin] Boost
description: A feature to speed up the site and improve performance.
name: [Tests] Includes Tests
name: I don't care about code coverage for this PR
description: Use this label to ignore the check for insufficient code coveage.
name: [Boost Feature] Speculation Rules
Created: 2025-03-11T17:17:02Z | Merged: 2025-03-26T12:57:29Z

<!--- Provide a general summary of your changes in the Title above -->
<!-- Would you like this feature to be tested by Beta testers?
Please add testing instructions to projects/plugins/jetpack/to-test.md in a new commit as part of your PR. -->
<!-- a12s: If you have an expected version that you're aiming for the PR to add, please use the Milestone field to communicate it. If you leave it blank, that indicates there isn't a preference. -->

Add a toggle to the Cornerstone Pages module that will add speculation rules to prerender those URLs. It's only active if WordPress 6.8 or higher is installed as it will use APIs to be introduced in that version.

ref: [#42322](https://github.com/Automattic/jetpack/issues/42322)

## Proposed changes:
<!--- Explain what functional changes your PR includes -->
* Add wpVersion JetpackBoost config.
* Add Prerender component to the Cornerstone Pages feature.
* The Prerender component has a toggle, and a tooltip warning of potential problems with using prerender.
* Register the prerender_cornerstone_pages setting

### Other information:

- [ ] Have you written new tests for your changes, if applicable?
- [ ] Have you checked the E2E test CI results, and verified that your changes do not break them?
- [ ] Have you tested your changes on WordPress.com, if applicable (if so, you'll see a generated comment below with a script to run)?

## Jetpack product discussion
<!-- If you're an Automattician, include a shortlink to the p2 discussion with Jetpack Product here. -->
<!-- Make sure any changes to existing products have been discussed and agreed upon -->
pc9hqz-3ny-p2

## Does this pull request change what data or activity we track or use?
<!--- If so, please add the "[Status] Needs Privacy Updates" label and explain what changes there are. -->
<!--- Check existing Jetpack support documents for a preview of the information we need. -->

## Testing instructions:
<!-- If you were reviewing this PR, how would you like the instructions to be presented? -->
<!-- Please include detailed testing steps, explaining how to test your change. -->
<!-- Bear in mind that context you working on is not obvious for everyone.  -->
<!-- Adding "simple" configuration steps will help reviewers to get to your PR as quickly as possible. -->
<!-- "Before / After" screenshots can also be very helpful when the change is visual. -->

* Apply PR
* `jetpack build plugins/boost`
* Reload the settings page and confirm there's no "prerender" toggle on the Cornerstone Pages feature.
* Make [this PHP function return true](https://github.com/Automattic/jetpack/blob/fc61db35b45459fdc7410bb44b7b42dc3cb3d27b/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php#L56) and reload the settings page.
* The toggle should be visible and can be enabled and disabled. A tooltip is on some of the text in the description.
* Run PHP tests with `composer phpunit` and they should pass.
* Test the settings page with premium and free Cornerstone Pages as the CTA will appear below the toggle.


Diff:
diff --git a/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/cornerstone-pages.tsx b/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/cornerstone-pages.tsx
index 3436136c9ca8..d8e9c63a3670 100644
--- a/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/cornerstone-pages.tsx
+++ b/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/cornerstone-pages.tsx
@@ -1,5 +1,5 @@
 import { __, _n, sprintf } from '@wordpress/i18n';
-import Meta from './meta/meta';
+import Meta, { CornerstonePagesUpgradeCTA } from './meta/meta';
 import { Panel, PanelBody, PanelRow } from '@wordpress/components';
 import Upgraded from '$features/ui/upgraded/upgraded';
 import styles from './cornerstone-pages.module.scss';
@@ -7,11 +7,20 @@ import { usePremiumFeatures } from '$lib/stores/premium-features';
 import { recordBoostEvent } from '$lib/utils/analytics';
 import { useCornerstonePages } from './lib/stores/cornerstone-pages';
 import Pill from '$features/ui/pill/pill';
+import Prerender from './prerender/prerender';
+import { z } from 'zod';
+import { useDataSync } from '@automattic/jetpack-react-data-sync-client';
 
 const CornerstonePages = () => {
 	const premiumFeatures = usePremiumFeatures();
 	const isPremium = premiumFeatures.includes( 'cornerstone-10-pages' );
 
+	const [ isSpeculationRulesApiSupported ] = useDataSync(
+		'jetpack_boost_ds',
+		'speculation_rules_api_support',
+		z.boolean().catch( false )
+	);
+
 	return (
 		<div className={ styles.wrapper }>
 			<Panel className={ styles.panel }>
@@ -37,6 +46,12 @@ const CornerstonePages = () => {
 					<PanelRow>
 						<Meta />
 					</PanelRow>
+					{ isSpeculationRulesApiSupported.data && (
+						<PanelRow>
+							<Prerender />
+						</PanelRow>
+					) }
+					<CornerstonePagesUpgradeCTA />
 				</PanelBody>
 			</Panel>
 		</div>
diff --git a/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/meta/meta.tsx b/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/meta/meta.tsx
index 93e6f18c8f24..b550acadbf8d 100644
--- a/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/meta/meta.tsx
+++ b/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/meta/meta.tsx
@@ -143,6 +143,29 @@ type ListProps = {
 	inputRows?: number;
 };
 
+export const CornerstonePagesUpgradeCTA = () => {
+	const cornerstonePagesProperties = useCornerstonePagesProperties();
+	const premiumFeatures = usePremiumFeatures();
+	const isPremium = premiumFeatures.includes( 'cornerstone-10-pages' );
+
+	if ( isPremium || ! cornerstonePagesProperties ) {
+		return null;
+	}
+
+	return (
+		<div className={ styles.wrapper }>
+			<InterstitialModalCTA
+				identifier="cornerstone-10-pages"
+				description={ sprintf(
+					/* translators: %d is the number of cornerstone pages. */
+					__( 'Premium users can add up to %d cornerstone pages.', 'jetpack-boost' ),
+					cornerstonePagesProperties.max_pages_premium
+				) }
+			/>
+		</div>
+	);
+};
+
 const List: React.FC< ListProps > = ( {
 	items,
 	setItems,
@@ -154,9 +177,6 @@ const List: React.FC< ListProps > = ( {
 	const [ inputValue, setInputValue ] = useState( items );
 	const [ inputInvalid, setInputInvalid ] = useState( false );
 	const [ validationError, setValidationError ] = useState< Error | null >( null );
-	const premiumFeatures = usePremiumFeatures();
-	const isPremium = premiumFeatures.includes( 'cornerstone-10-pages' );
-	const cornerstonePagesProperties = useCornerstonePagesProperties();
 	const validateInputValue = ( value: string ) => {
 		setInputValue( value );
 		try {
@@ -258,19 +278,6 @@ const List: React.FC< ListProps > = ( {
 					{ __( 'Load default pages', 'jetpack-boost' ) }
 				</Button>
 			</div>
-			{ ! isPremium && cornerstonePagesProperties && (
-				<div className={ styles.wrapper }>
-					<InterstitialModalCTA
-						eventName="cornerstone_pages_upgrade_link_clicked"
-						identifier="cornerstone-10-pages"
-						description={ sprintf(
-							/* translators: %d is the number of cornerstone pages. */
-							__( 'Premium users can add up to %d cornerstone pages.', 'jetpack-boost' ),
-							cornerstonePagesProperties.max_pages_premium
-						) }
-					/>
-				</div>
-			) }
 		</>
 	);
 };
diff --git a/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/prerender/prerender.module.scss b/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/prerender/prerender.module.scss
new file mode 100644
index 000000000000..bb35d4f57772
--- /dev/null
+++ b/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/prerender/prerender.module.scss
@@ -0,0 +1,43 @@
+.wrapper {
+	width: 100%;
+	margin-top: 10px;
+	border-top: 1px solid var(--gray-10);
+	padding-top: 20px;
+}
+
+.title {
+	display: flex;
+}
+
+.toggle-control {
+	margin-left: auto;
+	margin-bottom: 0 !important;
+}
+
+.description {
+	color: var(--gray-50);
+	font-size: 14px;
+	line-height: 1.6;
+	margin-top: 10px;
+}
+
+.warning-wrapper {
+	position: relative;
+	display: inline;
+
+	.warning-button {
+		position: relative;
+		z-index: 10;
+	}
+
+	.warning-tooltip-wrapper {
+		position: absolute;
+		top: 0;
+		left: -20px;
+		z-index: 9;
+
+		.warning-tooltip :global(.icon-tooltip-container .components-popover__content) {
+			width: 400px;
+		}
+	}
+}
\ No newline at end of file
diff --git a/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/prerender/prerender.tsx b/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/prerender/prerender.tsx
new file mode 100644
index 000000000000..45c9bd9664f5
--- /dev/null
+++ b/projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/prerender/prerender.tsx
@@ -0,0 +1,104 @@
+import { __ } from '@wordpress/i18n';
+import { ToggleControl } from '@wordpress/components';
+import styles from './prerender.module.scss';
+import { recordBoostEvent } from '$lib/utils/analytics';
+import { useMutationNotice } from '$features/ui';
+import { createInterpolateElement, useState } from '@wordpress/element';
+import { getRedirectUrl, IconTooltip } from '@automattic/jetpack-components';
+import { useDataSync } from '@automattic/jetpack-react-data-sync-client';
+import { z } from 'zod';
+const unsafeSpeculationRulesLink = getRedirectUrl( 'jetpack-boost-unsafe-speculation-rules' );
+
+const Prerender = () => {
+	const [ prerenderedEnabled, setPrerenderedEnabled ] = useDataSync(
+		'jetpack_boost_ds',
+		'prerender_cornerstone_pages',
+		z.boolean().catch( false )
+	);
+
+	const enabledMessage = __( 'Prerender enabled.', 'jetpack-boost' );
+	const disabledMessage = __( 'Prerender disabled.', 'jetpack-boost' );
+	useMutationNotice( 'prerender-cornerstone-pages', setPrerenderedEnabled, {
+		successMessage: prerenderedEnabled.data ? enabledMessage : disabledMessage,
+	} );
+
+	const handleToggle = ( value: boolean ) => {
+		setPrerenderedEnabled.mutate( value );
+		recordBoostEvent( 'cornerstone_pages_prerender_toggle', { enabled: Number( value ) } );
+	};
+
+	return (
+		<div className={ styles.wrapper }>
+			<div className={ styles.title }>
+				<h4>{ __( 'Prerender Cornerstone Pages', 'jetpack-boost' ) }</h4>
+				<ToggleControl
+					className={ styles[ 'toggle-control' ] }
+					checked={ prerenderedEnabled.data }
+					onChange={ handleToggle }
+					__nextHasNoMarginBottom={ true }
+				/>
+			</div>
+			<div className={ styles.description }>
+				{ createInterpolateElement(
+					__(
+						'Prerender these pages to improve their loading performance, but <help>be mindful</help> of potential drawbacks.',
+						'jetpack-boost'
+					),
+					{
+						help: <PrerenderWarningMessage />,
+						// eslint-disable-next-line jsx-a11y/anchor-has-content
+						link: <a href={ unsafeSpeculationRulesLink } target="_blank" rel="noreferrer" />,
+					}
+				) }
+			</div>
+		</div>
+	);
+};
+
+type BypassPatternsExampleProps = {
+	children?: React.ReactNode;
+};
+
+const PrerenderWarningMessage = ( { children }: BypassPatternsExampleProps ) => {
+	const [ show, setShow ] = useState( false );
+
+	return (
+		<div className={ styles[ 'warning-wrapper' ] }>
+			{ /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ }
+			<a
+				href="#"
+				className={ styles[ 'warning-button' ] }
+				onClick={ e => {
+					recordBoostEvent( 'prerender_warning_message_clicked', {} );
+					e.preventDefault();
+					setShow( ! show );
+				} }
+			>
+				{ children }
+			</a>
+			<div className={ styles[ 'warning-tooltip-wrapper' ] }>
+				<IconTooltip
+					placement="bottom-end"
+					popoverAnchorStyle="wrapper"
+					forceShow={ show }
+					offset={ -10 }
+					className={ styles[ 'warning-tooltip' ] }
+				>
+					<strong>{ __( 'Warning', 'jetpack-boost' ) }</strong>
+					<br />
+					{ __(
+						'Prerendering pages can be unsafe if the pages are not properly configured. JavaScript will execute on the prerendered page. This can lead to unexpected behavior if not handled correctly.',
+						'jetpack-boost'
+					) }
+					<br />
+					{ createInterpolateElement( __( '<link>Learn more</link>', 'jetpack-boost' ), {
+						// eslint-disable-next-line jsx-a11y/anchor-has-content
+						link: <a href={ unsafeSpeculationRulesLink } target="_blank" rel="noreferrer" />,
+					} ) }
+				</IconTooltip>
+			</div>
+		</div>
+	);
+};
+
+export default Prerender;
diff --git a/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Pages.php b/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Pages.php
index dd5bf351ba2f..0ace733cd280 100644
--- a/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Pages.php
+++ b/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Pages.php
@@ -31,6 +31,8 @@ private function register_ds_stores() {
 		$schema = Schema::as_array( Schema::as_string() )->fallback( array() );
 		jetpack_boost_register_option( 'cornerstone_pages_list', $schema, new Cornerstone_Pages_Entry( 'cornerstone_pages_list' ) );
 		jetpack_boost_register_readonly_option( 'cornerstone_pages_properties', array( $this, 'get_properties' ) );
+		jetpack_boost_register_option( 'prerender_cornerstone_pages', Schema::as_boolean()->fallback( false ) );
+		jetpack_boost_register_readonly_option( 'speculation_rules_api_support', array( Cornerstone_Utils::class, 'get_speculation_rules_api_support_status' ) );
 	}
 
 	public function remove_ccss_front_page_provider( $providers ) {
diff --git a/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php b/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php
index 65d24ea9d275..4a600c688fc8 100644
--- a/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php
+++ b/projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php
@@ -47,4 +47,13 @@ public static function is_cornerstone_page_by_url( $url ) {
 	public static function is_cornerstone_page( $post_id ) {
 		return self::is_cornerstone_page_by_url( get_permalink( $post_id ) );
 	}
+
+	/**
+	 * Is WordPress 6.8.0+ Speculation Rules API supported?
+	 *
+	 * @return bool True if the Speculation Rules API is supported, false otherwise.
+	 */
+	public static function get_speculation_rules_api_support_status() {
+		return version_compare( get_bloginfo( 'version' ), '6.8-beta3', '>=' );
+	}
 }
diff --git a/projects/plugins/boost/changelog/add-boost-cornerstone-pages-speculation-rules-toggle b/projects/plugins/boost/changelog/add-boost-cornerstone-pages-speculation-rules-toggle
new file mode 100644
index 000000000000..b8d1ed57ee51
--- /dev/null
+++ b/projects/plugins/boost/changelog/add-boost-cornerstone-pages-speculation-rules-toggle
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Cornerstone Pages: add speculation rules toggle to prerender those URLs
diff --git a/projects/plugins/boost/tests/php/admin/Config_Test.php b/projects/plugins/boost/tests/php/admin/Config_Test.php
new file mode 100644
index 000000000000..6a2af507844b
--- /dev/null
+++ b/projects/plugins/boost/tests/php/admin/Config_Test.php
@@ -0,0 +1,252 @@
+<?php
+
+namespace Automattic\Jetpack_Boost\Tests\Admin;
+
+use Automattic\Jetpack\Status;
+use Automattic\Jetpack\Status\Host;
+use Automattic\Jetpack_Boost\Admin\Config;
+use Brain\Monkey\Functions;
+use Mockery;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ */
+class Config_Test extends TestCase {
+	protected function setUp(): void {
+		parent::setUp();
+		\Brain\Monkey\setUp();
+		Functions\when( 'get_current_blog_id' )->justReturn( 1 );
+
+		// Define required constants if they're not already defined
+		if ( ! defined( 'JETPACK_BOOST_VERSION' ) ) {
+			define( 'JETPACK_BOOST_VERSION', '1.0.0' );
+		}
+		if ( ! defined( 'JETPACK_BOOST_PLUGINS_DIR_URL' ) ) {
+			define( 'JETPACK_BOOST_PLUGINS_DIR_URL', 'http://example.com/wp-content/plugins/jetpack-boost/' );
+		}
+		if ( ! defined( 'JETPACK_BOOST_PATH' ) ) {
+			define( 'JETPACK_BOOST_PATH', '/path/to/jetpack-boost/' );
+		}
+		if ( ! defined( 'JETPACK_BOOST_REST_NAMESPACE' ) ) {
+			define( 'JETPACK_BOOST_REST_NAMESPACE', 'jetpack-boost/v1' );
+		}
+		if ( ! defined( 'JETPACK_BOOST_REST_PREFIX' ) ) {
+			define( 'JETPACK_BOOST_REST_PREFIX', 'jetpack/v4' );
+		}
+	}
+
+	protected function tearDown(): void {
+		Mockery::close();
+		\Brain\Monkey\tearDown();
+		parent::tearDown();
+	}
+
+	protected function setupCommonMocks() {
+		// Mock WordPress functions
+		Functions\when( 'get_bloginfo' )
+			->alias(
+				function ( $show ) {
+					if ( $show === 'version' ) {
+							return '6.0.0';
+					}
+					return '';
+				}
+			);
+		Functions\when( 'plugins_url' )->justReturn( 'http://example.com/wp-content/plugins/jetpack-boost/app/assets/dist' );
+		Functions\when( 'wp_image_editor_supports' )
+			->alias(
+				function ( $args ) {
+					if ( isset( $args['methods'] ) && in_array( 'resize', $args['methods'], true ) ) {
+							return true;
+					}
+					return false;
+				}
+			);
+		Functions\when( 'get_home_url' )->justReturn( 'http://example.com' );
+		Functions\when( 'get_post_types' )->justReturn( array() );
+		Functions\when( 'is_post_type_viewable' )->justReturn( true );
+		Functions\when( 'wp_list_pluck' )->justReturn( array() );
+		Functions\when( 'wp_get_environment_type' )->justReturn( 'production' );
+		Functions\when( 'get_option' )->alias(
+			function ( $option ) {
+				if ( 'blog_public' === $option ) {
+						return '1';
+				}
+				return null;
+			}
+		);
+
+		// Mock Status class
+		$status = Mockery::mock( 'overload:' . Status::class );
+		$status->shouldReceive( 'get_site_suffix' )->andReturn( 'example.com' );
+		$status->shouldReceive( 'is_offline_mode' )->andReturn( false );
+		$status->shouldReceive( 'is_private_site' )->andReturn( false );
+	}
+
+	public function test_constants() {
+		$this->setupCommonMocks();
+
+		// Set up filter expectations in correct order
+		Functions\expect( 'apply_filters' )
+			->once()
+			->with( 'jetpack_boost_asset_internal_path', 'app/assets/dist/' )
+			->andReturn( 'app/assets/dist/' );
+
+		// This filter needs to return the constants array
+		Functions\expect( 'apply_filters' )
+			->once()
+			->with( 'jetpack_boost_js_constants', Mockery::type( 'array' ) )
+			->andReturnUsing(
+				function ( $hook, $constants ) {
+					$this->assertIsArray( $constants ); // Verify we're getting an array
+					return $constants; // Return the array unchanged
+				}
+			);
+
+		// Mock Host class for this specific test
+		$host = Mockery::mock( 'overload:' . Host::class );
+		$host->shouldReceive( 'is_woa_site' )->andReturn( false );
+		$host->shouldReceive( 'is_atomic_platform' )->andReturn( true );
+
+		$config = new Config();
+		$result = $config->constants();
+
+		$this->assertIsArray( $result );
+		$this->assertArrayHasKey( 'version', $result );
+		$this->assertTrue( $result['canResizeImages'] );
+		$this->assertEquals( 'http://example.com', $result['site']['url'] );
+		$this->assertEquals( 'atomic', $result['site']['host'] );
+		$this->assertTrue( $result['site']['online'] );
+	}
+
+	public function test_constants_private_site() {
+		// First, set up basic mocks
+		Functions\when( 'get_bloginfo' )
+			->alias(
+				function ( $show ) {
+					if ( $show === 'version' ) {
+							return '6.0.0';
+					}
+					return '';
+				}
+			);
+		Functions\when( 'plugins_url' )->justReturn( 'http://example.com/wp-content/plugins/jetpack-boost/app/assets/dist' );
+		Functions\when( 'wp_image_editor_supports' )
+			->alias(
+				function ( $args ) {
+					if ( isset( $args['methods'] ) && in_array( 'resize', $args['methods'], true ) ) {
+							return true;
+					}
+					return false;
+				}
+			);
+		Functions\when( 'get_home_url' )->justReturn( 'http://example.com' );
+		Functions\when( 'get_post_types' )->justReturn( array() );
+		Functions\when( 'is_post_type_viewable' )->justReturn( true );
+		Functions\when( 'wp_list_pluck' )->justReturn( array() );
+		Functions\when( 'wp_get_environment_type' )->justReturn( 'production' );
+
+		// Set up filter expectations
+		Functions\expect( 'apply_filters' )
+			->once()
+			->with( 'jetpack_boost_asset_internal_path', 'app/assets/dist/' )
+			->andReturn( 'app/assets/dist/' );
+
+		Functions\expect( 'apply_filters' )
+			->once()
+			->with( 'jetpack_boost_js_constants', Mockery::type( 'array' ) )
+			->andReturnUsing(
+				function ( $hook, $constants ) {
+					return $constants;
+				}
+			);
+
+		// Create a fresh Status mock for private site
+		$status = Mockery::mock( 'overload:' . Status::class );
+		$status->shouldReceive( 'get_site_suffix' )->andReturn( 'example.com' );
+		$status->shouldReceive( 'is_offline_mode' )->andReturn( false );
+		$status->shouldReceive( 'is_private_site' )->andReturn( true );
+
+		// Mock Host class
+		$host = Mockery::mock( 'overload:' . Host::class );
+		$host->shouldReceive( 'is_woa_site' )->andReturn( false );
+		$host->shouldReceive( 'is_atomic_platform' )->andReturn( true );
+
+		// Create new instance and test
+		$config = new Config();
+		$result = $config->constants();
+
+		$this->assertIsArray( $result );
+		$this->assertArrayHasKey( 'site', $result );
+		$this->assertArrayHasKey( 'online', $result['site'] );
+		$this->assertFalse(
+			$result['site']['online'],
+			'Site should be offline when Status::is_private_site() returns true'
+		);
+	}
+
+	public function test_get_hosting_provider_woa() {
+		$this->setupCommonMocks();
+
+		// Mock Host class specifically for WOA test
+		$host = Mockery::mock( 'overload:' . Host::class );
+		$host->shouldReceive( 'is_woa_site' )->once()->andReturn( true );
+		$host->shouldReceive( 'is_atomic_platform' )->never();
+
+		$result = Config::get_hosting_provider();
+		$this->assertEquals( 'woa', $result );
+	}
+
+	public function test_get_hosting_provider_other() {
+		$this->setupCommonMocks();
+
+		// Mock Host class specifically for 'other' test
+		$host = Mockery::mock( 'overload:' . Host::class );
+		$host->shouldReceive( 'is_woa_site' )->once()->andReturn( false );
+		$host->shouldReceive( 'is_atomic_platform' )->once()->andReturn( false );
+
+		$result = Config::get_hosting_provider();
+		$this->assertEquals( 'other', $result );
+	}
+
+	public function test_get_custom_post_types() {
+		$this->setupCommonMocks();
+
+		$mock_post_types = array(
+			'book'       => (object) array(
+				'name'  => 'book',
+				'label' => 'Books',
+			),
+			'movie'      => (object) array(
+				'name'  => 'movie',
+				'label' => 'Movies',
+			),
+			'attachment' => (object) array(
+				'name'  => 'attachment',
+				'label' => 'Attachments',
+			),
+		);
+
+		Functions\when( 'get_post_types' )->justReturn( $mock_post_types );
+		Functions\when( 'is_post_type_viewable' )->justReturn( true );
+		Functions\when( 'wp_list_pluck' )->justReturn(
+			array(
+				'book'  => 'Books',
+				'movie' => 'Movies',
+			)
+		);
+
+		$reflection = new \ReflectionClass( Config::class );
+		$method     = $reflection->getMethod( 'get_custom_post_types' );
+		$method->setAccessible( true );
+
+		$result = $method->invokeArgs( new Config(), array() );
+
+		$this->assertIsArray( $result );
+		$this->assertArrayNotHasKey( 'attachment', $result );
+		$this->assertArrayHasKey( 'book', $result );
+		$this->assertArrayHasKey( 'movie', $result );
+	}
+}
diff --git a/projects/plugins/boost/tests/php/lib/cornerstone/Cornerstone_Pages_Test.php b/projects/plugins/boost/tests/php/lib/cornerstone/Cornerstone_Pages_Test.php
new file mode 100644
index 000000000000..1c8ba758b08a
--- /dev/null
+++ b/projects/plugins/boost/tests/php/lib/cornerstone/Cornerstone_Pages_Test.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Automattic\Jetpack_Boost\Tests\Lib\Cornerstone;
+
+use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Pages;
+use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
+use Automattic\Jetpack_Boost\Tests\Lib\Mocks\Mock_Premium_Features;
+use Brain\Monkey\Functions;
+use Mockery;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ */
+class Cornerstone_Pages_Test extends TestCase {
+	private $cornerstone_pages;
+
+	public function setUp(): void {
+		parent::setUp();
+
+		// Set up Brain Monkey
+		\Brain\Monkey\setUp();
+
+		// Load the mock Premium_Features class
+		require_once __DIR__ . '/../mocks/Mock_Premium_Features.php';
+		$this->cornerstone_pages = new Cornerstone_Pages();
+	}
+
+	public function tearDown(): void {
+		Mockery::close();
+		\Brain\Monkey\tearDown();
+		parent::tearDown();
+	}
+
+	public function test_setup_registers_hooks() {
+		// Mock WordPress functions
+		Functions\expect( 'jetpack_boost_register_option' )->twice();
+		Functions\expect( 'jetpack_boost_register_readonly_option' )->twice();
+
+		// Run setup
+		$this->cornerstone_pages->setup();
+
+		// Verify filters and actions were added
+		$this->assertEquals( 10, has_filter( 'jetpack_boost_critical_css_providers', array( $this->cornerstone_pages, 'remove_ccss_front_page_provider' ) ) );
+		$this->assertEquals( 10, has_filter( 'display_post_states', array( $this->cornerstone_pages, 'add_display_post_states' ) ) );
+		$this->assertSame( 0, has_action( 'init', array( $this->cornerstone_pages, 'set_default_pages' ) ) );
+	}
+
+	public function test_remove_ccss_front_page_provider() {
+		$providers = array(
+			array( 'key' => 'core_front_page' ),
+			array( 'key' => 'other_provider' ),
+		);
+
+		$filtered = $this->cornerstone_pages->remove_ccss_front_page_provider( $providers );
+
+		$this->assertCount( 1, $filtered );
+		$this->assertEquals( 'other_provider', $filtered[0]['key'] );
+	}
+
+	public function test_get_properties_free_tier() {
+		Mock_Premium_Features::set_mock_return( false );
+
+		Functions\when( 'home_url' )
+			->justReturn( 'https://example.com' );
+
+		Functions\when( 'jetpack_boost_ds_get' )
+			->justReturn( array() );
+
+		// Mock WooCommerce function
+		Functions\when( 'wc_get_page_id' )->justReturn( false );
+
+		// Mock get_posts for Yoast cornerstone pages
+		Functions\when( 'get_posts' )->justReturn( array() );
+
+		$properties = $this->cornerstone_pages->get_properties();
+
+		$this->assertSame( 1, $properties['max_pages'] );
+		$this->assertEquals( 10, $properties['max_pages_premium'] );
+		$this->assertIsArray( $properties['default_pages'] );
+	}
+
+	public function test_get_properties_premium_tier() {
+		Mock_Premium_Features::set_mock_return( true );
+
+		// Mock jetpack_boost_ds_get to return features array
+		Functions\when( 'jetpack_boost_ds_get' )
+			->justReturn(
+				array(
+					'features' => array(
+						'cornerstone_ten_pages' => true,
+					),
+				)
+			);
+
+		// Mock other required functions
+		Functions\when( 'home_url' )
+			->justReturn( 'https://example.com' );
+
+		Functions\when( 'wc_get_page_id' )
+			->justReturn( false );
+
+		Functions\when( 'get_posts' )
+			->justReturn( array() );
+
+		// Create a new instance after setting up all mocks
+		$this->cornerstone_pages = new Cornerstone_Pages();
+
+		// Get properties
+		$properties = $this->cornerstone_pages->get_properties();
+
+		$this->assertEquals( 10, $properties['max_pages'], 'Premium tier should have 10 max pages' );
+		$this->assertEquals( 10, $properties['max_pages_premium'] );
+		$this->assertIsArray( $properties['default_pages'] );
+		$this->assertContains( 'https://example.com', $properties['default_pages'] );
+	}
+
+	public function test_add_display_post_states() {
+		// Mock Cornerstone_Utils static method
+		$cornerstone_utils = Mockery::mock( 'alias:' . Cornerstone_Utils::class );
+		$cornerstone_utils->shouldReceive( 'is_cornerstone_page' )
+			->with( 1 )
+			->once()
+			->andReturn( true );
+
+		Functions\expect( '__' )
+			->once()
+			->with( 'Cornerstone Page', 'jetpack-boost' )
+			->andReturn( 'Cornerstone Page' );
+
+		$post        = (object) array( 'ID' => 1 );
+		$post_states = array();
+
+		// Add a post. The is_cornerstone_page method and "__" function will run once.
+		$updated_states = $this->cornerstone_pages->add_display_post_states( $post_states, $post );
+
+		$this->assertContains( 'Cornerstone Page', $updated_states );
+	}
+}
diff --git a/projects/plugins/boost/tests/php/lib/mocks/Mock_Premium_Features.php b/projects/plugins/boost/tests/php/lib/mocks/Mock_Premium_Features.php
new file mode 100644
index 000000000000..c5f4e2fcc307
--- /dev/null
+++ b/projects/plugins/boost/tests/php/lib/mocks/Mock_Premium_Features.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Automattic\Jetpack_Boost\Tests\Lib\Mocks;
+
+class Mock_Premium_Features {
+	const CORNERSTONE_TEN_PAGES = 'cornerstone_ten_pages';
+
+	/**
+	 * Mock the premium feature check when it's true.
+	 *
+	 * @var bool
+	 */
+	private static $mock_return = false;
+
+	public static function has_feature( $feature ) {
+		if ( $feature === self::CORNERSTONE_TEN_PAGES ) {
+			return self::$mock_return;
+		}
+		return false;
+	}
+
+	public static function set_mock_return( $value ) {
+		self::$mock_return = (bool) $value;
+	}
+}
+
+class_alias(
+	'Automattic\Jetpack_Boost\Tests\Lib\Mocks\Mock_Premium_Features',
+	'Automattic\Jetpack_Boost\Lib\Premium_Features'
+);


Reviews:
user: login: haqadn
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T17:39:46Z
user: login: donnchawp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T15:49:32Z
user: login: donnchawp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T15:49:58Z
user: login: haqadn
body: I have a small suggestion. After it, it looks good to merge.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T17:07:30Z
user: login: haqadn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T17:08:27Z
user: login: donnchawp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T09:34:38Z
user: login: haqadn
body: LGTM!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T16:32:07Z

Review comments:
diff_hunk: @@ -0,0 +1,109 @@
+import { __ } from '@wordpress/i18n';
+import { ToggleControl } from '@wordpress/components';
+import styles from './prerender.module.scss';
+import { recordBoostEvent } from '$lib/utils/analytics';
+import { useMutationNotice } from '$features/ui';
+import { createInterpolateElement, useState } from '@wordpress/element';
+import { getRedirectUrl, IconTooltip } from '@automattic/jetpack-components';
+import { useDataSync } from '@automattic/jetpack-react-data-sync-client';
+import { z } from 'zod';
+const unsafeSpeculationRulesLink = getRedirectUrl( 'jetpack-boost-unsafe-speculation-rules' );
+
+const Prerender = () => {
+	const [ prerenderedEnabled, setPrerenderedEnabled ] = useDataSync(
+		'jetpack_boost_ds',
+		'prerender_cornerstone_pages',
+		z.boolean().catch( false )
+	);
+
+	const enabledMessage = __( 'Prerender enabled.', 'jetpack-boost' );
+	const disabledMessage = __( 'Prerender disabled.', 'jetpack-boost' );
+	useMutationNotice( 'prerender-cornerstone-pages', setPrerenderedEnabled, {
+		successMessage: prerenderedEnabled.data ? enabledMessage : disabledMessage,
+	} );
+
+	const isWordPressVersionSupported = Jetpack_Boost.wpVersion >= '6.8.0';
path: projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/prerender/prerender.tsx
user: login: haqadn
body: Can we just pass the **pre-rendering supported flag** itself from PHP? The reason I say this is becausue that would allow us to also disable this server-side. We can also take advantage of `version_compare` to make it supported for 6.8 RC and beta builds.
created_at: 2025-03-13T14:01:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,6 +38,9 @@ const CornerstonePages = () => {
 					<PanelRow>
 						<Meta />
 					</PanelRow>
+					<PanelRow>
path: projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/cornerstone-pages.tsx
user: login: haqadn
body: As discussed on the call, let's hide it completely for WP < 6.8
created_at: 2025-03-13T17:39:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,109 @@
+import { __ } from '@wordpress/i18n';
+import { ToggleControl } from '@wordpress/components';
+import styles from './prerender.module.scss';
+import { recordBoostEvent } from '$lib/utils/analytics';
+import { useMutationNotice } from '$features/ui';
+import { createInterpolateElement, useState } from '@wordpress/element';
+import { getRedirectUrl, IconTooltip } from '@automattic/jetpack-components';
+import { useDataSync } from '@automattic/jetpack-react-data-sync-client';
+import { z } from 'zod';
+const unsafeSpeculationRulesLink = getRedirectUrl( 'jetpack-boost-unsafe-speculation-rules' );
+
+const Prerender = () => {
+	const [ prerenderedEnabled, setPrerenderedEnabled ] = useDataSync(
+		'jetpack_boost_ds',
+		'prerender_cornerstone_pages',
+		z.boolean().catch( false )
+	);
+
+	const enabledMessage = __( 'Prerender enabled.', 'jetpack-boost' );
+	const disabledMessage = __( 'Prerender disabled.', 'jetpack-boost' );
+	useMutationNotice( 'prerender-cornerstone-pages', setPrerenderedEnabled, {
+		successMessage: prerenderedEnabled.data ? enabledMessage : disabledMessage,
+	} );
+
+	const isWordPressVersionSupported = Jetpack_Boost.wpVersion >= '6.8.0';
path: projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/prerender/prerender.tsx
user: login: donnchawp
body: Done. It's being used in cornerstone-pages.tsx instead.
created_at: 2025-03-24T15:49:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,6 +38,9 @@ const CornerstonePages = () => {
 					<PanelRow>
 						<Meta />
 					</PanelRow>
+					<PanelRow>
path: projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/cornerstone-pages.tsx
user: login: donnchawp
body: Done. The toggle is entirely hidden now by checking the WP version in PHP.
created_at: 2025-03-24T15:49:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,4 +47,13 @@ public static function is_cornerstone_page_by_url( $url ) {
 	public static function is_cornerstone_page( $post_id ) {
 		return self::is_cornerstone_page_by_url( get_permalink( $post_id ) );
 	}
+
+	/**
+	 * Is WordPress 6.8.0+ Speculation Rules API supported?
+	 *
+	 * @return bool True if the Speculation Rules API is supported, false otherwise.
+	 */
+	public static function get_speculation_rules_api_support_status() {
+		return version_compare( get_bloginfo( 'version' ), '6.8.0', '>=' );
path: projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php
user: login: haqadn
body: Should we use `6.8-beta3` to make it testable with the current beta and rc versions? versions?
created_at: 2025-03-24T17:02:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,4 +47,13 @@ public static function is_cornerstone_page_by_url( $url ) {
 	public static function is_cornerstone_page( $post_id ) {
 		return self::is_cornerstone_page_by_url( get_permalink( $post_id ) );
 	}
+
+	/**
+	 * Is WordPress 6.8.0+ Speculation Rules API supported?
+	 *
+	 * @return bool True if the Speculation Rules API is supported, false otherwise.
+	 */
+	public static function get_speculation_rules_api_support_status() {
+		return version_compare( get_bloginfo( 'version' ), '6.8.0', '>=' );
path: projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php
user: login: haqadn
body: I haven't tested this.
```suggestion
		return version_compare( get_bloginfo( 'version' ), '6.8-beta3, '>=' );
```
created_at: 2025-03-24T17:08:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,4 +47,13 @@ public static function is_cornerstone_page_by_url( $url ) {
 	public static function is_cornerstone_page( $post_id ) {
 		return self::is_cornerstone_page_by_url( get_permalink( $post_id ) );
 	}
+
+	/**
+	 * Is WordPress 6.8.0+ Speculation Rules API supported?
+	 *
+	 * @return bool True if the Speculation Rules API is supported, false otherwise.
+	 */
+	public static function get_speculation_rules_api_support_status() {
+		return version_compare( get_bloginfo( 'version' ), '6.8.0', '>=' );
path: projects/plugins/boost/app/lib/cornerstone/Cornerstone_Utils.php
user: login: donnchawp
body: That works, I just updated the PR with that.
created_at: 2025-03-25T09:34:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-11T17:18:11Z
body: **Thank you for your PR!**

When contributing to Jetpack, we have [a few suggestions](https://github.com/Automattic/jetpack/blob/trunk/.github/PULL_REQUEST_TEMPLATE.md) that can help us test and review your patch:<br>
- :white_check_mark: Include a description of your PR changes.<br>
- :white_check_mark: Add a "[Status]" label (In Progress, Needs Review, ...).<br>
- :white_check_mark: Add a "[Type]" label (Bug, Enhancement, Janitorial, Task).<br>
- :white_check_mark: Add testing instructions.<br>
- :white_check_mark: Specify whether this PR includes any changes to data or privacy.<br>
- :white_check_mark: Add changelog entries to affected projects<br>


This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation :robot:

******

**Follow this PR Review Process:**

1. Ensure all required checks appearing at the bottom of this PR are passing.
2. Make sure to test your changes on all platforms that it applies to. *You're responsible for the quality of the code you ship*.
3. You can use [GitHub's Reviewers functionality](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/requesting-a-pull-request-review) to request a review.
4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!
******

**Boost plugin:**

- Next scheduled release: _April 2, 2025_


If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.

author_association: CONTRIBUTOR
user: login: jp-launch-control[bot]
created_at: 2025-03-11T17:21:29Z
body: ### Code Coverage Summary

Coverage changed in 6 files. Only the first 5 are listed here.

| File | Coverage | Δ% | Δ Uncovered |
| :--- | :------: | :---: | :---: |
| projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/meta/meta.tsx | 0/65 (0.00%) | 0.00% | 4 💔 |
| projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/cornerstone-pages.tsx | 0/18 (0.00%) | 0.00% | 1 ❤️‍🩹 |
| projects/plugins/boost/app/lib/cornerstone/Cornerstone&#95;Utils.php | 0/12 (0.00%) | 0.00% | 1 ❤️‍🩹 |
| projects/plugins/boost/app/data-sync/Cornerstone&#95;Pages&#95;Entry.php | 1/22 (4.55%) | 4.55% | -1 💚 |
| projects/plugins/boost/app/admin/class-config.php | 36/36 (100.00%) | 100.00% | -36 💚 |

1 file is newly checked for coverage.

| File | Coverage |
| :--- | :------: |
| projects/plugins/boost/app/assets/src/js/features/cornerstone-pages/prerender/prerender.tsx | 0/16 (0.00%) 💔 |



[Full summary](https://jetpackcodecoverage.atomicsites.blog/prs/42374/) · [PHP report](https://jetpackcodecoverage.atomicsites.blog/prs/42374/php/) · [JS report](https://jetpackcodecoverage.atomicsites.blog/prs/42374/js/)

<sub>Coverage check overridden by https://github.com/Automattic/jetpack/labels/I%20don%27t%20care%20about%20code%20coverage%20for%20this%20PR.</sub>
author_association: NONE